@crisp-ui-kit/crisp 0.53.3 → 0.53.5

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 (39) hide show
  1. package/dist/cjs/components/heatmapchart/heatmapchart.js +24 -0
  2. package/dist/cjs/components/heatmapchart/heatmapchart.recipe.js +49 -0
  3. package/dist/cjs/components/heatmapchart/index.js +17 -0
  4. package/dist/cjs/components/toast/toast.recipe.js +8 -4
  5. package/dist/cjs/core/i18n.js +17 -2
  6. package/dist/cjs/core/index.js +2 -1
  7. package/dist/cjs/index.js +1 -0
  8. package/dist/cjs/product/recordpage/recordpage.js +17 -2
  9. package/dist/cjs/product/recordstable/recordstable.js +14 -3
  10. package/dist/cjs/product/richtext/richtext.js +36 -7
  11. package/dist/cjs/product/settingssection/settingssection.recipe.js +11 -2
  12. package/dist/esm/components/heatmapchart/heatmapchart.js +21 -0
  13. package/dist/esm/components/heatmapchart/heatmapchart.recipe.js +46 -0
  14. package/dist/esm/components/heatmapchart/index.js +1 -0
  15. package/dist/esm/components/toast/toast.recipe.js +9 -5
  16. package/dist/esm/core/i18n.js +16 -2
  17. package/dist/esm/core/index.js +1 -1
  18. package/dist/esm/index.js +1 -0
  19. package/dist/esm/product/recordpage/recordpage.js +18 -3
  20. package/dist/esm/product/recordstable/recordstable.js +15 -4
  21. package/dist/esm/product/richtext/richtext.js +36 -7
  22. package/dist/esm/product/settingssection/settingssection.recipe.js +11 -2
  23. package/dist/heatmapchart.css +27 -0
  24. package/dist/heatmapchart.d.ts +7 -0
  25. package/dist/heatmapchart.layered.css +29 -0
  26. package/dist/settingssection.css +2 -2
  27. package/dist/settingssection.layered.css +2 -2
  28. package/dist/styles.css +32 -4
  29. package/dist/styles.layered.css +32 -4
  30. package/dist/toast.css +2 -2
  31. package/dist/toast.layered.css +2 -2
  32. package/dist/types/components/heatmapchart/heatmapchart.d.ts +51 -0
  33. package/dist/types/components/heatmapchart/heatmapchart.recipe.d.ts +3 -0
  34. package/dist/types/components/heatmapchart/index.d.ts +1 -0
  35. package/dist/types/core/i18n.d.ts +30 -1
  36. package/dist/types/core/index.d.ts +1 -1
  37. package/dist/types/index.d.ts +1 -0
  38. package/dist/types/product/richtext/richtext.d.ts +20 -0
  39. package/package.json +6 -1
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HeatmapChart = HeatmapChart;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const heatmapchart_recipe_1 = require("./heatmapchart.recipe");
6
+ /**
7
+ * An NxM cell-matrix chart — GRC's own risk heatmap (impact x likelihood)
8
+ * is the first consumer. Severity/intensity mapping (`tone`, `ring`) is
9
+ * entirely the caller's own data, not a scale this component invents; see
10
+ * the recipe's own doc comment for why (and for its measurement — or lack
11
+ * of one — against Attio).
12
+ */
13
+ function HeatmapChart({ data, rowAxis, colAxis, format = (cell) => (cell.value ? cell.value : ""), cellTitle, onCellClick, isCellClickable = (cell) => cell.value > 0, empty, emptyLabel = "Nothing to plot yet.", "aria-label": ariaLabel, className, ...rest }) {
14
+ const cols = data[0]?.length ?? 0;
15
+ const summary = ariaLabel ?? `Heatmap: ${data.length} x ${cols} grid`;
16
+ return ((0, jsx_runtime_1.jsx)("div", { className: (0, heatmapchart_recipe_1.heatmapchartRecipe)({ className }), ...rest, role: "img", "aria-label": summary, children: empty ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-heatmapchart-empty", children: emptyLabel })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-heatmapchart-rowaxis", children: [(0, jsx_runtime_1.jsx)("span", { children: rowAxis.high }), rowAxis.title != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-heatmapchart-rowaxis-title", children: rowAxis.title })), (0, jsx_runtime_1.jsx)("span", { children: rowAxis.low })] }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-heatmapchart-body", children: [(0, jsx_runtime_1.jsx)("div", { className: "crisp-heatmapchart-grid", style: { gridTemplateColumns: `repeat(${cols}, 1fr)` }, children: data.map((row, r) => row.map((cell, cc) => {
17
+ const clickable = onCellClick != null && isCellClickable(cell, r, cc);
18
+ const title = cellTitle?.(cell, r, cc);
19
+ const key = `${r}-${cc}`;
20
+ const tone = cell.tone ?? "neutral";
21
+ return clickable ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-heatmapchart-cell", "data-tone": tone, "data-ring": cell.ring ? "" : undefined, title: title, onClick: () => onCellClick?.(r, cc, cell), children: format(cell, r, cc) }, key)) : ((0, jsx_runtime_1.jsx)("div", { className: "crisp-heatmapchart-cell", "data-tone": tone, "data-ring": cell.ring ? "" : undefined, title: title, children: format(cell, r, cc) }, key));
22
+ })) }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-heatmapchart-colaxis", children: [(0, jsx_runtime_1.jsx)("span", { children: colAxis.low }), colAxis.title != null && (0, jsx_runtime_1.jsx)("span", { children: colAxis.title }), (0, jsx_runtime_1.jsx)("span", { children: colAxis.high })] })] })] })) }));
23
+ }
24
+ HeatmapChart.displayName = "HeatmapChart";
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.heatmapchartRecipe = void 0;
4
+ const core_1 = require("../../core");
5
+ const tokens_1 = require("../../tokens");
6
+ const soft = (v, pct) => `color-mix(in srgb, ${v} ${pct}%, transparent)`;
7
+ // An NxM cell-matrix chart, alongside BarChart/LineChart/DonutChart — GRC's
8
+ // own risk heatmap (impact x likelihood) is the first consumer. No Attio
9
+ // widget in the workspace this was checked against (Reports/Dashboards:
10
+ // bar/line/number report tiles) has a matching matrix chart to measure a
11
+ // pixel value from, so this is composed entirely from already-measured
12
+ // crisp values: the same `color-mix` soft-tint recipe Badge's own `soft()`
13
+ // helper and StatCard's delta pill already use, at the same 14-16%,
14
+ // against the token contract's own success/warning/orange/danger roles.
15
+ // A cell's text stays `fgPrimary` at every tone — not tone-matched — the
16
+ // same "the background already carries the severity, tone-on-tone measures
17
+ // illegible" reasoning GRC's own hand-built heatmap already recorded.
18
+ const config = (0, core_1.defineRecipe)({
19
+ name: "heatmapchart",
20
+ base: `display: flex; gap: ${tokens_1.contract.space["2"]}; box-sizing: border-box; font-family: ${tokens_1.contract.font.sans};
21
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
22
+
23
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: ${tokens_1.contract.space["1"]} 0; font-size: ${tokens_1.contract.text.xs}; font-weight: ${tokens_1.contract.weight.medium}; text-transform: uppercase; letter-spacing: 0.02em; color: ${tokens_1.contract.fgNeutralSubtle}; }
24
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
25
+
26
+ & .crisp-heatmapchart-body { flex: 1; }
27
+ & .crisp-heatmapchart-grid { display: grid; gap: ${tokens_1.contract.space["1.5"]}; }
28
+
29
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: ${tokens_1.contract.radius.md}; border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.semibold}; font-variant-numeric: tabular-nums; color: ${tokens_1.contract.fgPrimary}; }
30
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
31
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
32
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: 1px; }
33
+
34
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: ${tokens_1.contract.bgRaised}; color: ${tokens_1.contract.fgMuted}; }
35
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: ${soft(tokens_1.contract.bgSuccess, 16)}; }
36
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: ${soft(tokens_1.contract.bgWarning, 16)}; }
37
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: ${soft(tokens_1.contract.bgOrange, 18)}; }
38
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: ${soft(tokens_1.contract.bgDanger, 16)}; }
39
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px ${tokens_1.contract.bgDangerSolid}; }
40
+
41
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: ${tokens_1.contract.space["1.5"]}; padding: 0 ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.xs}; font-weight: ${tokens_1.contract.weight.medium}; text-transform: uppercase; letter-spacing: 0.02em; color: ${tokens_1.contract.fgNeutralSubtle}; }
42
+
43
+ & .crisp-heatmapchart-empty { padding: ${tokens_1.contract.space["8"]} 0; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }`,
44
+ variants: {},
45
+ defaultVariants: {},
46
+ compoundVariants: [],
47
+ });
48
+ exports.heatmapchartRecipe = (0, core_1.resolveRecipe)(config);
49
+ exports.default = config;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./heatmapchart"), exports);
@@ -3,14 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.toastRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
- // Toast notifications — a fixed bottom-right stack of cards on the measured
6
+ // Toast notifications — a fixed TOP-right stack of cards on the measured
7
7
  // popover surface, each with a tone icon, title/description, optional action,
8
- // and a close button.
8
+ // and a close button. Position, radius, padding, surface and shadow measured
9
+ // live off Attio's own "Copied to clipboard!" toast (crisp#515): the stack
10
+ // anchors top-right (not bottom-right), and the card shares Menu/Popover's
11
+ // `bgPopover` surface + `elevation.floating` shadow (Lesson 31 — reference
12
+ // the shared token, never a hand-typed near-duplicate).
9
13
  const config = (0, core_1.defineRecipe)({
10
14
  name: "toast",
11
15
  base: `display: contents; font-family: ${tokens_1.contract.font.sans};
12
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: ${tokens_1.contract.space["4"]}; bottom: ${tokens_1.contract.space["4"]}; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]}; width: 360px; max-width: calc(100vw - ${tokens_1.contract.space["8"]}); pointer-events: none; }
13
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: ${tokens_1.contract.space["2.5"]}; box-sizing: border-box; padding: ${tokens_1.contract.space["3"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgRaised}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.fgPrimary} 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
16
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: ${tokens_1.contract.space["4"]}; top: ${tokens_1.contract.space["4"]}; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]}; width: 360px; max-width: calc(100vw - ${tokens_1.contract.space["8"]}); pointer-events: none; }
17
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: ${tokens_1.contract.space["2.5"]}; box-sizing: border-box; padding: ${tokens_1.contract.space["2.5"]}; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgPopover}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
14
18
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: ${tokens_1.contract.fgMuted}; }
15
19
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: ${tokens_1.contract.bgBrandSolid}; }
16
20
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: ${tokens_1.contract.bgSuccess}; }
@@ -36,11 +36,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CrispMessagesProvider = CrispMessagesProvider;
37
37
  exports.useCrispMessage = useCrispMessage;
38
38
  exports.useCrispMessages = useCrispMessages;
39
+ exports.useCrispComponentDefaults = useCrispComponentDefaults;
39
40
  const jsx_runtime_1 = require("react/jsx-runtime");
40
41
  const React = __importStar(require("react"));
41
42
  const CrispMessagesContext = React.createContext({});
42
- function CrispMessagesProvider({ messages, children, }) {
43
- return ((0, jsx_runtime_1.jsx)(CrispMessagesContext.Provider, { value: messages, children: children }));
43
+ const CrispComponentDefaultsContext = React.createContext({});
44
+ function CrispMessagesProvider({ messages, componentDefaults, children, }) {
45
+ return ((0, jsx_runtime_1.jsx)(CrispMessagesContext.Provider, { value: messages, children: (0, jsx_runtime_1.jsx)(CrispComponentDefaultsContext.Provider, { value: componentDefaults ?? {}, children: children }) }));
44
46
  }
45
47
  /**
46
48
  * Resolves one translatable string for a component: `explicit` is whatever
@@ -73,3 +75,16 @@ function useCrispMessage(explicit, englishDefault) {
73
75
  function useCrispMessages() {
74
76
  return React.useContext(CrispMessagesContext);
75
77
  }
78
+ /**
79
+ * Reads one component's own whole-object defaults override, set via
80
+ * `CrispMessagesProvider`'s `componentDefaults` prop (see its own doc for
81
+ * why this exists separately from `useCrispMessage`). `T` is the
82
+ * component's own labels TYPE (e.g. `Partial<RecordPageLabels>`) — this
83
+ * hook itself only knows `name`, a plain string key; the caller supplies
84
+ * and trusts the real shape, the same way `useContext` always has for an
85
+ * untyped-at-the-provider value.
86
+ */
87
+ function useCrispComponentDefaults(name) {
88
+ const all = React.useContext(CrispComponentDefaultsContext);
89
+ return all[name];
90
+ }
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.usePrefersReducedMotion = exports.useMentionTrigger = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.matchMentionTrigger = exports.useCrispMessages = exports.useCrispMessage = exports.CrispMessagesProvider = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = exports.caretRectInField = void 0;
17
+ exports.usePrefersReducedMotion = exports.useMentionTrigger = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.matchMentionTrigger = exports.useCrispMessages = exports.useCrispMessage = exports.useCrispComponentDefaults = exports.CrispMessagesProvider = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.hasOpenFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = exports.cellDatePrecision = exports.caretRectInField = void 0;
18
18
  var caret_rect_1 = require("./caret-rect");
19
19
  Object.defineProperty(exports, "caretRectInField", { enumerable: true, get: function () { return caret_rect_1.caretRectInField; } });
20
20
  var cell_date_1 = require("./cell-date");
@@ -39,6 +39,7 @@ var form_value_1 = require("./form-value");
39
39
  Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
40
40
  var i18n_1 = require("./i18n");
41
41
  Object.defineProperty(exports, "CrispMessagesProvider", { enumerable: true, get: function () { return i18n_1.CrispMessagesProvider; } });
42
+ Object.defineProperty(exports, "useCrispComponentDefaults", { enumerable: true, get: function () { return i18n_1.useCrispComponentDefaults; } });
42
43
  Object.defineProperty(exports, "useCrispMessage", { enumerable: true, get: function () { return i18n_1.useCrispMessage; } });
43
44
  Object.defineProperty(exports, "useCrispMessages", { enumerable: true, get: function () { return i18n_1.useCrispMessages; } });
44
45
  var mention_1 = require("./mention");
package/dist/cjs/index.js CHANGED
@@ -45,6 +45,7 @@ __exportStar(require("./components/fileupload"), exports);
45
45
  __exportStar(require("./components/filldoc"), exports);
46
46
  __exportStar(require("./components/fillsheet"), exports);
47
47
  __exportStar(require("./components/formfield"), exports);
48
+ __exportStar(require("./components/heatmapchart"), exports);
48
49
  __exportStar(require("./components/input"), exports);
49
50
  __exportStar(require("./components/kbd"), exports);
50
51
  __exportStar(require("./components/linechart"), exports);
@@ -257,19 +257,34 @@ function EmptyCard({ icon, children, }) {
257
257
  */
258
258
  function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
259
259
  const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, recordstable_1.useEditableField)(onEdit);
260
+ // crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
261
+ // BETWEEN the built-in English defaults and this instance's own explicit
262
+ // `labels` prop — a provider translates every RecordPage on the page at
263
+ // once, and a caller who still passes `labels` for one instance keeps
264
+ // overriding it there, same as before this existed. Not `useCrispMessage`:
265
+ // several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
266
+ // FUNCTIONS, which a flat string dictionary can't hold.
267
+ const providerLabels = (0, core_1.useCrispComponentDefaults)("recordPage");
260
268
  const t = React.useMemo(() => ({
261
269
  ...exports.recordPageLabels,
270
+ ...providerLabels,
262
271
  ...labels,
263
- tabs: { ...exports.recordPageLabels.tabs, ...labels?.tabs },
272
+ tabs: {
273
+ ...exports.recordPageLabels.tabs,
274
+ ...providerLabels?.tabs,
275
+ ...labels?.tabs,
276
+ },
264
277
  highlightLabels: {
265
278
  ...exports.recordPageLabels.highlightLabels,
279
+ ...providerLabels?.highlightLabels,
266
280
  ...labels?.highlightLabels,
267
281
  },
268
282
  highlightEmpty: {
269
283
  ...exports.recordPageLabels.highlightEmpty,
284
+ ...providerLabels?.highlightEmpty,
270
285
  ...labels?.highlightEmpty,
271
286
  },
272
- }), [labels]);
287
+ }), [labels, providerLabels]);
273
288
  const [favorite, setFavorite] = React.useState(false);
274
289
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
275
290
  // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
@@ -495,12 +495,23 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
495
495
  const [filterConds, setFilterConds] = React.useState([]);
496
496
  const [filterQuery, setFilterQuery] = React.useState("");
497
497
  // one popover open at a time: "sort" | "filter" | "more" | `sum:<key>`
498
+ // crisp/#471: same "provider sits between the built-in default and this
499
+ // instance's own explicit labels prop" order as RecordPage's own — see
500
+ // its comment. Several entries here (`selectionAnnouncement`, `sortedBy`,
501
+ // `recordCount`, …) are formatter functions, which is why this goes
502
+ // through `useCrispComponentDefaults`, not `useCrispMessage`.
503
+ const providerLabels = (0, core_1.useCrispComponentDefaults)("recordsTable");
498
504
  const t = React.useMemo(() => ({
499
505
  ...exports.recordsTableLabels,
506
+ ...providerLabels,
500
507
  ...labels,
501
- agg: { ...exports.recordsTableLabels.agg, ...labels?.agg },
502
- op: { ...exports.recordsTableLabels.op, ...labels?.op },
503
- }), [labels]);
508
+ agg: {
509
+ ...exports.recordsTableLabels.agg,
510
+ ...providerLabels?.agg,
511
+ ...labels?.agg,
512
+ },
513
+ op: { ...exports.recordsTableLabels.op, ...providerLabels?.op, ...labels?.op },
514
+ }), [labels, providerLabels]);
504
515
  const [openMenu, setOpenMenu] = React.useState(null);
505
516
  // the element that opened the current menu, so Esc/select can hand focus back
506
517
  const menuReturn = React.useRef(null);
@@ -149,7 +149,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
149
149
  * reach the identical menu, sitting at the end of the body's last line) —
150
150
  * only the two entry points #426 asked for.
151
151
  */
152
- exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
152
+ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, showTitle = true, ...rest }, ref) {
153
153
  const titleRef = React.useRef(null);
154
154
  const bodyRef = React.useRef(null);
155
155
  const [bar, setBar] = React.useState(null);
@@ -170,6 +170,19 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
170
170
  // entirely", the same distinction `updateBar` already makes for the block
171
171
  // menu's search input below.
172
172
  const mentionPickerRef = React.useRef(null);
173
+ // Whether focus currently sits somewhere inside whatever
174
+ // `renderMentionPicker` returned (#514) — tracked through React's own
175
+ // onFocus/onBlur, not `mentionPickerRef.current.contains(activeElement)`.
176
+ // A picker built from crisp's own `Command` (the shape #372's own doc
177
+ // recommends) renders through a `Portal`, so its DOM parent is nowhere
178
+ // near `mentionPickerRef` even though it's still that ref's REACT child;
179
+ // `.contains()` sees a stranger the instant the picker's search input
180
+ // takes focus and updateMention closes the picker it was just asked to
181
+ // protect. React's synthetic focus/blur events bubble through the
182
+ // component tree a portal renders into, not the DOM tree it renders to
183
+ // (documented React behaviour), so a plain onFocus/onBlur pair on this
184
+ // same wrapper sees the portal's contents correctly.
185
+ const mentionPickerHasFocusRef = React.useRef(false);
173
186
  const syncEmpty = (el) => {
174
187
  if (el)
175
188
  el.toggleAttribute("data-empty", (el.textContent ?? "") === "");
@@ -291,6 +304,11 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
291
304
  }, [readOnly, blockMenu, closeBlockMenu]);
292
305
  const closeMention = React.useCallback(() => {
293
306
  mentionRangeRef.current = null;
307
+ // Defensive, not load-bearing: the wrapper's onBlur already clears this
308
+ // when the picker's own focused element leaves the DOM on unmount, but
309
+ // resetting it directly here means a stale `true` can never survive
310
+ // past whatever actually closed the picker, blur-ordering included.
311
+ mentionPickerHasFocusRef.current = false;
294
312
  setMention(null);
295
313
  }, []);
296
314
  /**
@@ -302,16 +320,23 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
302
320
  * no trigger.
303
321
  *
304
322
  * Does NOT auto-close while focus sits inside the consumer's own picker
305
- * (`mentionPickerRef`) — a picker with its own search field would
323
+ * (`mentionPickerHasFocusRef`) — a picker with its own search field would
306
324
  * otherwise close itself the instant that field took focus, since focus
307
325
  * moving there also moves the document selection out of `body`. This is
308
326
  * the same distinction `updateBar` already draws for the block menu's own
309
- * search input, made while merging the two features together.
327
+ * search input, made while merging the two features together — except a
328
+ * consumer's picker, unlike the block menu's own inline search, is free
329
+ * to render through a Portal (crisp's own `Command`, the shape #372's own
330
+ * doc recommends building one from, does exactly that), so this reads a
331
+ * ref React's focus/blur bubbling keeps current instead of asking
332
+ * `mentionPickerRef`'s DOM node whether it contains `activeElement` (#514
333
+ * — a Portal's content is a DOM stranger to its React parent, so that
334
+ * check silently stopped protecting the one case it exists for).
310
335
  */
311
336
  const updateMention = React.useCallback(() => {
312
337
  if (readOnly)
313
338
  return;
314
- if (mentionPickerRef.current?.contains(document.activeElement))
339
+ if (mentionPickerHasFocusRef.current)
315
340
  return;
316
341
  const body = bodyRef.current;
317
342
  const sel = window.getSelection();
@@ -525,12 +550,12 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
525
550
  ol: (0, jsx_runtime_1.jsx)(lucide_react_1.ListOrdered, { ...menuIc }),
526
551
  image: (0, jsx_runtime_1.jsx)(lucide_react_1.Image, { ...menuIc }),
527
552
  };
528
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, richtext_recipe_1.richtextRecipe)({ className }), ...rest, children: [(0, jsx_runtime_1.jsx)("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
553
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, richtext_recipe_1.richtextRecipe)({ className }), ...rest, children: [showTitle && ((0, jsx_runtime_1.jsx)("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
529
554
  ? undefined
530
555
  : () => {
531
556
  syncEmpty(titleRef.current);
532
557
  emit();
533
- } }), (0, jsx_runtime_1.jsx)("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
558
+ } })), (0, jsx_runtime_1.jsx)("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
534
559
  ? undefined
535
560
  : () => {
536
561
  syncEmpty(bodyRef.current);
@@ -564,7 +589,11 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
564
589
  select: insertMention,
565
590
  close: closeMention,
566
591
  });
567
- return rendered == null ? null : ((0, jsx_runtime_1.jsx)("div", { ref: mentionPickerRef, style: { display: "contents" }, children: rendered }));
592
+ return rendered == null ? null : ((0, jsx_runtime_1.jsx)("div", { ref: mentionPickerRef, style: { display: "contents" }, onFocus: () => {
593
+ mentionPickerHasFocusRef.current = true;
594
+ }, onBlur: () => {
595
+ mentionPickerHasFocusRef.current = false;
596
+ }, children: rendered }));
568
597
  })()] }));
569
598
  });
570
599
  exports.RichTextEditor.displayName = "RichTextEditor";
@@ -6,13 +6,22 @@ const tokens_1 = require("../../tokens");
6
6
  // A settings section — measured from Settings pages: a 16px/600 title
7
7
  // with an optional subtitle, then a stack of rows (form fields, toggles…). Rows
8
8
  // gap evenly; an optional trailing action sits on the title row.
9
+ //
10
+ // Title line-height/letter-spacing and subtitle font-size/line-height
11
+ // re-measured live at app.attio.com/<workspace>/settings/account against a
12
+ // subsection heading ("Time preferences") — crisp#522. The subtitle's own
13
+ // colour (measured `rgba(0,0,0,0.63)`, a black-alpha value) is left as
14
+ // `fgMuted` pending a dark-mode measurement: a raw alpha-black has no
15
+ // existing token match and would need its own light/dark pair to stay
16
+ // legible in dark mode (Lesson: theme-constant vs theme-flipping colours
17
+ // must each be independently verified).
9
18
  const config = (0, core_1.defineRecipe)({
10
19
  name: "settingssection",
11
20
  base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${tokens_1.contract.font.sans};
12
21
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: ${tokens_1.contract.space["2"]}; }
13
22
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
14
- & .crisp-settingssection-title { font-size: ${tokens_1.contract.text.xl}; line-height: 24px; font-weight: ${tokens_1.contract.weight.semibold}; letter-spacing: -0.2px; color: ${tokens_1.contract.fgPrimary}; }
15
- & .crisp-settingssection-sub { margin-top: ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.md}; line-height: 18px; color: ${tokens_1.contract.fgMuted}; }
23
+ & .crisp-settingssection-title { font-size: ${tokens_1.contract.text.xl}; line-height: 20px; font-weight: ${tokens_1.contract.weight.semibold}; letter-spacing: -0.16px; color: ${tokens_1.contract.fgPrimary}; }
24
+ & .crisp-settingssection-sub { margin-top: ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; color: ${tokens_1.contract.fgMuted}; }
16
25
  & .crisp-settingssection-action { flex-shrink: 0; }
17
26
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: ${tokens_1.contract.space["4"]}; margin-top: ${tokens_1.contract.space["3"]}; }`,
18
27
  variants: {},
@@ -0,0 +1,21 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { heatmapchartRecipe } from "./heatmapchart.recipe.js";
3
+ /**
4
+ * An NxM cell-matrix chart — GRC's own risk heatmap (impact x likelihood)
5
+ * is the first consumer. Severity/intensity mapping (`tone`, `ring`) is
6
+ * entirely the caller's own data, not a scale this component invents; see
7
+ * the recipe's own doc comment for why (and for its measurement — or lack
8
+ * of one — against Attio).
9
+ */
10
+ export function HeatmapChart({ data, rowAxis, colAxis, format = (cell) => (cell.value ? cell.value : ""), cellTitle, onCellClick, isCellClickable = (cell) => cell.value > 0, empty, emptyLabel = "Nothing to plot yet.", "aria-label": ariaLabel, className, ...rest }) {
11
+ const cols = data[0]?.length ?? 0;
12
+ const summary = ariaLabel ?? `Heatmap: ${data.length} x ${cols} grid`;
13
+ return (_jsx("div", { className: heatmapchartRecipe({ className }), ...rest, role: "img", "aria-label": summary, children: empty ? (_jsx("div", { className: "crisp-heatmapchart-empty", children: emptyLabel })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "crisp-heatmapchart-rowaxis", children: [_jsx("span", { children: rowAxis.high }), rowAxis.title != null && (_jsx("span", { className: "crisp-heatmapchart-rowaxis-title", children: rowAxis.title })), _jsx("span", { children: rowAxis.low })] }), _jsxs("div", { className: "crisp-heatmapchart-body", children: [_jsx("div", { className: "crisp-heatmapchart-grid", style: { gridTemplateColumns: `repeat(${cols}, 1fr)` }, children: data.map((row, r) => row.map((cell, cc) => {
14
+ const clickable = onCellClick != null && isCellClickable(cell, r, cc);
15
+ const title = cellTitle?.(cell, r, cc);
16
+ const key = `${r}-${cc}`;
17
+ const tone = cell.tone ?? "neutral";
18
+ return clickable ? (_jsx("button", { type: "button", className: "crisp-heatmapchart-cell", "data-tone": tone, "data-ring": cell.ring ? "" : undefined, title: title, onClick: () => onCellClick?.(r, cc, cell), children: format(cell, r, cc) }, key)) : (_jsx("div", { className: "crisp-heatmapchart-cell", "data-tone": tone, "data-ring": cell.ring ? "" : undefined, title: title, children: format(cell, r, cc) }, key));
19
+ })) }), _jsxs("div", { className: "crisp-heatmapchart-colaxis", children: [_jsx("span", { children: colAxis.low }), colAxis.title != null && _jsx("span", { children: colAxis.title }), _jsx("span", { children: colAxis.high })] })] })] })) }));
20
+ }
21
+ HeatmapChart.displayName = "HeatmapChart";
@@ -0,0 +1,46 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ const soft = (v, pct) => `color-mix(in srgb, ${v} ${pct}%, transparent)`;
4
+ // An NxM cell-matrix chart, alongside BarChart/LineChart/DonutChart — GRC's
5
+ // own risk heatmap (impact x likelihood) is the first consumer. No Attio
6
+ // widget in the workspace this was checked against (Reports/Dashboards:
7
+ // bar/line/number report tiles) has a matching matrix chart to measure a
8
+ // pixel value from, so this is composed entirely from already-measured
9
+ // crisp values: the same `color-mix` soft-tint recipe Badge's own `soft()`
10
+ // helper and StatCard's delta pill already use, at the same 14-16%,
11
+ // against the token contract's own success/warning/orange/danger roles.
12
+ // A cell's text stays `fgPrimary` at every tone — not tone-matched — the
13
+ // same "the background already carries the severity, tone-on-tone measures
14
+ // illegible" reasoning GRC's own hand-built heatmap already recorded.
15
+ const config = defineRecipe({
16
+ name: "heatmapchart",
17
+ base: `display: flex; gap: ${c.space["2"]}; box-sizing: border-box; font-family: ${c.font.sans};
18
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
19
+
20
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: ${c.space["1"]} 0; font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; text-transform: uppercase; letter-spacing: 0.02em; color: ${c.fgNeutralSubtle}; }
21
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
22
+
23
+ & .crisp-heatmapchart-body { flex: 1; }
24
+ & .crisp-heatmapchart-grid { display: grid; gap: ${c.space["1.5"]}; }
25
+
26
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: ${c.radius.md}; border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.semibold}; font-variant-numeric: tabular-nums; color: ${c.fgPrimary}; }
27
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
28
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
29
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
30
+
31
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: ${c.bgRaised}; color: ${c.fgMuted}; }
32
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: ${soft(c.bgSuccess, 16)}; }
33
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: ${soft(c.bgWarning, 16)}; }
34
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: ${soft(c.bgOrange, 18)}; }
35
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: ${soft(c.bgDanger, 16)}; }
36
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px ${c.bgDangerSolid}; }
37
+
38
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: ${c.space["1.5"]}; padding: 0 ${c.space["0.5"]}; font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; text-transform: uppercase; letter-spacing: 0.02em; color: ${c.fgNeutralSubtle}; }
39
+
40
+ & .crisp-heatmapchart-empty { padding: ${c.space["8"]} 0; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }`,
41
+ variants: {},
42
+ defaultVariants: {},
43
+ compoundVariants: [],
44
+ });
45
+ export const heatmapchartRecipe = resolveRecipe(config);
46
+ export default config;
@@ -0,0 +1 @@
1
+ export * from "./heatmapchart.js";
@@ -1,13 +1,17 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
- import { contract as c } from "../../tokens/index.js";
3
- // Toast notifications — a fixed bottom-right stack of cards on the measured
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
+ // Toast notifications — a fixed TOP-right stack of cards on the measured
4
4
  // popover surface, each with a tone icon, title/description, optional action,
5
- // and a close button.
5
+ // and a close button. Position, radius, padding, surface and shadow measured
6
+ // live off Attio's own "Copied to clipboard!" toast (crisp#515): the stack
7
+ // anchors top-right (not bottom-right), and the card shares Menu/Popover's
8
+ // `bgPopover` surface + `elevation.floating` shadow (Lesson 31 — reference
9
+ // the shared token, never a hand-typed near-duplicate).
6
10
  const config = defineRecipe({
7
11
  name: "toast",
8
12
  base: `display: contents; font-family: ${c.font.sans};
9
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: ${c.space["4"]}; bottom: ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["2"]}; width: 360px; max-width: calc(100vw - ${c.space["8"]}); pointer-events: none; }
10
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: ${c.space["2.5"]}; box-sizing: border-box; padding: ${c.space["3"]}; border-radius: ${c.radius.xl}; background: ${c.bgRaised}; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
13
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: ${c.space["4"]}; top: ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["2"]}; width: 360px; max-width: calc(100vw - ${c.space["8"]}); pointer-events: none; }
14
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: ${c.space["2.5"]}; box-sizing: border-box; padding: ${c.space["2.5"]}; border-radius: ${c.radius.md}; background: ${c.bgPopover}; box-shadow: ${e.floating}; opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
11
15
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: ${c.fgMuted}; }
12
16
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: ${c.bgBrandSolid}; }
13
17
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: ${c.bgSuccess}; }
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  const CrispMessagesContext = React.createContext({});
4
- export function CrispMessagesProvider({ messages, children, }) {
5
- return (_jsx(CrispMessagesContext.Provider, { value: messages, children: children }));
4
+ const CrispComponentDefaultsContext = React.createContext({});
5
+ export function CrispMessagesProvider({ messages, componentDefaults, children, }) {
6
+ return (_jsx(CrispMessagesContext.Provider, { value: messages, children: _jsx(CrispComponentDefaultsContext.Provider, { value: componentDefaults ?? {}, children: children }) }));
6
7
  }
7
8
  /**
8
9
  * Resolves one translatable string for a component: `explicit` is whatever
@@ -35,3 +36,16 @@ export function useCrispMessage(explicit, englishDefault) {
35
36
  export function useCrispMessages() {
36
37
  return React.useContext(CrispMessagesContext);
37
38
  }
39
+ /**
40
+ * Reads one component's own whole-object defaults override, set via
41
+ * `CrispMessagesProvider`'s `componentDefaults` prop (see its own doc for
42
+ * why this exists separately from `useCrispMessage`). `T` is the
43
+ * component's own labels TYPE (e.g. `Partial<RecordPageLabels>`) — this
44
+ * hook itself only knows `name`, a plain string key; the caller supplies
45
+ * and trusts the real shape, the same way `useContext` always has for an
46
+ * untyped-at-the-provider value.
47
+ */
48
+ export function useCrispComponentDefaults(name) {
49
+ const all = React.useContext(CrispComponentDefaultsContext);
50
+ return all[name];
51
+ }
@@ -7,7 +7,7 @@ export { isRtlElement } from "./direction.js";
7
7
  export { FloatingPortal } from "./floating-portal.js";
8
8
  export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
9
9
  export { formValueStyle } from "./form-value.js";
10
- export { CrispMessagesProvider, useCrispMessage, useCrispMessages, } from "./i18n.js";
10
+ export { CrispMessagesProvider, useCrispComponentDefaults, useCrispMessage, useCrispMessages, } from "./i18n.js";
11
11
  export { matchMentionTrigger, } from "./mention.js";
12
12
  export { placeFloating, } from "./place-floating.js";
13
13
  export { Primitive } from "./primitive.js";
package/dist/esm/index.js CHANGED
@@ -29,6 +29,7 @@ export * from "./components/fileupload/index.js";
29
29
  export * from "./components/filldoc/index.js";
30
30
  export * from "./components/fillsheet/index.js";
31
31
  export * from "./components/formfield/index.js";
32
+ export * from "./components/heatmapchart/index.js";
32
33
  export * from "./components/input/index.js";
33
34
  export * from "./components/kbd/index.js";
34
35
  export * from "./components/linechart/index.js";
@@ -8,7 +8,7 @@ import { Select } from "../../components/select/index.js";
8
8
  import { Spinner } from "../../components/spinner/index.js";
9
9
  import { Switch } from "../../components/switch/index.js";
10
10
  import { Tabs } from "../../components/tabs/index.js";
11
- import { isRtlElement } from "../../core/index.js";
11
+ import { isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
12
12
  import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
13
13
  import { recordpageRecipe } from "./recordpage.recipe.js";
14
14
  function valueEmpty(v) {
@@ -220,19 +220,34 @@ function EmptyCard({ icon, children, }) {
220
220
  */
221
221
  export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
222
222
  const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
223
+ // crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
224
+ // BETWEEN the built-in English defaults and this instance's own explicit
225
+ // `labels` prop — a provider translates every RecordPage on the page at
226
+ // once, and a caller who still passes `labels` for one instance keeps
227
+ // overriding it there, same as before this existed. Not `useCrispMessage`:
228
+ // several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
229
+ // FUNCTIONS, which a flat string dictionary can't hold.
230
+ const providerLabels = useCrispComponentDefaults("recordPage");
223
231
  const t = React.useMemo(() => ({
224
232
  ...recordPageLabels,
233
+ ...providerLabels,
225
234
  ...labels,
226
- tabs: { ...recordPageLabels.tabs, ...labels?.tabs },
235
+ tabs: {
236
+ ...recordPageLabels.tabs,
237
+ ...providerLabels?.tabs,
238
+ ...labels?.tabs,
239
+ },
227
240
  highlightLabels: {
228
241
  ...recordPageLabels.highlightLabels,
242
+ ...providerLabels?.highlightLabels,
229
243
  ...labels?.highlightLabels,
230
244
  },
231
245
  highlightEmpty: {
232
246
  ...recordPageLabels.highlightEmpty,
247
+ ...providerLabels?.highlightEmpty,
233
248
  ...labels?.highlightEmpty,
234
249
  },
235
- }), [labels]);
250
+ }), [labels, providerLabels]);
236
251
  const [favorite, setFavorite] = React.useState(false);
237
252
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
238
253
  // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
@@ -14,7 +14,7 @@ import { Menu, MenuItem, useMenuKeyboard } from "../../components/menu/index.js"
14
14
  import { MultiSelect } from "../../components/multiselect/index.js";
15
15
  import { Select } from "../../components/select/index.js";
16
16
  import { Switch } from "../../components/switch/index.js";
17
- import { isInsideLaterFloatingLayer, isRtlElement, usePrefersReducedMotion, } from "../../core/index.js";
17
+ import { isInsideLaterFloatingLayer, isRtlElement, useCrispComponentDefaults, usePrefersReducedMotion, } from "../../core/index.js";
18
18
  import { companyLogos } from "./company-logos.js";
19
19
  import { recordstableRecipe } from "./recordstable.recipe.js";
20
20
  const EDITABLE = new Set(["text", "number", "url", "currency"]);
@@ -456,12 +456,23 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
456
456
  const [filterConds, setFilterConds] = React.useState([]);
457
457
  const [filterQuery, setFilterQuery] = React.useState("");
458
458
  // one popover open at a time: "sort" | "filter" | "more" | `sum:<key>`
459
+ // crisp/#471: same "provider sits between the built-in default and this
460
+ // instance's own explicit labels prop" order as RecordPage's own — see
461
+ // its comment. Several entries here (`selectionAnnouncement`, `sortedBy`,
462
+ // `recordCount`, …) are formatter functions, which is why this goes
463
+ // through `useCrispComponentDefaults`, not `useCrispMessage`.
464
+ const providerLabels = useCrispComponentDefaults("recordsTable");
459
465
  const t = React.useMemo(() => ({
460
466
  ...recordsTableLabels,
467
+ ...providerLabels,
461
468
  ...labels,
462
- agg: { ...recordsTableLabels.agg, ...labels?.agg },
463
- op: { ...recordsTableLabels.op, ...labels?.op },
464
- }), [labels]);
469
+ agg: {
470
+ ...recordsTableLabels.agg,
471
+ ...providerLabels?.agg,
472
+ ...labels?.agg,
473
+ },
474
+ op: { ...recordsTableLabels.op, ...providerLabels?.op, ...labels?.op },
475
+ }), [labels, providerLabels]);
465
476
  const [openMenu, setOpenMenu] = React.useState(null);
466
477
  // the element that opened the current menu, so Esc/select can hand focus back
467
478
  const menuReturn = React.useRef(null);
@@ -113,7 +113,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
113
113
  * reach the identical menu, sitting at the end of the body's last line) —
114
114
  * only the two entry points #426 asked for.
115
115
  */
116
- export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
116
+ export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, showTitle = true, ...rest }, ref) {
117
117
  const titleRef = React.useRef(null);
118
118
  const bodyRef = React.useRef(null);
119
119
  const [bar, setBar] = React.useState(null);
@@ -134,6 +134,19 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
134
134
  // entirely", the same distinction `updateBar` already makes for the block
135
135
  // menu's search input below.
136
136
  const mentionPickerRef = React.useRef(null);
137
+ // Whether focus currently sits somewhere inside whatever
138
+ // `renderMentionPicker` returned (#514) — tracked through React's own
139
+ // onFocus/onBlur, not `mentionPickerRef.current.contains(activeElement)`.
140
+ // A picker built from crisp's own `Command` (the shape #372's own doc
141
+ // recommends) renders through a `Portal`, so its DOM parent is nowhere
142
+ // near `mentionPickerRef` even though it's still that ref's REACT child;
143
+ // `.contains()` sees a stranger the instant the picker's search input
144
+ // takes focus and updateMention closes the picker it was just asked to
145
+ // protect. React's synthetic focus/blur events bubble through the
146
+ // component tree a portal renders into, not the DOM tree it renders to
147
+ // (documented React behaviour), so a plain onFocus/onBlur pair on this
148
+ // same wrapper sees the portal's contents correctly.
149
+ const mentionPickerHasFocusRef = React.useRef(false);
137
150
  const syncEmpty = (el) => {
138
151
  if (el)
139
152
  el.toggleAttribute("data-empty", (el.textContent ?? "") === "");
@@ -255,6 +268,11 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
255
268
  }, [readOnly, blockMenu, closeBlockMenu]);
256
269
  const closeMention = React.useCallback(() => {
257
270
  mentionRangeRef.current = null;
271
+ // Defensive, not load-bearing: the wrapper's onBlur already clears this
272
+ // when the picker's own focused element leaves the DOM on unmount, but
273
+ // resetting it directly here means a stale `true` can never survive
274
+ // past whatever actually closed the picker, blur-ordering included.
275
+ mentionPickerHasFocusRef.current = false;
258
276
  setMention(null);
259
277
  }, []);
260
278
  /**
@@ -266,16 +284,23 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
266
284
  * no trigger.
267
285
  *
268
286
  * Does NOT auto-close while focus sits inside the consumer's own picker
269
- * (`mentionPickerRef`) — a picker with its own search field would
287
+ * (`mentionPickerHasFocusRef`) — a picker with its own search field would
270
288
  * otherwise close itself the instant that field took focus, since focus
271
289
  * moving there also moves the document selection out of `body`. This is
272
290
  * the same distinction `updateBar` already draws for the block menu's own
273
- * search input, made while merging the two features together.
291
+ * search input, made while merging the two features together — except a
292
+ * consumer's picker, unlike the block menu's own inline search, is free
293
+ * to render through a Portal (crisp's own `Command`, the shape #372's own
294
+ * doc recommends building one from, does exactly that), so this reads a
295
+ * ref React's focus/blur bubbling keeps current instead of asking
296
+ * `mentionPickerRef`'s DOM node whether it contains `activeElement` (#514
297
+ * — a Portal's content is a DOM stranger to its React parent, so that
298
+ * check silently stopped protecting the one case it exists for).
274
299
  */
275
300
  const updateMention = React.useCallback(() => {
276
301
  if (readOnly)
277
302
  return;
278
- if (mentionPickerRef.current?.contains(document.activeElement))
303
+ if (mentionPickerHasFocusRef.current)
279
304
  return;
280
305
  const body = bodyRef.current;
281
306
  const sel = window.getSelection();
@@ -489,12 +514,12 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
489
514
  ol: _jsx(ListOrdered, { ...menuIc }),
490
515
  image: _jsx(ImageIcon, { ...menuIc }),
491
516
  };
492
- return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
517
+ return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [showTitle && (_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
493
518
  ? undefined
494
519
  : () => {
495
520
  syncEmpty(titleRef.current);
496
521
  emit();
497
- } }), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
522
+ } })), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
498
523
  ? undefined
499
524
  : () => {
500
525
  syncEmpty(bodyRef.current);
@@ -528,7 +553,11 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
528
553
  select: insertMention,
529
554
  close: closeMention,
530
555
  });
531
- return rendered == null ? null : (_jsx("div", { ref: mentionPickerRef, style: { display: "contents" }, children: rendered }));
556
+ return rendered == null ? null : (_jsx("div", { ref: mentionPickerRef, style: { display: "contents" }, onFocus: () => {
557
+ mentionPickerHasFocusRef.current = true;
558
+ }, onBlur: () => {
559
+ mentionPickerHasFocusRef.current = false;
560
+ }, children: rendered }));
532
561
  })()] }));
533
562
  });
534
563
  RichTextEditor.displayName = "RichTextEditor";
@@ -3,13 +3,22 @@ import { contract as c } from "../../tokens/index.js";
3
3
  // A settings section — measured from Settings pages: a 16px/600 title
4
4
  // with an optional subtitle, then a stack of rows (form fields, toggles…). Rows
5
5
  // gap evenly; an optional trailing action sits on the title row.
6
+ //
7
+ // Title line-height/letter-spacing and subtitle font-size/line-height
8
+ // re-measured live at app.attio.com/<workspace>/settings/account against a
9
+ // subsection heading ("Time preferences") — crisp#522. The subtitle's own
10
+ // colour (measured `rgba(0,0,0,0.63)`, a black-alpha value) is left as
11
+ // `fgMuted` pending a dark-mode measurement: a raw alpha-black has no
12
+ // existing token match and would need its own light/dark pair to stay
13
+ // legible in dark mode (Lesson: theme-constant vs theme-flipping colours
14
+ // must each be independently verified).
6
15
  const config = defineRecipe({
7
16
  name: "settingssection",
8
17
  base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${c.font.sans};
9
18
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: ${c.space["2"]}; }
10
19
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
11
- & .crisp-settingssection-title { font-size: ${c.text.xl}; line-height: 24px; font-weight: ${c.weight.semibold}; letter-spacing: -0.2px; color: ${c.fgPrimary}; }
12
- & .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.md}; line-height: 18px; color: ${c.fgMuted}; }
20
+ & .crisp-settingssection-title { font-size: ${c.text.xl}; line-height: 20px; font-weight: ${c.weight.semibold}; letter-spacing: -0.16px; color: ${c.fgPrimary}; }
21
+ & .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.lg}; line-height: 20px; color: ${c.fgMuted}; }
13
22
  & .crisp-settingssection-action { flex-shrink: 0; }
14
23
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: ${c.space["4"]}; margin-top: ${c.space["3"]}; }`,
15
24
  variants: {},
@@ -0,0 +1,27 @@
1
+ .crisp-heatmapchart {
2
+ display: flex; gap: var(--crisp-space-2); box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
3
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: var(--crisp-space-1) 0; font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
4
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
5
+ & .crisp-heatmapchart-body { flex: 1; }
6
+ & .crisp-heatmapchart-grid { display: grid; gap: var(--crisp-space-1-5); }
7
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: var(--crisp-radius-md); border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-semibold); font-variant-numeric: tabular-nums; color: var(--crisp-fg-primary); }
8
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
9
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
10
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
11
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); }
12
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: color-mix(in srgb, var(--crisp-bg-success) 16%, transparent); }
13
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); }
14
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 18%, transparent); }
15
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 16%, transparent); }
16
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px var(--crisp-bg-danger-solid); }
17
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: var(--crisp-space-1-5); padding: 0 var(--crisp-space-0-5); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
18
+ & .crisp-heatmapchart-empty { padding: var(--crisp-space-8) 0; text-align: center; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
19
+ }
20
+
21
+ @media (prefers-reduced-motion: reduce) {
22
+ .crisp-heatmapchart, .crisp-heatmapchart * {
23
+ animation-duration: 0.01ms !important;
24
+ animation-iteration-count: 1 !important;
25
+ transition-duration: 0.01ms !important;
26
+ }
27
+ }
@@ -0,0 +1,7 @@
1
+ import type { RecipeFn } from "../core/types";
2
+
3
+ export interface HeatmapchartVariants {
4
+
5
+ }
6
+
7
+ export declare const heatmapchartRecipe: RecipeFn<{ }>;
@@ -0,0 +1,29 @@
1
+ @layer crisp-components {
2
+ .crisp-heatmapchart {
3
+ display: flex; gap: var(--crisp-space-2); box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
4
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: var(--crisp-space-1) 0; font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
5
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
6
+ & .crisp-heatmapchart-body { flex: 1; }
7
+ & .crisp-heatmapchart-grid { display: grid; gap: var(--crisp-space-1-5); }
8
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: var(--crisp-radius-md); border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-semibold); font-variant-numeric: tabular-nums; color: var(--crisp-fg-primary); }
9
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
10
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
11
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
12
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); }
13
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: color-mix(in srgb, var(--crisp-bg-success) 16%, transparent); }
14
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); }
15
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 18%, transparent); }
16
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 16%, transparent); }
17
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px var(--crisp-bg-danger-solid); }
18
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: var(--crisp-space-1-5); padding: 0 var(--crisp-space-0-5); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
19
+ & .crisp-heatmapchart-empty { padding: var(--crisp-space-8) 0; text-align: center; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
20
+ }
21
+
22
+ @media (prefers-reduced-motion: reduce) {
23
+ .crisp-heatmapchart, .crisp-heatmapchart * {
24
+ animation-duration: 0.01ms !important;
25
+ animation-iteration-count: 1 !important;
26
+ transition-duration: 0.01ms !important;
27
+ }
28
+ }
29
+ }
@@ -2,8 +2,8 @@
2
2
  display: flex; flex-direction: column; box-sizing: border-box; font-family: var(--crisp-font-sans);
