@crisp-ui-kit/crisp 0.54.2 → 0.54.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/chip.css +7 -1
  2. package/dist/chip.d.ts +2 -1
  3. package/dist/chip.layered.css +7 -1
  4. package/dist/cjs/components/chip/chip.js +3 -2
  5. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  6. package/dist/cjs/components/datatable/datatable.js +489 -111
  7. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  8. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  9. package/dist/cjs/components/personchip/index.js +17 -0
  10. package/dist/cjs/components/personchip/personchip.js +50 -0
  11. package/dist/cjs/components/table/table.js +2 -2
  12. package/dist/cjs/components/tabs/tabs.js +2 -2
  13. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  14. package/dist/cjs/components/text/index.js +7 -0
  15. package/dist/cjs/components/text/text.js +55 -0
  16. package/dist/cjs/components/text/text.recipe.js +34 -0
  17. package/dist/cjs/index.js +2 -0
  18. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  19. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  20. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  21. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  22. package/dist/emptystate.css +15 -1
  23. package/dist/emptystate.d.ts +2 -2
  24. package/dist/emptystate.layered.css +15 -1
  25. package/dist/esm/components/chip/chip.js +3 -2
  26. package/dist/esm/components/chip/chip.recipe.js +37 -1
  27. package/dist/esm/components/datatable/datatable.js +488 -111
  28. package/dist/esm/components/emptystate/emptystate.js +4 -3
  29. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  30. package/dist/esm/components/emptystate/index.js +1 -1
  31. package/dist/esm/components/personchip/index.js +1 -0
  32. package/dist/esm/components/personchip/personchip.js +47 -0
  33. package/dist/esm/components/table/table.js +2 -2
  34. package/dist/esm/components/tabs/tabs.js +2 -2
  35. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  36. package/dist/esm/components/text/index.js +2 -0
  37. package/dist/esm/components/text/text.js +19 -0
  38. package/dist/esm/components/text/text.recipe.js +31 -0
  39. package/dist/esm/index.js +2 -0
  40. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  41. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  42. package/dist/esm/product/recordstable/recordstable.js +90 -8
  43. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  44. package/dist/headerbar.css +1 -1
  45. package/dist/headerbar.layered.css +1 -1
  46. package/dist/recordpage.css +13 -2
  47. package/dist/recordpage.layered.css +13 -2
  48. package/dist/richtext.css +11 -1
  49. package/dist/richtext.layered.css +11 -1
  50. package/dist/styles.css +64 -7
  51. package/dist/styles.layered.css +64 -7
  52. package/dist/tabs.css +7 -1
  53. package/dist/tabs.d.ts +2 -2
  54. package/dist/tabs.layered.css +7 -1
  55. package/dist/text.css +9 -0
  56. package/dist/text.d.ts +7 -0
  57. package/dist/text.layered.css +11 -0
  58. package/dist/types/components/chip/chip.d.ts +32 -0
  59. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  60. package/dist/types/components/datatable/datatable.d.ts +28 -4
  61. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  62. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  63. package/dist/types/components/emptystate/index.d.ts +1 -1
  64. package/dist/types/components/personchip/index.d.ts +1 -0
  65. package/dist/types/components/personchip/personchip.d.ts +37 -0
  66. package/dist/types/components/table/table.d.ts +36 -0
  67. package/dist/types/components/tabs/tabs.d.ts +4 -2
  68. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  69. package/dist/types/components/text/index.d.ts +2 -0
  70. package/dist/types/components/text/text.d.ts +32 -0
  71. package/dist/types/components/text/text.recipe.d.ts +40 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  74. package/package.json +11 -1
@@ -1,7 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { emptystateRecipe } from "./emptystate.recipe.js";
3
3
  /** A centred empty-state panel — icon, title, description, optional action. */
4
- export function EmptyState({ icon, title, description, action, variant, className, ...rest }) {
5
- return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action })] }));
4
+ export function EmptyState({ icon, title, description, action, learnMore, variant, className, ...rest }) {
5
+ const learnMoreInner = learnMore && (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-emptystate-learnmore-icon", children: learnMore.icon }), _jsx("span", { className: "crisp-emptystate-learnmore-title", children: learnMore.title })] }));
6
+ return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action }), learnMore && (_jsxs("div", { className: "crisp-emptystate-learnmore", children: [_jsx("div", { className: "crisp-emptystate-learnmore-label", children: learnMore.label ?? "Learn more" }), learnMore.href ? (_jsx("a", { className: "crisp-emptystate-learnmore-card", href: learnMore.href, onClick: learnMore.onClick, children: learnMoreInner })) : (_jsx("button", { type: "button", className: "crisp-emptystate-learnmore-card", onClick: learnMore.onClick, children: learnMoreInner }))] }))] }));
6
7
  }
7
8
  EmptyState.displayName = "EmptyState";
@@ -7,7 +7,18 @@ const config = defineRecipe({
7
7
  & .crisp-emptystate-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin-bottom: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 5%, transparent); color: ${c.fgMuted}; }
8
8
  & .crisp-emptystate-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
9
9
  & .crisp-emptystate-desc { max-width: 320px; font-size: ${c.text.md}; color: ${c.fgMuted}; }
10
- & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
10
+ /* #603: measured 8px gap between "Import CSV"/"Connect data source" --
11
+ inline-flex so one action (the common case) or several lay out the
12
+ same way, wrapping if the container is narrow. */
13
+ & .crisp-emptystate-action { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: ${c.space["2"]}; margin-top: ${c.space["2"]}; }
14
+ /* #603: Attio's zero-records "Learn more" card -- a label above a
15
+ clickable icon+title tile. Left/start-aligned, unlike the rest of
16
+ this centred panel, so it overrides the base text-align. */
17
+ & .crisp-emptystate-learnmore { display: flex; flex-direction: column; align-items: stretch; gap: ${c.space["2"]}; margin-top: ${c.space["6"]}; text-align: start; }
18
+ & .crisp-emptystate-learnmore-label { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
19
+ & .crisp-emptystate-learnmore-card { display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]} ${c.space["2"]} ${c.space["2"]}; margin: 0; border: 1px solid ${c.borderSubtle}; border-radius: 18px; background: ${c.bgSurface}; font: inherit; text-align: start; text-decoration: none; color: inherit; cursor: pointer; }
20
+ & .crisp-emptystate-learnmore-icon { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; flex-shrink: 0; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); color: ${c.fgMuted}; }
21
+ & .crisp-emptystate-learnmore-title { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }`,
11
22
  variants: {
12
23
  variant: {
13
24
  // compact icon tile (default)
@@ -22,6 +33,20 @@ const config = defineRecipe({
22
33
  & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
23
34
  & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }
24
35
  & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
36
+ // #603: Attio's zero-*records* table state (Users/Workspaces with
37
+ // nothing imported) -- the same dotted-grid backdrop `illustrated`
38
+ // uses, at its own measured 138x138 square (illustrated's is a
39
+ // 132x96 rectangle; different surface, not the same number).
40
+ // Attio's own illustration also draws four small "+" tick marks at
41
+ // the tile's corners -- a single proprietary SVG asset (confirmed
42
+ // by inspecting the live DOM: one <svg> with no separately
43
+ // measurable sub-elements), so not reproduced here per AUTHORING's
44
+ // "never copy Attio's icon set" rule; the icon itself is supplied
45
+ // by the consumer via the existing `icon` prop, same as every
46
+ // other variant.
47
+ records: `& .crisp-emptystate-icon { width: 138px; height: 138px; margin-bottom: ${c.space["2"]}; border-radius: 0; color: color-mix(in srgb, ${c.fgPrimary} 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, ${c.fgPrimary} 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
48
+ & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
49
+ & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }`,
25
50
  },
26
51
  },
27
52
  defaultVariants: { variant: "default" },
@@ -1,2 +1,2 @@
1
- export { EmptyState } from "./emptystate.js";
1
+ export { EmptyState, } from "./emptystate.js";
2
2
  export { emptystateRecipe } from "./emptystate.recipe.js";
@@ -0,0 +1 @@
1
+ export * from "./personchip.js";
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ import { Avatar } from "../avatar/index.js";
4
+ import { Chip } from "../chip/index.js";
5
+ // The same five hashed avatar tones DataTable's own `avatarTone="hashed"`
6
+ // picks from (datatable.tsx's private `TAG_TONES`, transcribed rather than
7
+ // imported — DataTable owns its own file, and this is a one-line pure
8
+ // function, not a visual value that could drift). `neutral`/`brand` are
9
+ // left out: `neutral` is Avatar's own no-photo default (wrong here, every
10
+ // name needs a colour to tell people apart) and `brand` is reserved for an
11
+ // entity that IS the record a cell points at (`RecordChip`'s own tone),
12
+ // which a person mention never is.
13
+ const HASH_TONES = ["brand", "orange", "cyan", "purple", "green"];
14
+ function hashTone(name) {
15
+ let h = 0;
16
+ for (let i = 0; i < name.length; i++)
17
+ h = (h * 31 + name.charCodeAt(i)) >>> 0;
18
+ return HASH_TONES[h % HASH_TONES.length];
19
+ }
20
+ function initials(name) {
21
+ const parts = name.trim().split(/\s+/).filter(Boolean);
22
+ if (parts.length === 0)
23
+ return "?";
24
+ return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
25
+ }
26
+ /**
27
+ * A person, as a chip: a small avatar (a photo, or hashed-colour initials)
28
+ * plus their name, truncating with an ellipsis when it doesn't fit — Attio's
29
+ * own actor chip (measured live on `techpartners`, Tasks page, "Assigned to"
30
+ * column): a 16px circular avatar in a neutral `Chip`, `gap: 3px` between
31
+ * avatar and name (now `Chip`'s own base — every multi-child `Chip` gets
32
+ * it). Every other value — the pill's height, padding, radius, ring and text
33
+ * — is already `Chip`'s, unchanged; `PersonChip` adds no recipe of its own.
34
+ *
35
+ * Ellipsis only, no `Tooltip` on hover (#623 — no Attio reference for one;
36
+ * a truncated cell there is revealed by its own click-to-expand, #579, a
37
+ * different, DataTable-specific interaction).
38
+ *
39
+ * Composes `Chip`'s `truncate` CSS directly rather than its string-only
40
+ * auto-wrap (children here is an avatar plus a name, not a single string),
41
+ * so the name gets its own `.crisp-chip-label` span while the avatar stays
42
+ * fixed-size.
43
+ */
44
+ export function PersonChip({ name, avatar, muted, className, ...props }) {
45
+ return (_jsxs(Chip, { tone: "neutral", truncate: true, className: className, ...props, children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: avatar ? "neutral" : hashTone(name), children: avatar ? _jsx("img", { src: avatar, alt: "" }) : initials(name) }), _jsx("span", { className: "crisp-chip-label", style: muted ? { color: c.fgNeutralMuted } : undefined, children: name })] }));
46
+ }
47
+ PersonChip.displayName = "PersonChip";
@@ -12,11 +12,11 @@ export const Table = React.forwardRef(({ interactive, surface, className, wrappe
12
12
  // on the root for the same reason as Textarea: the wrapper is the box a
13
13
  // consumer sees and the scroll container they want to size. Native
14
14
  // table attributes still reach the <table> below.
15
- style, ...props }, ref) => (_jsx("div", { className: tableRecipe({
15
+ style, tableStyle, ...props }, ref) => (_jsx("div", { className: tableRecipe({
16
16
  interactive,
17
17
  surface,
18
18
  className: wrapperClassName,
19
- }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, ...props, children: children }) }) })));
19
+ }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, style: tableStyle, ...props, children: children }) }) })));
20
20
  Table.displayName = "Table";
21
21
  export const TableHeader = React.forwardRef((props, ref) => _jsx("thead", { ref: ref, ...props }));
22
22
  TableHeader.displayName = "TableHeader";
@@ -8,7 +8,7 @@ import { useTablistKeyboard } from "./tabs-keyboard.js";
8
8
  * Home/End jump); activation follows focus. Controlled via
9
9
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
10
10
  */
11
- export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, "aria-label": ariaLabel, ...rest }) {
11
+ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
12
12
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
13
13
  // so two tab bars sharing a value mint the same id twice and every
14
14
  // aria-labelledby resolves to the first one (#227).
@@ -47,7 +47,7 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
47
47
  select,
48
48
  listRef,
49
49
  });
50
- return (_jsxs("div", { className: tabsRecipe({ className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
50
+ return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
51
51
  const selected = t.value === active;
52
52
  return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
53
53
  }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
@@ -12,6 +12,22 @@ import { contract as c } from "../../tokens/index.js";
12
12
  // a 16px leading icon and an optional count badge. Measured from the live
13
13
  // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
14
14
  // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
15
+ //
16
+ // The panel's own `padding-top` (`c.space["8"]`, 32px) is measured
17
+ // separately, live, on a company record page (Overview tab): the gap from
18
+ // the tab row's own bottom to the content below it (the "Highlights"
19
+ // heading) is a clean 32px with zero margin/padding contributed by either
20
+ // boundary — not the 12px a GRC consumer had been carrying locally, which
21
+ // turned out (checking its own history) to have been measured against that
22
+ // app's own demo rendering, never against Attio itself.
23
+ //
24
+ // The panel is also `display: flex; flex-direction: column` (structural,
25
+ // not measured): a consumer's own content — a table sizing itself to "the
26
+ // rest of the space" — needs a flex-column ancestor with a real height to
27
+ // grow into. A GRC consumer used to supply that itself, wrapping every
28
+ // `children(active)` in its own `<div className="flex h-full min-h-0
29
+ // flex-col">`; dropping that wrapper in favour of `fill` without this would
30
+ // have silently broken every such table the moment the wrapper went away.
15
31
  const config = defineRecipe({
16
32
  name: "tabs",
17
33
  base: `
@@ -29,11 +45,22 @@ const config = defineRecipe({
29
45
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
30
46
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
31
47
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
32
- & .crisp-tabs-panel { flex: 1; min-height: 0; }
48
+ & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
33
49
  & .crisp-tabs-panel:focus-visible { outline: none; }
34
50
  `,
35
- variants: {},
36
- defaultVariants: {},
51
+ variants: {
52
+ // Opt-in: a `Tabs` dropped into a document-flow layout (a docs page, a
53
+ // demo) should size to its content like anything else. `fill` is for a
54
+ // consumer embedding it in a fixed-height shell — a record page, a full
55
+ // screen — matching the record page's own tab area, which visibly
56
+ // fills all available height (the same live check this file's panel
57
+ // measurement came from).
58
+ fill: {
59
+ true: `height: 100%;`,
60
+ false: "",
61
+ },
62
+ },
63
+ defaultVariants: { fill: false },
37
64
  compoundVariants: [],
38
65
  });
39
66
  export const tabsRecipe = Object.assign(resolveRecipe(config), { config });
@@ -0,0 +1,2 @@
1
+ export { Text } from "./text.js";
2
+ export { textRecipe } from "./text.recipe.js";
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Primitive } from "../../core/index.js";
4
+ import { textRecipe } from "./text.recipe.js";
5
+ /**
6
+ * Plain coloured text — `tone="default"` (the app's primary text colour) or
7
+ * `"muted"` — for the runs of text that aren't a Badge, a Chip, a link, or
8
+ * any of crisp's other typed things: a cell's own yes/no, an empty-state
9
+ * caption, a secondary line under a heading. One component so every such
10
+ * spot reaches for crisp's own tokens instead of each hand-writing its own
11
+ * colour utility for the same two values.
12
+ *
13
+ * Renders a `<span>` (inherits its size/weight/line-height from context —
14
+ * see `text.recipe.ts`'s doc for why `Text` sets none of those itself).
15
+ */
16
+ export const Text = React.forwardRef(({ tone, className, asChild, children, ...props }, ref) => {
17
+ return (_jsx(Primitive.span, { ref: ref, asChild: asChild, className: textRecipe({ tone, className }), ...props, children: children }));
18
+ });
19
+ Text.displayName = "Text";
@@ -0,0 +1,31 @@
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // No new measurement — `tone` just names two colour tokens every other
4
+ // component already carries (`fgPrimary`/`fgNeutralMuted`), for the plain
5
+ // runs of text that aren't any of crisp's other typed things (a Badge, a
6
+ // Chip, a link). Font size/weight/line-height are deliberately NOT set here:
7
+ // unlike Badge or Chip, plain text has no single measured size across the
8
+ // app (a table cell, a label, a caption are all different sizes) — `Text`
9
+ // inherits type from its context, the same choice `MiddleTruncate` makes for
10
+ // the same reason (see its own doc).
11
+ const spec = defineSpec({
12
+ name: "text",
13
+ // `display: inline` -- a span's own default, stated rather than left
14
+ // implicit: an EMPTY base compiles to an empty string, and
15
+ // `core/resolve.ts`'s `if (base) classes.push(prefixedName)` treats that
16
+ // falsy string as "no base rule", which drops the `crisp-text` class
17
+ // itself, silently, from every instance (caught by this file's own
18
+ // tests going red on that exact class).
19
+ base: { display: "inline" },
20
+ variants: {
21
+ tone: {
22
+ default: { color: c.fgPrimary },
23
+ muted: { color: c.fgNeutralMuted },
24
+ },
25
+ },
26
+ defaultVariants: { tone: "default" },
27
+ });
28
+ const config = specToRecipe(spec);
29
+ export const textRecipe = Object.assign(resolveRecipe(config), { config });
30
+ export { spec };
31
+ export default config;
package/dist/esm/index.js CHANGED
@@ -37,6 +37,7 @@ export * from "./components/mentionchip/index.js";
37
37
  export * from "./components/menu/index.js";