3
3
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: var(--crisp-space-2); }
4
4
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
5
- & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 24px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.2px; color: var(--crisp-fg-primary); }
6
- & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-md); line-height: 18px; color: var(--crisp-fg-muted); }
5
+ & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 20px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.16px; color: var(--crisp-fg-primary); }
6
+ & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-lg); line-height: 20px; color: var(--crisp-fg-muted); }
7
7
  & .crisp-settingssection-action { flex-shrink: 0; }
8
8
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: var(--crisp-space-4); margin-top: var(--crisp-space-3); }
9
9
  }
@@ -3,8 +3,8 @@
3
3
  display: flex; flex-direction: column; box-sizing: border-box; font-family: var(--crisp-font-sans);
4
4
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: var(--crisp-space-2); }
5
5
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
6
- & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 24px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.2px; color: var(--crisp-fg-primary); }
7
- & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-md); line-height: 18px; color: var(--crisp-fg-muted); }
6
+ & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 20px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.16px; color: var(--crisp-fg-primary); }
7
+ & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-lg); line-height: 20px; color: var(--crisp-fg-muted); }
8
8
  & .crisp-settingssection-action { flex-shrink: 0; }
9
9
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: var(--crisp-space-4); margin-top: var(--crisp-space-3); }
10
10
  }
package/dist/styles.css CHANGED
@@ -1028,6 +1028,34 @@
1028
1028
  & .crisp-formfield-error { font-size: var(--crisp-text-sm); line-height: 16px; color: var(--crisp-bg-danger); }
1029
1029
  }
1030
1030
 
1031
+ .crisp-heatmapchart {
1032
+ display: flex; gap: var(--crisp-space-2); box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
1033
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: var(--crisp-space-1) 0; font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
1034
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
1035
+ & .crisp-heatmapchart-body { flex: 1; }
1036
+ & .crisp-heatmapchart-grid { display: grid; gap: var(--crisp-space-1-5); }
1037
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: var(--crisp-radius-md); border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-semibold); font-variant-numeric: tabular-nums; color: var(--crisp-fg-primary); }
1038
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
1039
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
1040
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
1041
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); }
1042
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: color-mix(in srgb, var(--crisp-bg-success) 16%, transparent); }
1043
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); }
1044
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 18%, transparent); }
1045
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 16%, transparent); }
1046
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px var(--crisp-bg-danger-solid); }
1047
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: var(--crisp-space-1-5); padding: 0 var(--crisp-space-0-5); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
1048
+ & .crisp-heatmapchart-empty { padding: var(--crisp-space-8) 0; text-align: center; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
1049
+ }
1050
+
1051
+ @media (prefers-reduced-motion: reduce) {
1052
+ .crisp-heatmapchart, .crisp-heatmapchart * {
1053
+ animation-duration: 0.01ms !important;
1054
+ animation-iteration-count: 1 !important;
1055
+ transition-duration: 0.01ms !important;
1056
+ }
1057
+ }
1058
+
1031
1059
  .crisp-input {
1032
1060
  position: relative; box-sizing: border-box; display: inline-flex; align-items: center; width: 100%; background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-field); font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); line-height: 20px;
1033
1061
  &::after { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 2px solid transparent; transition: border-color .1s ease; pointer-events: none; }
@@ -1758,8 +1786,8 @@
1758
1786
 
1759
1787
  .crisp-toast {
1760
1788
  display: contents; font-family: var(--crisp-font-sans);
1761
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); bottom: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
1762
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-3); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-raised); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
1789
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); top: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
1790
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-2-5); border-radius: var(--crisp-radius-md); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
1763
1791
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: var(--crisp-fg-muted); }
1764
1792
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: var(--crisp-bg-brand-solid); }
1765
1793
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: var(--crisp-bg-success); }
@@ -2646,8 +2674,8 @@
2646
2674
  display: flex; flex-direction: column; box-sizing: border-box; font-family: var(--crisp-font-sans);
2647
2675
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: var(--crisp-space-2); }
2648
2676
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
2649
- & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 24px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.2px; color: var(--crisp-fg-primary); }
2650
- & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-md); line-height: 18px; color: var(--crisp-fg-muted); }
2677
+ & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 20px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.16px; color: var(--crisp-fg-primary); }
2678
+ & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-lg); line-height: 20px; color: var(--crisp-fg-muted); }
2651
2679
  & .crisp-settingssection-action { flex-shrink: 0; }
2652
2680
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: var(--crisp-space-4); margin-top: var(--crisp-space-3); }
2653
2681
  }
@@ -1029,6 +1029,34 @@
1029
1029
  & .crisp-formfield-error { font-size: var(--crisp-text-sm); line-height: 16px; color: var(--crisp-bg-danger); }
1030
1030
  }
1031
1031
 
1032
+ .crisp-heatmapchart {
1033
+ display: flex; gap: var(--crisp-space-2); box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
1034
+ & .crisp-heatmapchart-rowaxis { display: flex; flex-direction: column; justify-content: space-between; padding: var(--crisp-space-1) 0; font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
1035
+ & .crisp-heatmapchart-rowaxis-title { writing-mode: vertical-rl; transform: rotate(180deg); align-self: center; }
1036
+ & .crisp-heatmapchart-body { flex: 1; }
1037
+ & .crisp-heatmapchart-grid { display: grid; gap: var(--crisp-space-1-5); }
1038
+ & .crisp-heatmapchart-cell { box-sizing: border-box; display: flex; align-items: center; justify-content: center; aspect-ratio: 2 / 1; border-radius: var(--crisp-radius-md); border: none; background: transparent; margin: 0; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-semibold); font-variant-numeric: tabular-nums; color: var(--crisp-fg-primary); }
1039
+ & button.crisp-heatmapchart-cell { cursor: pointer; transition: filter 0.12s ease; }
1040
+ & button.crisp-heatmapchart-cell:hover { filter: brightness(0.97); }
1041
+ & button.crisp-heatmapchart-cell:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
1042
+ & .crisp-heatmapchart-cell[data-tone="neutral"] { background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); }
1043
+ & .crisp-heatmapchart-cell[data-tone="success"] { background: color-mix(in srgb, var(--crisp-bg-success) 16%, transparent); }
1044
+ & .crisp-heatmapchart-cell[data-tone="warning"] { background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); }
1045
+ & .crisp-heatmapchart-cell[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 18%, transparent); }
1046
+ & .crisp-heatmapchart-cell[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 16%, transparent); }
1047
+ & .crisp-heatmapchart-cell[data-ring] { box-shadow: inset 0 0 0 2px var(--crisp-bg-danger-solid); }
1048
+ & .crisp-heatmapchart-colaxis { display: flex; justify-content: space-between; margin-top: var(--crisp-space-1-5); padding: 0 var(--crisp-space-0-5); font-size: var(--crisp-text-xs); font-weight: var(--crisp-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; color: var(--crisp-fg-neutral-subtle); }
1049
+ & .crisp-heatmapchart-empty { padding: var(--crisp-space-8) 0; text-align: center; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); }
1050
+ }
1051
+
1052
+ @media (prefers-reduced-motion: reduce) {
1053
+ .crisp-heatmapchart, .crisp-heatmapchart * {
1054
+ animation-duration: 0.01ms !important;
1055
+ animation-iteration-count: 1 !important;
1056
+ transition-duration: 0.01ms !important;
1057
+ }
1058
+ }
1059
+
1032
1060
  .crisp-input {
1033
1061
  position: relative; box-sizing: border-box; display: inline-flex; align-items: center; width: 100%; background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-field); font-family: var(--crisp-font-sans); font-size: var(--crisp-text-lg); line-height: 20px;
1034
1062
  &::after { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 2px solid transparent; transition: border-color .1s ease; pointer-events: none; }
@@ -1759,8 +1787,8 @@
1759
1787
 
1760
1788
  .crisp-toast {
1761
1789
  display: contents; font-family: var(--crisp-font-sans);
1762
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); bottom: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
1763
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-3); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-raised); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
1790
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); top: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
1791
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-2-5); border-radius: var(--crisp-radius-md); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
1764
1792
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: var(--crisp-fg-muted); }
1765
1793
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: var(--crisp-bg-brand-solid); }
1766
1794
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: var(--crisp-bg-success); }
@@ -2647,8 +2675,8 @@
2647
2675
  display: flex; flex-direction: column; box-sizing: border-box; font-family: var(--crisp-font-sans);
2648
2676
  & .crisp-settingssection-head { display: flex; align-items: flex-start; gap: var(--crisp-space-2); }
2649
2677
  & .crisp-settingssection-heading { flex: 1; min-width: 0; }
2650
- & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 24px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.2px; color: var(--crisp-fg-primary); }
2651
- & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-md); line-height: 18px; color: var(--crisp-fg-muted); }
2678
+ & .crisp-settingssection-title { font-size: var(--crisp-text-xl); line-height: 20px; font-weight: var(--crisp-weight-semibold); letter-spacing: -0.16px; color: var(--crisp-fg-primary); }
2679
+ & .crisp-settingssection-sub { margin-top: var(--crisp-space-0-5); font-size: var(--crisp-text-lg); line-height: 20px; color: var(--crisp-fg-muted); }
2652
2680
  & .crisp-settingssection-action { flex-shrink: 0; }
2653
2681
  & .crisp-settingssection-body { display: flex; flex-direction: column; gap: var(--crisp-space-4); margin-top: var(--crisp-space-3); }
2654
2682
  }
package/dist/toast.css CHANGED
@@ -1,7 +1,7 @@
1
1
  .crisp-toast {
2
2
  display: contents; font-family: var(--crisp-font-sans);
3
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); bottom: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
4
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-3); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-raised); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
3
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); top: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
4
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-2-5); border-radius: var(--crisp-radius-md); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
5
5
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: var(--crisp-fg-muted); }
6
6
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: var(--crisp-bg-brand-solid); }
7
7
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: var(--crisp-bg-success); }
@@ -1,8 +1,8 @@
1
1
  @layer crisp-components {
2
2
  .crisp-toast {
3
3
  display: contents; font-family: var(--crisp-font-sans);
4
- & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); bottom: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
5
- & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-3); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-raised); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 5%, transparent), 0 8px 28px -6px rgba(28,40,64,0.16), 0 2px 6px -2px rgba(28,40,64,0.10); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
4
+ & .crisp-toast-viewport { position: fixed; z-index: 80; inset-inline-end: var(--crisp-space-4); top: var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-2); width: 360px; max-width: calc(100vw - var(--crisp-space-8)); pointer-events: none; }
5
+ & .crisp-toast { pointer-events: auto; display: flex; align-items: flex-start; gap: var(--crisp-space-2-5); box-sizing: border-box; padding: var(--crisp-space-2-5); border-radius: var(--crisp-radius-md); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); opacity: 1; transform: translateY(0); transition: opacity 0.15s ease, transform 0.15s ease; @starting-style { opacity: 0; transform: translateY(8px); } }
6
6
  & .crisp-toast-icon { flex-shrink: 0; margin-top: 1px; display: inline-flex; color: var(--crisp-fg-muted); }
7
7
  & .crisp-toast[data-tone="info"] .crisp-toast-icon { color: var(--crisp-bg-brand-solid); }
8
8
  & .crisp-toast[data-tone="success"] .crisp-toast-icon { color: var(--crisp-bg-success); }
@@ -0,0 +1,51 @@
1
+ import type * as React from "react";
2
+ export type HeatmapCell = {
3
+ /** The raw value a consumer's own severity/intensity scale maps from. */
4
+ value: number;
5
+ /** Cell background. "neutral" (the default) is a plain muted tint —
6
+ * typically used for a value of 0. */
7
+ tone?: "neutral" | "success" | "warning" | "orange" | "danger";
8
+ /** Draws an inset danger ring around the cell (e.g. "over the appetite
9
+ * threshold"), independent of — and layered on top of — `tone`. */
10
+ ring?: boolean;
11
+ };
12
+ /** An axis's own end labels + an optional rotated (row) or inline (column)
13
+ * title between them — a 5x5 heatmap reads its axis by its two extremes
14
+ * and what they mean, not a tick per row/column. */
15
+ export type HeatmapAxis = {
16
+ low: React.ReactNode;
17
+ high: React.ReactNode;
18
+ title?: React.ReactNode;
19
+ };
20
+ export type HeatmapChartProps = Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onClick"> & {
21
+ /** `data[row][col]`, row 0 at the top (the axis's `high` end). */
22
+ data: HeatmapCell[][];
23
+ rowAxis: HeatmapAxis;
24
+ colAxis: HeatmapAxis;
25
+ /** Cell display text — default `cell.value || ""` (a 0 renders blank, the
26
+ * same "an empty cell says nothing happened here" reading GRC's own
27
+ * heatmap already used). */
28
+ format?: (cell: HeatmapCell, row: number, col: number) => React.ReactNode;
29
+ /** Hover/focus tooltip text for a cell. */
30
+ cellTitle?: (cell: HeatmapCell, row: number, col: number) => string;
31
+ /** Only cells this returns true for become a real `<button>` — a cell
32
+ * with nothing behind it stays a plain, non-interactive `<div>`. */
33
+ onCellClick?: (row: number, col: number, cell: HeatmapCell) => void;
34
+ isCellClickable?: (cell: HeatmapCell, row: number, col: number) => boolean;
35
+ /** Renders the empty-state message instead of the grid. */
36
+ empty?: boolean;
37
+ emptyLabel?: React.ReactNode;
38
+ "aria-label"?: string;
39
+ className?: string;
40
+ };
41
+ /**
42
+ * An NxM cell-matrix chart — GRC's own risk heatmap (impact x likelihood)
43
+ * is the first consumer. Severity/intensity mapping (`tone`, `ring`) is
44
+ * entirely the caller's own data, not a scale this component invents; see
45
+ * the recipe's own doc comment for why (and for its measurement — or lack
46
+ * of one — against Attio).
47
+ */
48
+ export declare function HeatmapChart({ data, rowAxis, colAxis, format, cellTitle, onCellClick, isCellClickable, empty, emptyLabel, "aria-label": ariaLabel, className, ...rest }: HeatmapChartProps): React.JSX.Element;
49
+ export declare namespace HeatmapChart {
50
+ var displayName: string;
51
+ }
@@ -0,0 +1,3 @@
1
+ declare const config: import("../..").RecipeConfig<{}>;
2
+ export declare const heatmapchartRecipe: import("../..").RecipeFn<{}>;
3
+ export default config;
@@ -0,0 +1 @@
1
+ export * from "./heatmapchart";
@@ -28,8 +28,27 @@ import * as React from "react";
28
28
  * before this existed.
29
29
  */
30
30
  export type CrispMessages = Record<string, string>;
31
- export declare function CrispMessagesProvider({ messages, children, }: {
31
+ /**
32
+ * A handful of product components (`RecordPage`, `RecordsTable`) already
33
+ * take their own translatable text as one big `labels` object rather than a
34
+ * prop per string — and several of ITS entries are formatter FUNCTIONS
35
+ * (`domainSetTo(value)`, `sortedBy(first, extra)`), not plain strings, since
36
+ * word order and pluralisation differ too much across languages for a
37
+ * template (the same reason `TableToolbar.sortTriggerLabel` is a function).
38
+ * `CrispMessages` can't hold those — it's `Record<string,string>` — so those
39
+ * two components get their own whole-object slot instead of a per-string
40
+ * swap through `messages`. `componentDefaults` is deliberately untyped here
41
+ * (`Record<string, unknown>`, cast back to the real shape by
42
+ * `useCrispComponentDefaults<T>` at the CALL site): `core/` sits underneath
43
+ * `product/`, so it cannot import `RecordPageLabels`/`RecordsTableLabels`
44
+ * without a circular dependency. Keyed by the component's own name
45
+ * ("recordPage", "recordsTable") — a small, closed set, not a general
46
+ * registry, since only these two currently need it.
47
+ */
48
+ export type CrispComponentDefaults = Record<string, unknown>;
49
+ export declare function CrispMessagesProvider({ messages, componentDefaults, children, }: {
32
50
  messages: CrispMessages;
51
+ componentDefaults?: CrispComponentDefaults;
33
52
  children?: React.ReactNode;
34
53
  }): React.ReactElement;
35
54
  /**
@@ -56,3 +75,13 @@ export declare function useCrispMessage(explicit: string | undefined, englishDef
56
75
  * per fragment, same fallback order, no hook-in-a-loop.
57
76
  */
58
77
  export declare function useCrispMessages(): CrispMessages;
78
+ /**
79
+ * Reads one component's own whole-object defaults override, set via
80
+ * `CrispMessagesProvider`'s `componentDefaults` prop (see its own doc for
81
+ * why this exists separately from `useCrispMessage`). `T` is the
82
+ * component's own labels TYPE (e.g. `Partial<RecordPageLabels>`) — this
83
+ * hook itself only knows `name`, a plain string key; the caller supplies
84
+ * and trusts the real shape, the same way `useContext` always has for an
85
+ * untyped-at-the-provider value.
86
+ */
87
+ export declare function useCrispComponentDefaults<T>(name: string): T | undefined;
@@ -7,7 +7,7 @@ export { isRtlElement } from "./direction";
7
7
  export { FloatingPortal } from "./floating-portal";
8
8
  export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack";
9
9
  export { formValueStyle } from "./form-value";
10
- export { type CrispMessages, CrispMessagesProvider, useCrispMessage, useCrispMessages, } from "./i18n";
10
+ export { type CrispComponentDefaults, type CrispMessages, CrispMessagesProvider, useCrispComponentDefaults, useCrispMessage, useCrispMessages, } from "./i18n";
11
11
  export { type MentionAnchor, type MentionMatch, matchMentionTrigger, } from "./mention";
12
12
  export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from "./place-floating";
13
13
  export { Primitive } from "./primitive";
@@ -25,6 +25,7 @@ export * from "./components/fileupload";
25
25
  export * from "./components/filldoc";
26
26
  export * from "./components/fillsheet";
27
27
  export * from "./components/formfield";
28
+ export * from "./components/heatmapchart";
28
29
  export * from "./components/input";
29
30
  export * from "./components/kbd";
30
31
  export * from "./components/linechart";
@@ -93,6 +93,16 @@ export type RichTextEditorProps = Omit<React.ComponentPropsWithoutRef<"div">, "c
93
93
  * headings/lists/marks all survive into it. No toolbar renders in this
94
94
  * mode. */
95
95
  readOnly?: boolean;
96
+ /**
97
+ * Renders the title field at all (default `true`, matching Attio's own
98
+ * preview — see `readOnly` above). `false` omits it from the DOM
99
+ * entirely, not just visually: for a consumer whose own layout already
100
+ * shows a note's title elsewhere (a list row's own heading, a record's
101
+ * collapsed-note summary), rendering it a second time here would
102
+ * duplicate it rather than match anything Attio does. `onChange`'s
103
+ * `title` is always `""` with this off; `defaultTitle` is ignored.
104
+ */
105
+ showTitle?: boolean;
96
106
  className?: string;
97
107
  };
98
108
  /**
@@ -187,5 +197,15 @@ export declare const RichTextEditor: React.ForwardRefExoticComponent<Omit<Omit<R
187
197
  * headings/lists/marks all survive into it. No toolbar renders in this
188
198
  * mode. */
189
199
  readOnly?: boolean;
200
+ /**
201
+ * Renders the title field at all (default `true`, matching Attio's own
202
+ * preview — see `readOnly` above). `false` omits it from the DOM
203
+ * entirely, not just visually: for a consumer whose own layout already
204
+ * shows a note's title elsewhere (a list row's own heading, a record's
205
+ * collapsed-note summary), rendering it a second time here would
206
+ * duplicate it rather than match anything Attio does. `onChange`'s
207
+ * `title` is always `""` with this off; `defaultTitle` is ignored.
208
+ */
209
+ showTitle?: boolean;
190
210
  className?: string;
191
211
  } & React.RefAttributes<RichTextEditorHandle>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.53.3",
3
+ "version": "0.53.5",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -168,6 +168,11 @@
168
168
  "import": "./dist/esm/components/formfield/index.js",
169
169
  "require": "./dist/cjs/components/formfield/index.js"
170
170
  },
171
+ "./heatmapchart": {
172
+ "types": "./dist/types/components/heatmapchart/index.d.ts",
173
+ "import": "./dist/esm/components/heatmapchart/index.js",
174
+ "require": "./dist/cjs/components/heatmapchart/index.js"
175
+ },
171
176
  "./input": {
172
177
  "types": "./dist/types/components/input/index.d.ts",
173
178
  "import": "./dist/esm/components/input/index.js",