38
38
  export * from "./components/middletruncate/index.js";
39
39
  export * from "./components/multiselect/index.js";
40
+ export * from "./components/personchip/index.js";
40
41
  export * from "./components/popover/index.js";
41
42
  export * from "./components/progress/index.js";
42
43
  export * from "./components/quantitypicker/index.js";
@@ -54,6 +55,7 @@ export * from "./components/switch/index.js";
54
55
  export * from "./components/table/index.js";
55
56
  export * from "./components/tabletoolbar/index.js";
56
57
  export * from "./components/tabs/index.js";
58
+ export * from "./components/text/index.js";
57
59
  export * from "./components/textarea/index.js";
58
60
  export * from "./components/textlink/index.js";
59
61
  export * from "./components/timepicker/index.js";
@@ -17,10 +17,18 @@ import { contract as c } from "../../tokens/index.js";
17
17
  // `title`/`trailing` slot content a consumer supplies. `size.header` (48px)
18
18
  // is reused rather than a new token, since it's the same "workspace-switcher
19
19
  // header height" measured elsewhere in the sidebar.
20
+ //
21
+ // crisp#576: `border-box` plus a plain `height: ${size.header}` meant the
22
+ // bordered variant's own 1px border was eaten OUT OF the 48px box, leaving
23
+ // only 47px of content -- caught in review of internal/grc's own shell
24
+ // shifting up 1px end-to-end after adopting this component. Re-measured
25
+ // live: Attio's own `<header>` is 49px total (border-box) with the 1px
26
+ // border OUTSIDE the 48px content row, not inside it. `[data-bordered]` now
27
+ // grows the box by the border's own width so content stays 48px either way.
20
28
  const config = defineRecipe({
21
29
  name: "headerbar",
22
30
  base: `box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
23
- &[data-bordered] { border-bottom: 1px solid ${c.borderSubtle}; }
31
+ &[data-bordered] { height: calc(${c.size.header} + 1px); border-bottom: 1px solid ${c.borderSubtle}; }
24
32
  & .crisp-headerbar-leading { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; min-width: 0; }
25
33
  & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: ${c.space["2"]}; overflow: hidden; }
26
34
  & .crisp-headerbar-trailing { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; }`,
@@ -6,6 +6,14 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
6
6
  // column (transparent, border-right) and a flexible main area whose tab bar
7
7
  // (14/500 labels over a sliding ink underline) switches content — Overview shows
8
8
  // a bordered Highlights stat-grid and an Activity feed.
9
+ //
10
+ // `.crisp-recordpage-panel`'s own `padding-top` (c.space["4"], 16px) was
11
+ // dropped (your-moon/crisp#588): this panel is only ever rendered as Tabs'
12
+ // own children (every branch of `builtinPanel` in recordpage.tsx, all fed to
13
+ // `<Tabs>` below), so it sat inside `.crisp-tabs-panel`, which now carries
14
+ // its own measured 32px top-inset by default — keeping both would have
15
+ // stacked to 48px. Caught by tracing every consumer of `Tabs` before
16
+ // shipping its new default, not by a screenshot.
9
17
  const config = defineRecipe({
10
18
  name: "recordpage",
11
19
  base: `
@@ -125,7 +133,8 @@ const config = defineRecipe({
125
133
 
126
134
  & .crisp-recordpage-main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; padding: ${c.space["3"]} ${c.space["5"]}; }
127
135
 
128
- & .crisp-recordpage-panel { padding-top: ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["4"]}; }
136
+ /* no padding-top: Tabs' own panel supplies it now (#588) */
137
+ & .crisp-recordpage-panel { display: flex; flex-direction: column; gap: ${c.space["4"]}; }
129
138
  & .crisp-recordpage-card { border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius["2xl"]}; background: ${c.bgSurface}; overflow: hidden; }
130
139
  & .crisp-recordpage-card-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; letter-spacing: -0.14px; color: ${c.fgPrimary}; padding: ${c.space["3"]} ${c.space["3"]} ${c.space["2"]}; }
131
140
  & .crisp-recordpage-hl-card { border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius["2xl"]}; overflow: hidden; }
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plus, Star, Tag, Trash2, TrendingUp, Users, Workflow, X, } from "lucide-react";
2
+ import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plug, Plus, Star, Tag, Trash2, TrendingUp, Upload, Users, Workflow, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Avatar } from "../../components/avatar/index.js";
@@ -8,15 +8,67 @@ import { Button } from "../../components/button/index.js";
8
8
  import { Checkbox } from "../../components/checkbox/index.js";
9
9
  import { DatePickerField } from "../../components/datepickerfield/index.js";
10
10
  import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
11
- import { EmptyState } from "../../components/emptystate/index.js";
11
+ import { EmptyState, } from "../../components/emptystate/index.js";
12
12
  import { Input } from "../../components/input/index.js";
13
13
  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 { Tooltip } from "../../components/tooltip/index.js";
17
18
  import { isInsideLaterFloatingLayer, isRtlElement, useCrispComponentDefaults, usePrefersReducedMotion, } from "../../core/index.js";
18
19
  import { companyLogos } from "./company-logos.js";
19
20
  import { recordstableRecipe } from "./recordstable.recipe.js";
21
+ // #534: whether an element's own content is being cut off by its own box --
22
+ // the same fact `.crisp-recordstable-th-label`'s CSS ellipsis is silently
23
+ // applying, exported so the comparison itself (not layout) can be
24
+ // mutation-tested without a real browser. Identical to DataTable's own
25
+ // `isLabelTruncated` (components/datatable/datatable.tsx) -- not shared
26
+ // because `product/` can depend on `components/` but not the other way, and
27
+ // this file already duplicates DataTable's other measured header behaviour
28
+ // rather than reach across that boundary.
29
+ export function isLabelTruncated(el) {
30
+ return el.scrollWidth > el.clientWidth;
31
+ }
32
+ /**
33
+ * The header label span: plain when it fits, wrapped in the shared `Tooltip`
34
+ * (full text, `asChild` so the wrapper never displaces the ellipsis CSS the
35
+ * span already carries -- see `Tooltip`'s own `asChild` doc) once it's
36
+ * actually cut off. Re-measures on mount and on resize (column drag,
37
+ * viewport change, label change) via `ResizeObserver`; SSR-safe the same way
38
+ * `MiddleTruncate` is -- the DOM is only touched inside an effect, so the
39
+ * server (and the first client paint) renders the plain span.
40
+ */
41
+ function ThLabel({ label }) {
42
+ const ref = React.useRef(null);
43
+ const [overflowing, setOverflowing] = React.useState(false);
44
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `label` isn't read in the body, but a rename (col.label changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
45
+ React.useLayoutEffect(() => {
46
+ const el = ref.current;
47
+ if (!el)
48
+ return;
49
+ const measure = () => setOverflowing(isLabelTruncated(el));
50
+ measure();
51
+ if (typeof ResizeObserver === "undefined")
52
+ return;
53
+ const ro = new ResizeObserver(measure);
54
+ ro.observe(el);
55
+ return () => ro.disconnect();
56
+ }, [label]);
57
+ // `Tooltip` always wraps the SAME span (never swapped in/out of the tree
58
+ // based on `overflowing`) so the span's own DOM node identity survives
59
+ // every toggle -- a real-browser-only bug (happy-dom has no
60
+ // ResizeObserver, so the test suite never exercised this path): swapping
61
+ // between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
62
+ // the element type at this position, so React unmounts the old span and
63
+ // mounts a new one; the effect above never re-runs (its deps are
64
+ // `[label]`, unchanged), so its ResizeObserver keeps watching the now
65
+ // -detached old node, whose deferred initial callback then reports 0x0 and
66
+ // flips `overflowing` straight back to false -- the tooltip would never
67
+ // actually show. `open={false}` when it fits keeps the Tooltip mounted
68
+ // (asChild costs no extra DOM either way) but pins it permanently closed,
69
+ // so hovering a non-overflowing header still opens nothing.
70
+ return (_jsx(Tooltip, { content: label, asChild: true, open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-recordstable-th-label", children: label }) }));
71
+ }
20
72
  const EDITABLE = new Set(["text", "number", "url", "currency"]);
21
73
  // types whose cell is an always-interactive picker (not double-click-to-edit)
22
74
  const PICKER = new Set(["status", "reference", "date", "tags"]);
@@ -100,6 +152,8 @@ export const recordsTableLabels = {
100
152
  setRecordPlaceholder: "Set a record…",
101
153
  noRecords: "No records yet",
102
154
  untitled: "Untitled",
155
+ importCsv: "Import CSV",
156
+ connectDataSource: "Connect data source",
103
157
  agg: {
104
158
  none: "None",
105
159
  count: "Count all",
@@ -111,6 +165,9 @@ export const recordsTableLabels = {
111
165
  },
112
166
  op: {
113
167
  contains: "contains",
168
+ doesnotcontain: "does not contain",
169
+ startswith: "starts with",
170
+ endswith: "ends with",
114
171
  is: "is",
115
172
  isnot: "is not",
116
173
  empty: "is empty",
@@ -137,8 +194,17 @@ const compact = new Intl.NumberFormat("en", {
137
194
  const CHECK_W = 40;
138
195
  const ADDCOL_W = 44;
139
196
  const colWidth = (col) => col.width ?? (col.type === "entity" ? 240 : 180);
140
- const OP_ORDER = ["contains", "is", "isnot", "empty", "notempty"];
141
- const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
197
+ const OP_ORDER = [
198
+ "contains",
199
+ "doesnotcontain",
200
+ "startswith",
201
+ "endswith",
202
+ "is",
203
+ "isnot",
204
+ "empty",
205
+ "notempty",
206
+ ];
207
+ const opNeedsValue = (op) => op !== "empty" && op !== "notempty";
142
208
  function matchesFilter(col, row, cond) {
143
209
  if (cond.op === "empty")
144
210
  return !isFilled(col, row);
@@ -152,6 +218,12 @@ function matchesFilter(col, row, cond) {
152
218
  return true;
153
219
  if (cond.op === "contains")
154
220
  return t.includes(v);
221
+ if (cond.op === "doesnotcontain")
222
+ return !t.includes(v);
223
+ if (cond.op === "startswith")
224
+ return t.startsWith(v);
225
+ if (cond.op === "endswith")
226
+ return t.endsWith(v);
155
227
  if (cond.op === "is")
156
228
  return t === v;
157
229
  return t !== v; // isnot
@@ -433,7 +505,7 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
433
505
  * and a floating bulk-action bar (delete / deselect) when rows are selected.
434
506
  * Selection can be controlled via `selectedIds`/`onSelectionChange`.
435
507
  */
436
- export function RecordsTable({ labels, columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", className, ...rest }) {
508
+ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", emptyStateExtra, className, ...rest }) {
437
509
  const [cols, setCols] = React.useState(columnsProp);
438
510
  const [data, setData] = React.useState(rowsProp);
439
511
  // The table owns `cols`/`data` so it can rename a column, edit a cell, or
@@ -1101,7 +1173,7 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1101
1173
  setDragCol(col.key);
1102
1174
  e.dataTransfer.effectAllowed = "move";
1103
1175
  }
1104
- : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-recordstable-th-label", children: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1176
+ : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { label: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1105
1177
  rootRef.current &&
1106
1178
  createPortal(_jsxs("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--hdr", role: "menu", "aria-label": `${col.label} column`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "asc"), children: [_jsx(ArrowUp, { size: 15, strokeWidth: 1.75 }), t.sortAscending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "desc"), children: [_jsx(ArrowDown, { size: 15, strokeWidth: 1.75 }), t.sortDescending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [_jsx(ArrowLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveLeft] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [_jsx(ArrowRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveRight] }), _jsx("div", { className: "crisp-recordstable-menu-sep" }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => {
1107
1179
  setRenamingCol(col.key);
@@ -1170,9 +1242,19 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1170
1242
  }, children: res ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-recordstable-sum-label", children: t.agg[a] }), _jsx("span", { className: "crisp-recordstable-sum-value", children: res.value })] })) : (_jsx("span", { className: "crisp-recordstable-sum-label", children: t.calculate })) }), sumOpen &&
1171
1243
  rootRef.current &&
1172
1244
  createPortal(_jsx("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--up", role: "menu", "aria-label": `${col.label} summary`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: aggOptions(col).map((opt) => (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitemradio", tabIndex: -1, "aria-checked": a === opt, "data-on": a === opt ? "" : undefined, onClick: () => setAggValue(col.key, opt), children: [t.agg[opt], a === opt ? (_jsx(Check, { className: "crisp-recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, opt))) }), rootRef.current)] }) }, col.key));
1173
- }), _jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "crisp-recordstable-empty", children: _jsx(EmptyState, { variant: "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1245
+ }), _jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "crisp-recordstable-empty", children: _jsx(EmptyState
1246
+ // #603: the unfiltered zero-records state, when the consumer
1247
+ // opts into the extra CTAs, uses Attio's own larger measured
1248
+ // tile (Users/Workspaces, 138px) instead of the 45px
1249
+ // filtered-to-zero ring -- two different Attio surfaces.
1250
+ , {
1251
+ // #603: the unfiltered zero-records state, when the consumer
1252
+ // opts into the extra CTAs, uses Attio's own larger measured
1253
+ // tile (Users/Workspaces, 138px) instead of the 45px
1254
+ // filtered-to-zero ring -- two different Attio surfaces.
1255
+ variant: !activeFilters && emptyStateExtra ? "records" : "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: !activeFilters && emptyStateExtra ? 40 : 18, strokeWidth: 1.25, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1174
1256
  ? "None of your records match the filters that are set."
1175
- : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] })) }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsxs(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1257
+ : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [_jsx(Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsxs(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1176
1258
  createPortal(_jsxs("div", { ref: ctxRef, className: "crisp-recordstable-menu crisp-recordstable-ctx", role: "menu", "aria-label": t.recordActions, style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, onKeyDown: onCtxKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => copyRow(ctxMenu.id), children: [_jsx(Copy, { size: 15, strokeWidth: 1.75 }), t.copy] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => duplicateRow(ctxMenu.id), children: [_jsx(CopyPlus, { size: 15, strokeWidth: 1.75 }), t.duplicate] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => pinRow(ctxMenu.id), children: [_jsx(Star, { size: 15, strokeWidth: 1.75 }), t.addToList] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item crisp-recordstable-menu-item--danger", role: "menuitem", tabIndex: -1, onClick: () => deleteRow(ctxMenu.id), children: [_jsx(Trash2, { size: 15, strokeWidth: 1.75 }), t.delete] })] }), rootRef.current ?? document.body)] }));
1177
1259
  }
1178
1260
  export const defaultColumns = [
@@ -40,7 +40,32 @@ const config = defineRecipe({
40
40
  & .crisp-rte-body h1 { font-size: ${c.text.xl}; line-height: 28px; font-weight: ${c.weight.semibold}; letter-spacing: -0.3px; margin-top: ${c.space["4"]}; }
41
41
  & .crisp-rte-body h2 { font-size: 17px; line-height: 24px; font-weight: ${c.weight.semibold}; letter-spacing: -0.2px; margin-top: ${c.space["3"]}; }
42
42
  & .crisp-rte-body h3 { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.semibold}; margin-top: ${c.space["2.5"]}; }
43
- & .crisp-rte-body ul, & .crisp-rte-body ol { padding-inline-start: ${c.space["5"]}; }
43
+ /* #575: a consumer's CSS reset (e.g. Tailwind preflight's un-scoped
44
+ ul, ol { list-style: none }, specificity 0,0,1) was winning every
45
+ list marker outright -- this rule set padding but never declared
46
+ list-style-type, so there was no higher-specificity value (this
47
+ selector is 0,1,1) to beat the reset with. Split into two
48
+ single-selector rules, each declaring its own marker type, rather
49
+ than the old single comma-joined rule ( .crisp-rte-body ul, &
50
+ .crisp-rte-body ol ) the issue flagged as malformed.
51
+ #611: padding-inline-start re-measured live once CRUD on the
52
+ measurement workspace was allowed (a "zz-parity" note with a
53
+ bulleted list, a nested bulleted list, and a numbered list) --
54
+ Attio's own <ul>/<ol> measures 36px (getComputedStyle,
55
+ ~36.1353px), not the 20px placeholder #575 shipped with no real
56
+ content to check against. 36px isn't in tokens/scale.ts (steps
57
+ stop at 24px, jump to 32px) and is a single component-intrinsic
58
+ value, so it stays a raw px per AUTHORING.md's golden rule 4b
59
+ rather than being rounded to an existing token or added to the
60
+ shared scale. Marker colour (currentColor via the already-measured
61
+ fgPrimary, confirmed to equal Attio's measured rgb(16,17,18) ==
62
+ #101112 exactly) and the ~4px li-to-li gap (the existing li
63
+ margin-top rule below) were re-checked at the same time and
64
+ already matched -- only the padding was wrong. Attio doesn't vary
65
+ marker type for a nested list either (nested <ul> is disc too,
66
+ not circle), which this already doesn't attempt. */
67
+ & .crisp-rte-body ul { list-style-type: disc; padding-inline-start: 36px; }
68
+ & .crisp-rte-body ol { list-style-type: decimal; padding-inline-start: 36px; }
44
69
  & .crisp-rte-body li { margin-top: ${c.space["1"]}; }
45
70
  & .crisp-rte-body blockquote { padding-inline-start: ${c.space["3"]}; border-inline-start: 2px solid ${c.borderDefault}; color: ${c.fgMuted}; }
46
71
  & .crisp-rte-body code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: ${c.text.md}; padding: 1px ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
@@ -1,6 +1,6 @@
1
1
  .crisp-headerbar {
2
2
  box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
3
- &[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
3
+ &[data-bordered] { height: calc(var(--crisp-size-header) + 1px); border-bottom: 1px solid var(--crisp-border-subtle); }
4
4
  & .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
5
5
  & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
6
6
  & .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }
@@ -1,7 +1,7 @@
1
1
  @layer crisp-components {
2
2
  .crisp-headerbar {
3
3
  box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
4
- &[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
4
+ &[data-bordered] { height: calc(var(--crisp-size-header) + 1px); border-bottom: 1px solid var(--crisp-border-subtle); }
5
5
  & .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
6
6
  & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
7
7
  & .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }