Records Table
CRM-style grid with tags, sorting and relationship status.
"use client";
/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import "./styles/records-table.css";
import "./styles/foundation.css";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import GlideMenu from "./GlideMenu";
/* ─────────────────────────────────────────────────────────
* RECORDS TABLE — an AI spreadsheet grid. Columns are
* *properties*: click a header to open its configuration
* popover (type, tool, grounding, inputs, prompt, run), add
* a new AI property from the + header, and watch cells
* resolve row-by-row while it calculates.
* ───────────────────────────────────────────────────────── */
type Strength = "strong" | "weak" | "veryweak" | "none";
type SortKey = "name" | "last" | "strength";
type ColumnKey = "company" | "categories" | "last" | "strength" | "links" | "ai";
const DEFAULT_COLUMN_WIDTHS: Record<ColumnKey, number> = {
company: 270,
categories: 275,
last: 190,
strength: 210,
links: 175,
ai: 240,
};
const STRENGTH: Record<Strength, { label: string; color: string; rank: number }> = {
strong: { label: "Very strong", color: "var(--green)", rank: 3 },
weak: { label: "Weak", color: "var(--orange)", rank: 2 },
veryweak: { label: "Very weak", color: "var(--red)", rank: 1 },
none: { label: "No communication", color: "var(--ink-3)", rank: 0 },
};
// A single mid-lightness base hue per tag. Background, text, and border are
// derived from this via color-mix() against the theme tokens in .records-tag,
// so the chips adapt to light and dark automatically (same pattern as FilterTable).
type TagColor = { base: string };
const TAG_PALETTE: Record<string, TagColor> = {
amber: { base: "oklch(0.76 0.13 70)" },
lime: { base: "oklch(0.77 0.16 122)" },
yellow: { base: "oklch(0.80 0.15 101)" },
purple: { base: "oklch(0.62 0.18 293)" },
orange: { base: "oklch(0.71 0.16 48)" },
cyan: { base: "oklch(0.72 0.10 221)" },
red: { base: "oklch(0.64 0.19 27)" },
magenta: { base: "oklch(0.66 0.21 323)" },
green: { base: "oklch(0.70 0.13 162)" },
pink: { base: "oklch(0.67 0.19 3)" },
};
const TAG_COLORS: Record<string, TagColor> = {
B2B: TAG_PALETTE.amber,
B2C: TAG_PALETTE.lime,
Cafe: TAG_PALETTE.red,
Catering: TAG_PALETTE.magenta,
"Dairy-free": TAG_PALETTE.cyan,
Brand: TAG_PALETTE.purple,
Imports: TAG_PALETTE.orange,
Local: TAG_PALETTE.green,
Seasonal: TAG_PALETTE.yellow,
Sorbet: TAG_PALETTE.pink,
Vegan: TAG_PALETTE.lime,
Wholesale: TAG_PALETTE.amber,
};
export type RecordRow = {
id: string;
name: string;
tags: string[];
last: string;
strength: Strength;
website?: string;
};
const INITIAL_ROWS: RecordRow[] = [
{ id: "aurora", name: "Aurora Works — Reykjavík", tags: ["Brand", "Seasonal"], last: "9 days ago", strength: "strong", website: "aurora-works.example.com" },
{ id: "kumo", name: "Kumo Studio — Tokyo", tags: ["B2C", "Cafe", "Vegan"], last: "3 weeks ago", strength: "strong", website: "kumo-studio.example.com" },
{ id: "sol-nieve", name: "Sol y Nieve — Buenos Aires", tags: ["Brand", "Local"], last: "2 months ago", strength: "weak", website: "sol-y-nieve.example.com" },
{ id: "maple-orbit", name: "Maple Orbit — Montréal", tags: ["B2B", "Wholesale", "Seasonal"], last: "15 days ago", strength: "weak", website: "maple-orbit.example.com" },
{ id: "blue-fig", name: "Blue Fig Brand — Florence", tags: ["Brand", "Cafe"], last: "over 1 year ago", strength: "veryweak", website: "blue-fig.example.com" },
{ id: "sahara-swirl", name: "Sahara Swirl — Marrakech", tags: ["Sorbet", "Local"], last: "5 months ago", strength: "veryweak" },
{ id: "cloudberry", name: "Cloudberry Asset — Helsinki", tags: ["Dairy-free", "Seasonal"], last: "No contact", strength: "none", website: "cloudberry-asset.example.com" },
{ id: "palm-sugar", name: "Palm Sugar Studio — Bangkok", tags: ["B2C", "Vegan"], last: "3 months ago", strength: "veryweak", website: "palm-sugar.example.com" },
{ id: "cape-vanilla", name: "Cape Vanilla Co. — Cape Town", tags: ["Wholesale", "Imports"], last: "over 1 year ago", strength: "veryweak", website: "cape-vanilla.example.com" },
{ id: "andes-snow", name: "Andes Snow Studio — Quito", tags: ["Brand", "Catering"], last: "almost 2 years ago", strength: "veryweak" },
{ id: "tasman-sea", name: "Tasman Sea Brand — Hobart", tags: ["Brand", "Local"], last: "2 months ago", strength: "weak", website: "tasman-sea.example.com" },
{ id: "silk-road", name: "Silk Road Sorbet — Tbilisi", tags: ["Sorbet", "Imports"], last: "about 1 month ago", strength: "weak", website: "silk-road.example.com" },
{ id: "rosewater", name: "Rosewater Kulfi — Jaipur", tags: ["B2C", "Seasonal"], last: "2 months ago", strength: "veryweak" },
{ id: "lumen", name: "Lumen Soft Serve — Copenhagen", tags: ["Dairy-free", "Cafe"], last: "8 months ago", strength: "weak", website: "lumen-soft-serve.example.com" },
{ id: "cacao-norte", name: "Cacao Norte — Oaxaca", tags: ["B2B", "Local", "Wholesale"], last: "about 2 years ago", strength: "none", website: "cacao-norte.example.com" },
{ id: "pine-pistachio", name: "Pine & Pistachio — Istanbul", tags: ["Brand", "Catering"], last: "about 1 month ago", strength: "veryweak" },
{ id: "ember-asset", name: "Ember Asset Company — Seoul", tags: ["B2C", "Vegan"], last: "15 days ago", strength: "weak", website: "ember-asset.example.com" },
{ id: "coral-coast", name: "Coral Coast Sorbet — Honolulu", tags: ["Sorbet", "Local"], last: "9 days ago", strength: "strong", website: "coral-coast.example.com" },
{ id: "sunbird", name: "Sunbird Gelateria — Lisbon", tags: ["Brand", "Cafe"], last: "over 2 years ago", strength: "none", website: "sunbird.example.com" },
{ id: "mooncake", name: "Mooncake Design — Singapore", tags: ["B2B", "Wholesale"], last: "about 1 month ago", strength: "veryweak", website: "mooncake-ice-cream.example.com" },
{ id: "juniper", name: "Juniper & Cream — Vancouver", tags: ["Dairy-free", "Catering"], last: "No contact", strength: "none" },
{ id: "mango-moon", name: "Mango Moon Brand — Nairobi", tags: ["Sorbet", "Vegan"], last: "almost 2 years ago", strength: "veryweak", website: "mango-moon.example.com" },
{ id: "fjord-fizz", name: "Fjord Fizz Ice — Oslo", tags: ["Dairy-free", "Seasonal"], last: "No contact", strength: "none" },
{ id: "pampa", name: "Pampa Studio — Córdoba", tags: ["B2C", "Local"], last: "12 months ago", strength: "veryweak", website: "pampa-studio.example.com" },
{ id: "lotus-leaf", name: "Lotus Leaf Works — Hanoi", tags: ["Vegan", "Cafe"], last: "15 days ago", strength: "weak" },
{ id: "saffron-sky", name: "Saffron Sky Kulfi — Dubai", tags: ["Imports", "Catering"], last: "almost 2 years ago", strength: "veryweak", website: "saffron-sky.example.com" },
{ id: "alpine-plan", name: "Alpine Plan — Zürich", tags: ["B2B", "Brand", "Wholesale"], last: "4 days ago", strength: "strong", website: "alpine-plan.example.com" },
{ id: "monsoon-mango", name: "Monsoon Mango — Mumbai", tags: ["Sorbet", "Vegan", "Catering"], last: "18 days ago", strength: "weak", website: "monsoon-mango.example.com" },
{ id: "cedar-spoon", name: "Cedar Spoon — Beirut", tags: ["Cafe", "Local", "Seasonal"], last: "6 days ago", strength: "strong", website: "cedar-spoon.example.com" },
{ id: "baltic-berry", name: "Baltic Berry — Tallinn", tags: ["Dairy-free", "Seasonal", "B2C"], last: "5 weeks ago", strength: "weak", website: "baltic-berry.example.com" },
{ id: "delta-dairy", name: "Delta Dairy Works — New Orleans", tags: ["B2B", "Wholesale", "Local"], last: "2 days ago", strength: "strong", website: "delta-dairy.example.com" },
{ id: "yuzu-yard", name: "Yuzu Yard — Kyoto", tags: ["Sorbet", "Cafe", "Seasonal"], last: "11 days ago", strength: "strong", website: "yuzu-yard.example.com" },
{ id: "copper-asset", name: "Copper Asset — Melbourne", tags: ["Brand", "Cafe", "B2C"], last: "about 1 month ago", strength: "weak", website: "copper-asset.example.com" },
{ id: "mint-medina", name: "Mint Medina — Tunis", tags: ["Dairy-free", "Vegan", "Local"], last: "No contact", strength: "none" },
{ id: "glacier-grove", name: "Glacier Grove — Anchorage", tags: ["Seasonal", "Local", "Catering"], last: "7 weeks ago", strength: "weak", website: "glacier-grove.example.com" },
{ id: "orchard-cloud", name: "Orchard Cloud — Lyon", tags: ["Brand", "Seasonal", "Cafe"], last: "5 days ago", strength: "strong", website: "orchard-cloud.example.com" },
{ id: "tamarind-tide", name: "Tamarind Tide — Chennai", tags: ["Vegan", "Sorbet", "B2C"], last: "9 months ago", strength: "veryweak", website: "tamarind-tide.example.com" },
{ id: "amber-work", name: "Amber Work — Prague", tags: ["Brand", "B2B"], last: "over 1 year ago", strength: "none" },
{ id: "boreal-batch", name: "Boreal Batch — Yellowknife", tags: ["Dairy-free", "Local", "Seasonal"], last: "8 days ago", strength: "strong", website: "boreal-batch.example.com" },
{ id: "coconut-commons", name: "Coconut Commons — Manila", tags: ["Vegan", "B2C", "Cafe"], last: "24 days ago", strength: "weak", website: "coconut-commons.example.com" },
{ id: "dolomite-dairy", name: "Dolomite Dairy — Bolzano", tags: ["Brand", "Wholesale"], last: "3 days ago", strength: "strong", website: "dolomite-dairy.example.com" },
{ id: "equator-cream", name: "Equator Cream — Kampala", tags: ["B2B", "Catering", "Local"], last: "10 months ago", strength: "veryweak", website: "equator-cream.example.com" },
{ id: "hibiscus-house", name: "Hibiscus House — Accra", tags: ["Sorbet", "Cafe"], last: "6 weeks ago", strength: "weak", website: "hibiscus-house.example.com" },
{ id: "lagoon-ladle", name: "Lagoon Ladle — Venice", tags: ["Brand", "Seasonal", "Catering"], last: "7 days ago", strength: "strong", website: "lagoon-ladle.example.com" },
{ id: "midnight-milk", name: "Midnight Milk — Tromsø", tags: ["Dairy-free", "Vegan", "Wholesale"], last: "No contact", strength: "none" },
{ id: "nomad-nougat", name: "Nomad Nougat — Ulaanbaatar", tags: ["Imports", "B2B"], last: "almost 2 years ago", strength: "none", website: "nomad-nougat.example.com" },
{ id: "olive-snow", name: "Olive Snow — Athens", tags: ["Brand", "Cafe", "Local"], last: "4 days ago", strength: "strong", website: "olive-snow.example.com" },
{ id: "pacific-pear", name: "Pacific Pear — Valparaíso", tags: ["Sorbet", "Seasonal"], last: "2 months ago", strength: "weak", website: "pacific-pear.example.com" },
{ id: "quartz-asset", name: "Quartz Asset — Denver", tags: ["B2C", "Wholesale"], last: "10 days ago", strength: "strong", website: "quartz-asset.example.com" },
{ id: "red-lantern", name: "Red Lantern Studio — Taipei", tags: ["Cafe", "Vegan"], last: "about 1 month ago", strength: "weak", website: "red-lantern.example.com" },
{ id: "salt-silk", name: "Salt & Silk — Muscat", tags: ["Imports", "Catering", "Brand"], last: "8 months ago", strength: "veryweak", website: "salt-and-silk.example.com" },
{ id: "tropic-plan", name: "Tropic Plan — San Juan", tags: ["Sorbet", "Local", "B2C"], last: "6 days ago", strength: "strong", website: "tropic-plan.example.com" },
{ id: "umber-cream", name: "Umber Cream — Warsaw", tags: ["B2B", "Wholesale", "Cafe"], last: "5 weeks ago", strength: "weak", website: "umber-cream.example.com" },
{ id: "vanilla-vale", name: "Vanilla Vale — Antananarivo", tags: ["Imports", "Local"], last: "No contact", strength: "none" },
{ id: "willow-whip", name: "Willow Whip — Portland", tags: ["Dairy-free", "Vegan", "Cafe"], last: "3 days ago", strength: "strong", website: "willow-whip.example.com" },
{ id: "zenith-brand", name: "Zenith Brand — Auckland", tags: ["Brand", "Seasonal"], last: "3 weeks ago", strength: "weak", website: "zenith-brand.example.com" },
{ id: "apricot-atlas", name: "Apricot Atlas — Algiers", tags: ["Sorbet", "Imports"], last: "11 months ago", strength: "veryweak", website: "apricot-atlas.example.com" },
{ id: "black-sesame", name: "Black Sesame Social — Bandung", tags: ["Vegan", "Cafe", "B2C"], last: "9 days ago", strength: "strong", website: "black-sesame.example.com" },
{ id: "crimson-clover", name: "Crimson Clover — Brussels", tags: ["Brand", "Wholesale", "Catering"], last: "2 months ago", strength: "weak", website: "crimson-clover.example.com" },
{ id: "dragonfruit-dock", name: "Dragonfruit Dock — Shenzhen", tags: ["Sorbet", "B2B", "Wholesale"], last: "No contact", strength: "none" },
];
/* the AI column resolves to fictional competitor pairs */
const AI_LABEL = "Competitors";
const COMPETITOR_POOL = [
"Frost & Ladle",
"Polar Pint Co.",
"Meltwater Studio",
"Cirrus Works",
"Golden Plan",
"Velvet Freeze",
"North Asset Collective",
"Sundae Syndicate",
];
const competitorsFor = (index: number) => `${COMPETITOR_POOL[index % 8]}, ${COMPETITOR_POOL[(index + 3) % 8]}`;
function Icon({ children, size = 14, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{children}
</svg>
);
}
/* glyph library for property types & tools */
const TYPE_GLYPHS: Record<string, React.ReactNode> = {
Text: <path d="M4 6h16M4 12h10M4 18h7" />,
File: <g><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,
Collection: <g><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" /></g>,
"Single select": <g><circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.4 2.4 4.6-4.9" /></g>,
"Multi select": <g><path d="M11 6h9M11 12h9M11 18h9" /><path d="M4 6l1.5 1.5L8 5M4 12l1.5 1.5L8 11M4 18l1.5 1.5L8 17" /></g>,
URL: <g><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1" /></g>,
Reference: <path d="M7 17 17 7M9 7h8v8" />,
JSON: <g><path d="M8 4c-2 0-2 2-2 3s.5 3-2 3c2.5 0 2 2 2 3s0 3 2 3" /><path d="M16 4c2 0 2 2 2 3s-.5 3 2 3c-2.5 0-2 2-2 3s0 3-2 3" /></g>,
"File splitter": <g><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></g>,
Date: <g><rect x="3" y="5" width="18" height="16" rx="2.5" /><path d="M8 3v4M16 3v4M3 10h18" /></g>,
};
const TOOL_GLYPHS: Record<string, React.ReactNode> = {
model: <path d="M12 3l1.7 5.1a2 2 0 0 0 1.2 1.2L20 11l-5.1 1.7a2 2 0 0 0-1.2 1.2L12 19l-1.7-5.1a2 2 0 0 0-1.2-1.2L4 11l5.1-1.7a2 2 0 0 0 1.2-1.2z" />,
web: <g><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a13.5 13.5 0 0 1 3.5 9 13.5 13.5 0 0 1-3.5 9 13.5 13.5 0 0 1-3.5-9A13.5 13.5 0 0 1 12 3z" /></g>,
user: <g><circle cx="12" cy="8" r="4" /><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" /></g>,
};
/* per-property configuration shown in the popover */
type Prompt = { before: string; chip?: string; after?: string };
type ToolKind = "model" | "web" | "user";
type ColumnMeta = { type: string; tool: string; toolKind: ToolKind; inputs?: string; prompt?: Prompt };
const COLUMN_META: Record<string, ColumnMeta> = {
Company: { type: "Text", tool: "User input", toolKind: "user" },
Categories: { type: "Multi select", tool: "Sprinkles 5", toolKind: "model", inputs: "Company", prompt: { before: "Tag each ", chip: "Company", after: " with its market categories." } },
"Last interaction": { type: "Date", tool: "User input", toolKind: "user" },
"Connection strength": { type: "Single select", tool: "Sprinkles 5", toolKind: "model", inputs: "Last interaction", prompt: { before: "Score the relationship from ", chip: "Last interaction", after: "." } },
Links: { type: "URL", tool: "Web search", toolKind: "web", inputs: "Company", prompt: { before: "Find the website for ", chip: "Company", after: "." } },
[AI_LABEL]: { type: "Text", tool: "Web search", toolKind: "web", inputs: "Company", prompt: { before: "Find competitors for ", chip: "Company" } },
};
const NEW_PROPERTY_TYPES = ["Text", "File", "Collection", "Single select", "Multi select", "URL", "Reference", "JSON", "File splitter"];
const MODEL_OPTIONS = ["Sprinkles 5", "Sprinkles 4.2", "Sprinkles Mini"];
const INPUT_OPTIONS = ["Company", "Categories", "Last interaction", "Connection strength", "Links"];
function Checkbox({ checked, mixed = false, onChange, label }: { checked: boolean; mixed?: boolean; onChange: () => void; label: string }) {
return (
<label className="records-checkbox" title={label} onClick={(event) => event.stopPropagation()}>
<input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
<span className={`records-checkbox-box ${checked || mixed ? "is-active" : ""}`}>
{mixed ? <span className="records-checkbox-dash" /> : checked ? <Icon size={12}><path d="m5 12 4 4L19 6" /></Icon> : null}
</span>
</label>
);
}
function Tag({ name }: { name: string }) {
const color = TAG_COLORS[name] ?? { base: "var(--ink-3)" };
return (
<span
className="records-tag"
style={{ "--tag-base": color.base } as React.CSSProperties}
>
{name}
</span>
);
}
function TagList({ tags }: { tags: string[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(tags.length);
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const update = () => {
const available = container.clientWidth;
const tagWidths = Array.from(measure.querySelectorAll<HTMLElement>("[data-tag-measure]"), (tag) => tag.offsetWidth);
const moreWidth = measure.querySelector<HTMLElement>("[data-more-measure]")?.offsetWidth ?? 0;
let used = 0;
let count = 0;
for (let index = 0; index < tagWidths.length; index += 1) {
const nextUsed = used + (count > 0 ? 4 : 0) + tagWidths[index];
const hiddenAfter = tags.length - (index + 1);
const totalWithOverflow = nextUsed + (hiddenAfter > 0 ? 4 + moreWidth : 0);
if (totalWithOverflow > available) break;
used = nextUsed;
count += 1;
}
setVisibleCount(count);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
return () => observer.disconnect();
}, [tags]);
const hiddenCount = tags.length - visibleCount;
return (
<div ref={containerRef} className="records-tags" title={tags.join(", ")} aria-label={`Categories: ${tags.join(", ")}`}>
<div ref={measureRef} className="records-tags-measure" aria-hidden>
{tags.map((tag) => <span key={tag} data-tag-measure><Tag name={tag} /></span>)}
<span data-more-measure className="records-more-tag">+{tags.length}</span>
</div>
{tags.slice(0, visibleCount).map((tag) => <Tag key={tag} name={tag} />)}
{hiddenCount > 0 && <span className="records-more-tag">+{hiddenCount}</span>}
</div>
);
}
function CalcCell() {
return (
<span className="records-calc">
<span className="records-muted">Calculating…</span>
<span className="records-pulse" />
</span>
);
}
function MiniSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label: string }) {
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-label={label}
onClick={onToggle}
className="relative h-4.5 w-7.5 shrink-0 rounded-full transition-colors duration-150"
style={{ background: on ? "var(--highlight)" : "var(--line-strong)" }}
>
<span
className="absolute top-0.5 left-0.5 size-3.5 rounded-full bg-white shadow-btn transition-transform duration-150"
style={{ transform: on ? "translateX(12px)" : "translateX(0)", transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)" }}
/>
</button>
);
}
function HeaderCell({ label, icon, sortKey, sort, onSort, onResizeStart, resizing = false, className = "", selected = false, onPick }: { label: string; icon: React.ReactNode; sortKey?: SortKey; sort: { key: SortKey; dir: 1 | -1 }; onSort: (key: SortKey) => void; onResizeStart: (event: React.PointerEvent<HTMLSpanElement>) => void; resizing?: boolean; className?: string; selected?: boolean; onPick?: (event: React.MouseEvent) => void }) {
return (
<th className={`records-header-cell ${selected ? "is-colsel" : ""} ${className}`}>
{/* header click opens the property config; the arrow sorts */}
<button type="button" className="records-header-button" onClick={onPick}>
<span className="records-header-icon">{icon}</span>
<span className="truncate">{label}</span>
{sortKey && (
<span
role="button"
tabIndex={0}
aria-label={`Sort by ${label}`}
onClick={(event) => {
event.stopPropagation();
onSort(sortKey);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onSort(sortKey);
}
}}
className={`records-sort ${sort.key === sortKey ? "is-visible" : ""}`}
style={{ transform: sort.key === sortKey && sort.dir === -1 ? "rotate(180deg)" : undefined }}
>
<Icon size={12}><path d="M12 5v14M5 12l7 7 7-7" /></Icon>
</span>
)}
</button>
<span
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${label} column`}
className={`records-resize-handle ${resizing ? "is-resizing" : ""}`}
onPointerDown={onResizeStart}
/>
</th>
);
}
/* config row inside the property popover */
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="relative flex h-8 items-center justify-between">
<span className="text-[13px] text-ink-3">{label}</span>
{children}
</div>
);
}
function ConfigPicker({
label,
options,
selected,
onSelect,
}: {
label: string;
options: { label: string; icon: React.ReactNode }[];
selected: string;
onSelect: (value: string) => void;
}) {
return (
<div
role="menu"
aria-label={label}
className="absolute left-full top-0 z-30 ml-5 w-[210px] rounded-[12px] bg-card p-1.5 shadow-overlay"
style={{ animation: "pop-in 140ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
>
<div className="px-2 pb-1 pt-0.5 text-[11.5px] font-medium text-ink-3">{label}</div>
<GlideMenu className="flex flex-col gap-px">
{options.map((option) => (
<button
key={option.label}
data-menu-row
type="button"
role="menuitemradio"
aria-checked={selected === option.label}
onClick={() => onSelect(option.label)}
className="relative z-10 flex h-8 w-full items-center gap-1.5 rounded-[8px] px-1.5 text-left text-[13px] font-medium text-ink"
>
<span className="flex size-4 shrink-0 items-center justify-center text-ink-2">{option.icon}</span>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
<span className={selected === option.label ? "text-ink" : "invisible"}>
<Icon size={14} strokeWidth={2.2}><path d="m5 12 4 4L19 6" /></Icon>
</span>
</button>
))}
</GlideMenu>
</div>
);
}
function InputPicker({
options,
selected,
onToggle,
}: {
options: string[];
selected: string[];
onToggle: (value: string) => void;
}) {
return (
<div
role="menu"
aria-label="Calculation inputs"
className="absolute left-full top-0 z-30 ml-5 w-[220px] rounded-[12px] bg-card p-1.5 shadow-overlay"
style={{ animation: "pop-in 140ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
>
<div className="px-2 pb-1 pt-0.5 text-[11.5px] font-medium text-ink-3">Use values from</div>
<GlideMenu className="flex flex-col gap-px">
{options.map((option) => {
const checked = selected.includes(option);
return (
<button
key={option}
data-menu-row
type="button"
role="menuitemcheckbox"
aria-checked={checked}
onClick={() => onToggle(option)}
className="relative z-10 flex h-8 w-full items-center gap-1.5 rounded-[8px] px-1.5 text-left text-[13px] font-medium text-ink"
>
<span className={`flex size-4 shrink-0 items-center justify-center rounded-[5px] border ${checked ? "border-highlight bg-highlight text-primary-foreground" : "border-line-strong text-transparent"}`}>
<Icon size={11} strokeWidth={2.4}><path d="m5 12 4 4L19 6" /></Icon>
</span>
<span className="min-w-0 flex-1 truncate">{option}</span>
</button>
);
})}
</GlideMenu>
</div>
);
}
export default function RecordsTable({ rows = INITIAL_ROWS, fill = false }: { rows?: RecordRow[]; fill?: boolean; variant?: string }) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: "name", dir: 1 });
const [columnWidths, setColumnWidths] = useState(DEFAULT_COLUMN_WIDTHS);
const [actionColumnWidth, setActionColumnWidth] = useState(100);
const [columnWidthsLocked, setColumnWidthsLocked] = useState(false);
const [resizingColumn, setResizingColumn] = useState<ColumnKey | null>(null);
const initialColumnWidthsRef = useRef<Record<ColumnKey, number> | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
/* property popover, anchored to the clicked header */
const [prop, setProp] = useState<{ col: string; x: number; y: number } | null>(null);
const [grounding, setGrounding] = useState(false);
const [groundingHelpOpen, setGroundingHelpOpen] = useState(false);
const [configMenu, setConfigMenu] = useState<"type" | "tool" | "inputs" | null>(null);
const [columnOverrides, setColumnOverrides] = useState<Record<string, Partial<ColumnMeta>>>({});
const [inputSelections, setInputSelections] = useState<Record<string, string[]>>({});
const [pinnedColumns, setPinnedColumns] = useState<Set<string>>(new Set());
const [moreSettingsOpen, setMoreSettingsOpen] = useState(false);
const [advancedSettings, setAdvancedSettings] = useState({ required: false, allowEmpty: true, confidence: false });
/* + new-property menu */
const [addOpen, setAddOpen] = useState<{ x: number; y: number } | null>(null);
const [tableMenuOpen, setTableMenuOpen] = useState<{ x: number; y: number } | null>(null);
/* the added AI column and its lifecycle */
const [aiAdded, setAiAdded] = useState(false);
const [aiDone, setAiDone] = useState(false);
const [pendingOpenAi, setPendingOpenAi] = useState(false);
const aiThRef = useRef<HTMLTableCellElement>(null);
/* programmatic scrolls (revealing the new column) shouldn't close popovers */
const ignoreScrollRef = useRef(false);
/* a running calculation resolves rows one by one */
const [calc, setCalc] = useState<{ col: string; resolved: number } | null>(null);
/* Let the table fill its available space once, then capture those rendered
* widths before paint. From that point on every column is explicit, so a
* resize changes only the dragged column and the table's total width. */
useLayoutEffect(() => {
if (columnWidthsLocked || !tableRef.current) return;
const headers = Array.from(tableRef.current.querySelectorAll<HTMLTableCellElement>("thead th"));
if (headers.length < 6) return;
const measured: Record<ColumnKey, number> = {
company: headers[0].getBoundingClientRect().width,
categories: headers[1].getBoundingClientRect().width,
last: headers[2].getBoundingClientRect().width,
strength: headers[3].getBoundingClientRect().width,
links: headers[4].getBoundingClientRect().width,
ai: DEFAULT_COLUMN_WIDTHS.ai,
};
initialColumnWidthsRef.current = measured;
setColumnWidths(measured);
setActionColumnWidth(headers[headers.length - 1].getBoundingClientRect().width);
setColumnWidthsLocked(true);
}, [columnWidthsLocked]);
const visibleRows = useMemo(() => {
return [...rows].sort((a, b) => {
const value = sort.key === "name"
? a.name.localeCompare(b.name)
: sort.key === "last"
? a.last.localeCompare(b.last)
: STRENGTH[a.strength].rank - STRENGTH[b.strength].rank;
return value * sort.dir;
});
}, [rows, sort]);
/* stagger: one row resolves every beat */
useEffect(() => {
if (!calc) return;
if (calc.resolved > visibleRows.length) {
const timer = setTimeout(() => {
if (calc.col === AI_LABEL) setAiDone(true);
setCalc(null);
}, 0);
return () => clearTimeout(timer);
}
const t = setTimeout(() => setCalc((current) => (current ? { ...current, resolved: current.resolved + 1 } : current)), 110);
return () => clearTimeout(t);
}, [calc, visibleRows.length]);
/* after adding the AI column, scroll it into view and open its config
* anchored to the new header */
useEffect(() => {
if (!pendingOpenAi || !aiThRef.current) return;
const scroller = aiThRef.current.closest(".records-scroll");
if (scroller) {
ignoreScrollRef.current = true;
scroller.scrollLeft = scroller.scrollWidth;
}
const rect = aiThRef.current.getBoundingClientRect();
setProp({ col: AI_LABEL, x: Math.min(rect.left, window.innerWidth - 336), y: rect.bottom + 6 });
setPendingOpenAi(false);
}, [pendingOpenAi, aiAdded]);
/* click anywhere else closes popovers */
useEffect(() => {
if (!prop && !addOpen && !tableMenuOpen) return;
const close = (event: PointerEvent) => {
if (!(event.target as Element).closest("[data-recpop]")) {
setProp(null);
setConfigMenu(null);
setGroundingHelpOpen(false);
setMoreSettingsOpen(false);
setAddOpen(null);
setTableMenuOpen(null);
}
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [prop, addOpen, tableMenuOpen]);
const openProp = (col: string) => (event: React.MouseEvent) => {
const th = (event.currentTarget as Element).closest("th");
if (!th) return;
setAddOpen(null);
setTableMenuOpen(null);
setConfigMenu(null);
setGroundingHelpOpen(false);
setMoreSettingsOpen(false);
setProp((current) => {
if (current?.col === col) return null;
const rect = th.getBoundingClientRect();
return { col, x: Math.min(rect.left, window.innerWidth - 336), y: rect.bottom + 6 };
});
};
const isCalc = (col: string, index: number) => !!calc && calc.col === col && index >= calc.resolved;
const allSelected = visibleRows.length > 0 && visibleRows.every((row) => selected.has(row.id));
const partiallySelected = !allSelected && visibleRows.some((row) => selected.has(row.id));
const toggleSort = (key: SortKey) => setSort((current) => current.key === key ? { key, dir: (current.dir * -1) as 1 | -1 } : { key, dir: 1 });
const startColumnResize = (key: ColumnKey, minWidth = 120) => (event: React.PointerEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
setProp(null);
setConfigMenu(null);
setGroundingHelpOpen(false);
setMoreSettingsOpen(false);
setAddOpen(null);
setTableMenuOpen(null);
const startX = event.clientX;
const startWidth = columnWidths[key];
const previousCursor = document.body.style.cursor;
const previousSelection = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setResizingColumn(key);
const move = (moveEvent: PointerEvent) => {
const width = Math.max(minWidth, startWidth + moveEvent.clientX - startX);
setColumnWidths((current) => ({ ...current, [key]: width }));
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousSelection;
setResizingColumn(null);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
};
const toggleRow = (id: string) => setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const toggleAll = () => setSelected((current) => {
const next = new Set(current);
if (allSelected) visibleRows.forEach((row) => next.delete(row.id));
else visibleRows.forEach((row) => next.add(row.id));
return next;
});
const meta = prop ? { ...COLUMN_META[prop.col], ...columnOverrides[prop.col] } : null;
const selectedInputs = prop && meta
? inputSelections[prop.col] ?? (meta.inputs ? [meta.inputs] : [])
: [];
const tableWidth = columnWidths.company + columnWidths.categories + columnWidths.last + columnWidths.strength + columnWidths.links + (aiAdded ? columnWidths.ai : 0) + actionColumnWidth;
return (
<div className={`records-shell${fill ? " is-fill" : ""}`}>
<div
className="records-scroll"
tabIndex={0}
aria-label="Companies table. Scroll horizontally and vertically to view all columns and records."
onScroll={() => {
if (ignoreScrollRef.current) {
ignoreScrollRef.current = false;
return;
}
setProp(null);
setConfigMenu(null);
setGroundingHelpOpen(false);
setMoreSettingsOpen(false);
setAddOpen(null);
setTableMenuOpen(null);
}}
>
<table ref={tableRef} className="records-table" style={{ width: columnWidthsLocked ? tableWidth : "100%", minWidth: tableWidth }}>
<colgroup>
<col className="records-company-col" style={{ width: columnWidths.company }} />
<col className="records-category-col" style={{ width: columnWidths.categories }} />
<col className="records-last-col" style={{ width: columnWidths.last }} />
<col className="records-strength-col" style={{ width: columnWidths.strength }} />
<col className="records-link-col" style={{ width: columnWidths.links }} />
{aiAdded && <col style={{ width: columnWidths.ai }} />}
<col style={{ width: 100 }} />
</colgroup>
<thead>
<tr>
<th className={`records-header-cell records-sticky-cell ${prop?.col === "Company" ? "is-colsel" : ""}`}>
<div className="records-company-header" style={{ cursor: "pointer" }} onClick={(event) => openProp("Company")(event)}>
<Checkbox checked={allSelected} mixed={partiallySelected} onChange={toggleAll} label="Select all companies" />
<span>Company</span>
</div>
<span role="separator" aria-orientation="vertical" aria-label="Resize Company column" className={`records-resize-handle ${resizingColumn === "company" ? "is-resizing" : ""}`} onPointerDown={startColumnResize("company", 180)} />
</th>
<HeaderCell label="Categories" selected={prop?.col === "Categories"} onPick={openProp("Categories")} sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("categories")} resizing={resizingColumn === "categories"} icon={<Icon size={15}>{TYPE_GLYPHS["Multi select"]}</Icon>} />
<HeaderCell label="Last interaction" selected={prop?.col === "Last interaction"} onPick={openProp("Last interaction")} sortKey="last" sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("last")} resizing={resizingColumn === "last"} icon={<Icon size={15}>{TYPE_GLYPHS.Date}</Icon>} />
<HeaderCell label="Connection strength" selected={prop?.col === "Connection strength"} onPick={openProp("Connection strength")} sortKey="strength" sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("strength")} resizing={resizingColumn === "strength"} icon={<Icon size={15}>{TYPE_GLYPHS["Single select"]}</Icon>} />
<HeaderCell label="Links" selected={prop?.col === "Links"} onPick={openProp("Links")} sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("links")} resizing={resizingColumn === "links"} icon={<Icon size={15}>{TYPE_GLYPHS.URL}</Icon>} />
{aiAdded && (
<th ref={aiThRef} className={`records-header-cell ${prop?.col === AI_LABEL ? "is-colsel" : ""}`}>
<button type="button" className="records-header-button" onClick={openProp(AI_LABEL)}>
<span className="records-header-icon"><Icon size={15}>{TYPE_GLYPHS.Text}</Icon></span>
<span className="truncate">{AI_LABEL}</span>
</button>
<span role="separator" aria-orientation="vertical" aria-label={`Resize ${AI_LABEL} column`} className={`records-resize-handle ${resizingColumn === "ai" ? "is-resizing" : ""}`} onPointerDown={startColumnResize("ai")} />
</th>
)}
<th className="records-header-cell">
<div className="flex h-[35px] items-center gap-1 px-2">
<button
type="button"
aria-label="New property"
data-recpop
onClick={(event) => {
setProp(null);
setTableMenuOpen(null);
const rect = (event.currentTarget as Element).getBoundingClientRect();
setAddOpen((current) => (current ? null : { x: Math.min(rect.left, window.innerWidth - 276), y: rect.bottom + 6 }));
}}
className="flex size-7 items-center justify-center rounded-[7px] text-ink-2 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<Icon size={15} strokeWidth={2}><path d="M12 5v14M5 12h14" /></Icon>
</button>
<button
type="button"
aria-label="Table options"
aria-expanded={!!tableMenuOpen}
data-recpop
onClick={(event) => {
setProp(null);
setAddOpen(null);
const rect = event.currentTarget.getBoundingClientRect();
setTableMenuOpen((current) => current ? null : {
x: Math.max(8, Math.min(rect.right - 220, window.innerWidth - 228)),
y: rect.bottom + 6,
});
}}
className="flex size-7 items-center justify-center rounded-[7px] text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></svg>
</button>
</div>
</th>
</tr>
</thead>
{/* data cells stay silent — the papery link/flick sound is too much when scanning rows */}
<tbody data-sound-silent>
{visibleRows.map((row, index) => {
const selectedRow = selected.has(row.id);
const strength = STRENGTH[row.strength];
return <tr key={row.id} className={`records-row ${selectedRow ? "is-selected" : ""}`}>
<td className={`records-cell records-sticky-cell records-company-cell ${prop?.col === "Company" ? "is-colsel" : ""}`}><span className="records-rownum">{index + 1}</span><Checkbox checked={selectedRow} onChange={() => toggleRow(row.id)} label={`Select ${row.name}`} /><span className="records-company-mark">{row.name.slice(0, 1).toUpperCase()}</span><a href={row.website ? `https://${row.website}` : "#"} onClick={(event) => !row.website && event.preventDefault()} title={row.name} className={`records-company-name ${row.website ? "has-link" : ""}`}>{row.name}</a></td>
<td className={`records-cell ${prop?.col === "Categories" ? "is-colsel" : ""}`}>{isCalc("Categories", index) ? <CalcCell /> : <TagList tags={row.tags} />}</td>
<td className={`records-cell ${row.last === "No contact" ? "records-muted" : ""} ${prop?.col === "Last interaction" ? "is-colsel" : ""}`}>{isCalc("Last interaction", index) ? <CalcCell /> : row.last}</td>
<td className={`records-cell ${prop?.col === "Connection strength" ? "is-colsel" : ""}`}>{isCalc("Connection strength", index) ? <CalcCell /> : <span className="records-strength"><span className="records-strength-dot" style={{ background: strength.color }} />{strength.label}</span>}</td>
<td className={`records-cell ${prop?.col === "Links" ? "is-colsel" : ""}`}>{isCalc("Links", index) ? <CalcCell /> : row.website ? <a className="records-link" href={`https://${row.website}`} title={row.website} target="_blank" rel="noreferrer"><span className="records-link-label">{row.website}</span><Icon size={12}><path d="M14 5h5v5M19 5l-8 8" /></Icon></a> : <span className="records-muted">—</span>}</td>
{aiAdded && (
<td className={`records-cell ${prop?.col === AI_LABEL ? "is-colsel" : ""}`}>
{calc?.col === AI_LABEL ? (index < calc.resolved ? competitorsFor(index) : <CalcCell />) : aiDone ? competitorsFor(index) : <span className="records-muted">—</span>}
</td>
)}
<td className="records-cell" />
</tr>;
})}
</tbody>
<tfoot>
<tr className="records-calculation-row">
<td className="records-cell records-sticky-cell">
<span className="records-footer-value records-calculation-label"><span className="records-calculation-number">{rows.length}</span> count</span>
</td>
<td className="records-cell">
<button type="button" className="records-add-calculation"><Icon size={15}><path d="M12 5v14M5 12h14" /></Icon>Add calculation</button>
</td>
<td className="records-cell records-muted"><span className="records-footer-value">—</span></td>
<td className="records-cell">
<span className="records-footer-value records-average"><span className="records-strength-dot" style={{ background: "var(--orange)" }} />{Math.round(rows.reduce((sum, row) => sum + STRENGTH[row.strength].rank, 0) / rows.length / 3 * 100)}% average</span>
</td>
<td className="records-cell"><span className="records-footer-value records-muted">{rows.filter((row) => row.website).length} links</span></td>
{aiAdded && <td className="records-cell records-muted"><span className="records-footer-value">{aiDone ? `${rows.length} filled` : "—"}</span></td>}
<td className="records-cell" />
</tr>
</tfoot>
</table>
</div>
{/* ── property configuration popover ─────────────────── */}
{prop && meta && (
<div
data-recpop
className="fixed z-50 w-[320px] rounded-[14px] bg-card px-3 pt-3 pb-1.5 shadow-overlay"
style={{ top: prop.y, left: prop.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
>
<div className="pb-2 text-[13.5px] font-medium text-ink">{prop.col}</div>
<ConfigRow label="Type">
<button
type="button"
aria-haspopup="menu"
aria-expanded={configMenu === "type"}
onClick={() => setConfigMenu((current) => current === "type" ? null : "type")}
className="flex items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] font-medium text-ink transition-colors duration-100 hover:bg-hover"
>
<span className="text-ink-2"><Icon size={14}>{TYPE_GLYPHS[meta.type] ?? TYPE_GLYPHS.Text}</Icon></span>
{meta.type}
<span className="text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
</button>
{configMenu === "type" && (
<ConfigPicker
label="Property type"
selected={meta.type}
options={NEW_PROPERTY_TYPES.map((type) => ({ label: type, icon: <Icon size={15}>{TYPE_GLYPHS[type]}</Icon> }))}
onSelect={(type) => {
setColumnOverrides((current) => ({ ...current, [prop.col]: { ...current[prop.col], type } }));
setConfigMenu(null);
}}
/>
)}
</ConfigRow>
<ConfigRow label="Tool">
<button
type="button"
aria-haspopup="menu"
aria-expanded={configMenu === "tool"}
onClick={() => setConfigMenu((current) => current === "tool" ? null : "tool")}
className="flex items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] font-medium text-ink transition-colors duration-100 hover:bg-hover"
>
<span className={meta.toolKind === "model" ? "text-highlight" : "text-ink-2"}>
{meta.toolKind === "model"
? <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>{TOOL_GLYPHS.model}</svg>
: <Icon size={14}>{TOOL_GLYPHS[meta.toolKind]}</Icon>}
</span>
{meta.tool}
<span className="text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
</button>
{configMenu === "tool" && (
<ConfigPicker
label="Model"
selected={meta.tool}
options={MODEL_OPTIONS.map((model) => ({
label: model,
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>{TOOL_GLYPHS.model}</svg>,
}))}
onSelect={(tool) => {
setColumnOverrides((current) => ({ ...current, [prop.col]: { ...current[prop.col], tool, toolKind: "model" } }));
setConfigMenu(null);
}}
/>
)}
</ConfigRow>
<ConfigRow label="Grounding">
<span className="flex items-center gap-2">
<MiniSwitch label="Grounding" on={grounding} onToggle={() => setGrounding((current) => !current)} />
<button
type="button"
aria-label="About grounding"
aria-expanded={groundingHelpOpen}
onClick={() => setGroundingHelpOpen((open) => !open)}
className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
<Icon size={13}><g><circle cx="12" cy="12" r="9" /><path d="M12 8h.01M11 12h1v4h1" /></g></Icon>
</button>
</span>
{groundingHelpOpen && (
<div className="absolute right-0 top-[30px] z-30 w-[230px] rounded-[10px] px-3 py-2.5 text-[12px] leading-relaxed shadow-overlay" style={{ color: "var(--tooltip-fg)", background: "var(--tooltip-bg)" }} role="status">
Grounding lets the model verify generated values against connected sources.
</div>
)}
</ConfigRow>
<ConfigRow label="Inputs">
<button
type="button"
aria-haspopup="menu"
aria-expanded={configMenu === "inputs"}
onClick={() => setConfigMenu((current) => current === "inputs" ? null : "inputs")}
className="flex max-w-[220px] items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] text-ink-2 transition-colors duration-100 hover:bg-hover hover:text-ink"
>
{selectedInputs.length ? (
<span className="flex min-w-0 items-center gap-1">
{selectedInputs.slice(0, 2).map((input) => (
<span key={input} className="max-w-[92px] truncate rounded-[5px] bg-highlight-tint px-1.5 py-0.5 text-[12px] font-medium text-highlight-ink">{input}</span>
))}
{selectedInputs.length > 2 && <span className="text-[11px] font-medium text-ink-3">+{selectedInputs.length - 2}</span>}
</span>
) : (
<span>Select inputs</span>
)}
<span className="shrink-0 text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
</button>
{configMenu === "inputs" && (
<InputPicker
selected={selectedInputs}
options={INPUT_OPTIONS.filter((input) => input !== prop.col)}
onToggle={(input) => {
setInputSelections((current) => {
const existing = current[prop.col] ?? (meta.inputs ? [meta.inputs] : []);
const next = existing.includes(input) ? existing.filter((item) => item !== input) : [...existing, input];
return { ...current, [prop.col]: next };
});
}}
/>
)}
</ConfigRow>
{/* prompt — @-mention chips inline */}
<div
contentEditable
suppressContentEditableWarning
role="textbox"
aria-label={`${prop.col} calculation prompt`}
aria-multiline="true"
spellCheck
className="mt-2 min-h-[88px] cursor-text rounded-[10px] bg-inset p-3 text-[13px] leading-relaxed shadow-hairline outline-none transition-[box-shadow] duration-150 focus:shadow-[0_0_0_2px_var(--highlight)]"
>
{meta.prompt ? (
<span className="text-ink">
{meta.prompt.before}
{meta.prompt.chip && <span contentEditable={false} className="rounded-[5px] bg-highlight-tint px-1.5 py-0.5 text-[12px] font-medium text-highlight-ink">{meta.prompt.chip}</span>}
{meta.prompt.after}
</span>
) : (
<span className="text-ink-3">Set a prompt (press @ to mention an input)</span>
)}
</div>
<button
type="button"
disabled={!!calc}
onClick={() => {
setCalc({ col: prop.col, resolved: 0 });
setProp(null);
}}
className="mt-2.5 flex h-9 w-full items-center justify-center gap-2 rounded-[9px] text-[12.5px] font-medium text-ink shadow-btn transition-[background-color,transform] duration-150 hover:bg-hover active:scale-[0.98] disabled:opacity-60"
>
<Icon size={14} strokeWidth={1.9}><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" /></Icon>
Go calculate
</button>
<GlideMenu className="mt-3 flex flex-col gap-0.5 border-t border-line pt-2" highlightClassName="-inset-x-1.5 rounded-[8px] bg-hover">
<button
data-menu-row
type="button"
aria-pressed={pinnedColumns.has(prop.col)}
onClick={() => setPinnedColumns((current) => {
const next = new Set(current);
if (next.has(prop.col)) next.delete(prop.col);
else next.add(prop.col);
return next;
})}
className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
>
<span className={pinnedColumns.has(prop.col) ? "text-highlight" : "text-ink-2"}><Icon size={15}><path d="M12 17v5M8 3h8l-1 7 3 3H6l3-3-1-7z" /></Icon></span>
{pinnedColumns.has(prop.col) ? "Unpin" : "Pin"}
</button>
<button
data-menu-row
type="button"
aria-expanded={moreSettingsOpen}
onClick={() => setMoreSettingsOpen((open) => !open)}
className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
>
<span className={moreSettingsOpen ? "text-ink" : "text-ink-2"}><Icon size={15}><g><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1" /></g></Icon></span>
<span className="flex-1">More settings</span>
<span className={`text-ink-3 transition-transform duration-150 ${moreSettingsOpen ? "rotate-90" : ""}`}><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
</button>
{prop.col === AI_LABEL && (
<button
data-menu-row
type="button"
onClick={() => {
setAiAdded(false);
setAiDone(false);
setProp(null);
}}
className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
>
<span className="text-ink-2"><Icon size={15}><g><path d="M10.6 5.1A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a16.3 16.3 0 0 1-2.1 3M6.6 6.6A16 16 0 0 0 2 12s3 7 10 7a9.7 9.7 0 0 0 5.4-1.6M3 3l18 18" /><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" /></g></Icon></span>
Hide from view
</button>
)}
</GlideMenu>
{moreSettingsOpen && (
<div className="mt-2 border-t border-line pt-2" style={{ animation: "fade-up 160ms cubic-bezier(0.23,1,0.32,1) both" }}>
<div className="pb-1 text-[11.5px] font-medium text-ink-3">Behavior</div>
<ConfigRow label="Required value">
<MiniSwitch label="Required value" on={advancedSettings.required} onToggle={() => setAdvancedSettings((current) => ({ ...current, required: !current.required }))} />
</ConfigRow>
<ConfigRow label="Allow empty results">
<MiniSwitch label="Allow empty results" on={advancedSettings.allowEmpty} onToggle={() => setAdvancedSettings((current) => ({ ...current, allowEmpty: !current.allowEmpty }))} />
</ConfigRow>
<ConfigRow label="Show confidence">
<MiniSwitch label="Show confidence" on={advancedSettings.confidence} onToggle={() => setAdvancedSettings((current) => ({ ...current, confidence: !current.confidence }))} />
</ConfigRow>
</div>
)}
</div>
)}
{/* ── new property type menu ─────────────────────────── */}
{addOpen && (
<div
data-recpop
className="fixed z-50 w-[260px] rounded-[14px] bg-card p-1.5 shadow-overlay"
style={{ top: addOpen.y, left: addOpen.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
>
<div className="px-2 pb-1 pt-1 text-[12px] font-medium text-ink-3">New property</div>
<GlideMenu className="flex flex-col gap-px">
{NEW_PROPERTY_TYPES.map((type) => (
<button
key={type}
data-menu-row
type="button"
onClick={() => {
setAddOpen(null);
setAiDone(false);
setAiAdded(true);
setPendingOpenAi(true);
}}
className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
>
<span className="text-ink-2"><Icon size={15}>{TYPE_GLYPHS[type]}</Icon></span>
{type}
</button>
))}
</GlideMenu>
</div>
)}
{/* ── table options menu ─────────────────────────────── */}
{tableMenuOpen && (
<div
data-recpop
className="fixed z-50 w-[220px] rounded-[14px] bg-card p-1.5 shadow-overlay"
style={{ top: tableMenuOpen.y, left: tableMenuOpen.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top right" }}
>
<div className="px-2 pb-1 pt-1 text-[12px] font-medium text-ink-3">Table options</div>
<GlideMenu className="flex flex-col gap-px">
<button
data-menu-row
type="button"
onClick={() => {
const position = tableMenuOpen;
setTableMenuOpen(null);
setAddOpen({ x: Math.min(position.x, window.innerWidth - 276), y: position.y });
}}
className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
>
<span className="text-ink-2"><Icon size={15} strokeWidth={2}><path d="M12 5v14M5 12h14" /></Icon></span>
Add property
</button>
<button
data-menu-row
type="button"
onClick={() => {
setColumnWidths({ company: 220, categories: 220, last: 155, strength: 180, links: 160, ai: 200 });
setTableMenuOpen(null);
}}
className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
>
<span className="text-ink-2"><Icon size={15}><path d="M4 8h16M7 4 3 8l4 4M17 4l4 4-4 4M4 16h16" /></Icon></span>
Compact columns
</button>
<button
data-menu-row
type="button"
onClick={() => {
setColumnWidths({ ...(initialColumnWidthsRef.current ?? DEFAULT_COLUMN_WIDTHS) });
setTableMenuOpen(null);
}}
className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
>
<span className="text-ink-2"><Icon size={15}><path d="M3 12a9 9 0 1 0 3-6.7M3 4v6h6" /></Icon></span>
Reset column widths
</button>
<div className="my-1 h-px bg-line" />
<button
data-menu-row
type="button"
onClick={() => {
setSelected(new Set());
setTableMenuOpen(null);
}}
className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
>
<span className="text-ink-2"><Icon size={15}><path d="M5 5l14 14M19 5 5 19" /></Icon></span>
Clear selection
</button>
</GlideMenu>
</div>
)}
</div>
);
}/*
* MIT License
*
* Copyright (c) 2026 Shane Levine
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/* ══════════════════════════════════════════════════════════
* COMPONENT-SPECIFIC — RecordsTable
* The full CRM grid, spreadsheet gutter, and AI column (~800 lines).
* Copy this block only alongside RecordsTable; it is not part of the
* generic foundation above.
* ══════════════════════════════════════════════════════════ */
.records-shell {
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
box-shadow: 0 1px 2px oklch(0 0 0 / 0.06);
}
/* fill mode: the shell stretches to fill its container and drops its own
* border/radius/shadow so it reads as part of the surrounding card rather
* than a container-in-a-container. The grid body flexes to fill height. */
.records-shell.is-fill {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.records-shell.is-fill .records-scroll {
max-height: none;
flex: 1;
}
.records-toolbar {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid var(--line-strong);
background: var(--card);
}
.records-toolbar-left,
.records-toolbar-right,
.records-company-header,
.records-quiet-button,
.records-secondary-button,
.records-primary-button,
.records-select-button,
.records-strength,
.records-link,
.records-add-calculation {
display: inline-flex;
align-items: center;
}
.records-toolbar-left,
.records-toolbar-right {
gap: 4px;
}
.records-toolbar-right {
flex-shrink: 0;
}
.records-select-button,
.records-quiet-button,
.records-secondary-button,
.records-primary-button {
height: 32px;
gap: 7px;
border-radius: var(--radius-control);
padding: 0 10px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
transition: background-color 140ms var(--ease-out-strong), color 140ms var(--ease-out-strong), transform 140ms var(--ease-out-strong), box-shadow 140ms var(--ease-out-strong);
}
.records-select-button {
gap: 8px;
color: var(--ink);
border: 1px solid var(--line);
background: var(--card);
}
.records-select-button:hover,
.records-quiet-button:hover,
.records-secondary-button:hover {
background: var(--hover);
color: var(--ink);
}
.records-quiet-button {
color: var(--ink-2);
}
.records-quiet-button.is-active {
background: var(--highlight-tint);
color: var(--highlight-ink);
}
.records-secondary-button {
border: 1px solid var(--tooltip-border);
color: var(--ink-2);
box-shadow: none;
}
.records-primary-button {
margin-left: 3px;
background: var(--highlight);
color: oklch(1 0 0);
box-shadow: none;
}
.records-primary-button:hover {
filter: saturate(0.9) brightness(1.04);
}
.records-quiet-button:active,
.records-secondary-button:active,
.records-primary-button:active,
.records-select-button:active,
.records-add-calculation:active {
transform: scale(0.96);
}
.records-database-icon {
display: inline-flex;
color: var(--green);
}
.records-filter-wrap {
position: relative;
}
.records-filter-dot {
width: 5px;
height: 5px;
margin-left: -2px;
border-radius: 99px;
background: var(--highlight);
}
.records-filter-menu {
position: absolute;
z-index: 30;
top: calc(100% + 7px);
left: 0;
width: 190px;
padding: 6px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--card);
box-shadow: var(--shadow-overlay);
animation: pop-in 160ms var(--ease-out-strong) both;
transform-origin: top left;
}
.records-sort-menu {
left: -48px;
}
.records-filter-label {
padding: 5px 8px 6px;
color: var(--ink-3);
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.records-filter-menu button {
display: flex;
width: 100%;
align-items: center;
gap: 7px;
border-radius: var(--radius-chip);
padding: 7px 8px;
color: var(--ink-2);
font-size: 12px;
text-align: left;
transition: background-color 120ms ease-out, color 120ms ease-out;
}
.records-filter-menu button:hover,
.records-filter-menu button.is-selected {
background: var(--hover);
color: var(--ink);
}
.records-menu-check {
width: 13px;
color: var(--highlight-ink);
font-size: 12px;
font-weight: 500;
text-align: center;
}
.records-scroll {
max-height: 438px;
overflow: auto;
overscroll-behavior: none;
scrollbar-color: var(--line-strong) transparent;
scrollbar-gutter: stable;
}
.records-scroll:focus-visible {
outline: 2px solid var(--highlight);
outline-offset: -2px;
}
.records-table {
width: 100%;
min-width: 990px;
border-collapse: separate;
border-spacing: 0;
color: var(--ink);
font-size: 13px;
font-weight: 500;
table-layout: fixed;
}
.records-company-col { width: 270px; }
.records-category-col { width: 275px; }
.records-last-col { width: 190px; }
.records-strength-col { width: 210px; }
.records-link-col { width: 175px; }
.records-table th,
.records-table td {
border-right: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
text-align: left;
vertical-align: middle;
}
.records-table tr > :last-child {
border-right: 0;
}
.records-table thead th {
position: sticky;
z-index: 5;
top: 0;
height: 35px;
background: var(--card);
color: var(--ink-2);
font-size: 12.5px;
font-weight: 500;
}
.records-header-cell {
padding: 0;
}
.records-resize-handle {
position: absolute;
z-index: 12;
top: 0;
right: -6px;
width: 12px;
height: 100%;
cursor: col-resize;
touch-action: none;
}
.records-resize-handle::after {
position: absolute;
top: 7px;
bottom: 7px;
left: 5px;
width: 2px;
border-radius: 99px;
background: var(--highlight);
content: "";
opacity: 0;
transform: scaleY(0.65);
transition: opacity 120ms ease-out, transform 150ms var(--ease-out-strong);
}
.records-resize-handle:hover::after,
.records-resize-handle.is-resizing::after {
opacity: 1;
transform: scaleY(1);
}
.records-header-cell.records-sticky-cell {
z-index: 7;
}
.records-header-button,
.records-company-header {
width: 100%;
height: 35px;
gap: 8px;
padding: 0 12px;
}
.records-company-header {
padding-left: 6px;
}
.records-header-button {
display: flex;
align-items: center;
color: var(--ink-2);
text-align: left;
transition: background-color 120ms ease-out, color 120ms ease-out;
}
.records-header-button:hover {
background: var(--hover);
color: var(--ink);
}
.records-header-icon {
display: inline-flex;
flex-shrink: 0;
color: var(--ink-3);
}
.records-sort {
display: inline-flex;
flex-shrink: 0;
margin-left: auto;
opacity: 0;
transition: opacity 120ms ease-out, transform 160ms var(--ease-out-strong);
}
.records-header-button:hover .records-sort,
.records-sort.is-visible {
opacity: 1;
}
.records-company-header {
display: flex;
}
.records-company-header > span:not(.records-checkbox-box) {
white-space: nowrap;
}
.records-add-field {
display: inline-flex;
margin-left: auto;
color: var(--ink-3);
opacity: 0;
transition: opacity 120ms ease-out, color 120ms ease-out;
}
.records-company-header:hover .records-add-field,
.records-add-field:focus-visible {
opacity: 1;
}
.records-add-field:hover {
color: var(--ink);
}
.records-checkbox {
position: relative;
display: inline-flex;
width: 24px;
height: 24px;
flex: 0 0 24px;
align-items: center;
justify-content: center;
border-radius: var(--radius-chip);
}
.records-checkbox input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.records-checkbox-box {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border: 1px solid oklch(0.845 0.011 247.953);
border-radius: 6px;
color: oklch(0.446 0.018 251.343);
background: oklch(1 0 0);
transition: border-color 140ms ease-out, background-color 140ms ease-out, box-shadow 140ms ease-out, transform 140ms var(--ease-out-strong);
}
.records-checkbox:hover .records-checkbox-box {
border-color: oklch(0.773 0.016 251.194);
background: oklch(0.966 0.003 228.784);
}
.records-checkbox:active .records-checkbox-box {
transform: scale(0.96);
}
.records-checkbox input:focus-visible + .records-checkbox-box {
outline: 2px solid var(--highlight);
outline-offset: 2px;
}
.records-checkbox-box.is-active {
border-color: var(--highlight);
color: oklch(1 0 0);
background: var(--highlight);
box-shadow: none;
}
.records-checkbox-dash {
width: 8px;
height: 1.5px;
border-radius: 99px;
background: oklch(1 0 0);
}
:is(.dark, [data-theme="dark"]) .records-checkbox-box {
border-color: oklch(0.45 0.017 254.711);
color: oklch(0.917 0.007 247.901);
background: oklch(0.346 0.015 252.294);
}
:is(.dark, [data-theme="dark"]) .records-checkbox:hover .records-checkbox-box {
border-color: oklch(0.521 0.019 254);
background: oklch(0.391 0.016 251.761);
}
:is(.dark, [data-theme="dark"]) .records-checkbox-box.is-active {
border-color: var(--highlight);
color: oklch(1 0 0);
background: var(--highlight);
}
:is(.dark, [data-theme="dark"]) .records-checkbox-dash {
background: oklch(1 0 0);
}
.records-cell {
height: 35px;
padding: 0 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.records-sticky-cell {
position: sticky;
left: 0;
z-index: 2;
background: var(--card);
box-shadow: 5px 0 8px -10px oklch(0 0 0 / 0.4);
}
.records-company-cell {
display: flex;
align-items: center;
gap: 4px;
padding-left: 6px;
overflow: visible;
}
.records-row > .records-cell {
transition: background-color 120ms ease-out, color 120ms ease-out;
}
.records-row:hover > .records-cell {
background: var(--hover);
}
.records-row.is-selected > .records-cell {
background: color-mix(in srgb, var(--highlight) 7%, var(--card));
}
/* ── spreadsheet gutter ──────────────────────────────────
* Row numbers at rest; the checkbox takes their place on
* hover or once the row is selected. */
.records-rownum {
display: inline-flex;
width: 24px;
height: 24px;
flex: 0 0 24px;
align-items: center;
justify-content: center;
color: var(--ink-3);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.records-row .records-checkbox {
display: none;
}
.records-row:hover .records-rownum,
.records-row.is-selected .records-rownum {
display: none;
}
.records-row:hover .records-checkbox,
.records-row.is-selected .records-checkbox {
display: inline-flex;
}
/* ── selected column — the property being configured ───── */
.records-table th.is-colsel {
background: color-mix(in srgb, var(--highlight) 8%, var(--card));
box-shadow: inset 0 2px 0 var(--highlight);
}
th.is-colsel .records-header-button,
th.is-colsel .records-company-header,
th.is-colsel .records-header-icon {
color: var(--highlight-ink);
}
.records-table td.is-colsel {
background: color-mix(in srgb, var(--highlight) 4%, var(--card));
}
.records-row:hover > td.is-colsel {
background: color-mix(in srgb, var(--highlight) 8%, var(--card));
}
.records-row.is-selected > td.is-colsel {
background: color-mix(in srgb, var(--highlight) 10%, var(--card));
}
/* ── AI column calculating state ─────────────────────────── */
.records-calc {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.records-pulse {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
background: var(--highlight);
animation: records-pulse 1.1s ease-in-out infinite;
}
@keyframes records-pulse {
0%,
100% {
opacity: 0.35;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* agent-screen — a static decorative cursor sitting on the capture (for show) */
.agent-cursor {
position: absolute;
pointer-events: none;
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.35));
}
.records-company-mark {
display: inline-flex;
width: 20px;
height: 20px;
flex: 0 0 20px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
color: var(--ink-2);
background: var(--field);
font-size: 10px;
font-weight: 500;
}
.records-company-name {
min-width: 0;
overflow: hidden;
color: var(--ink);
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-company-name.has-link:hover,
.records-company-name.has-link:focus-visible {
color: var(--highlight-ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.records-tags {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 4px;
overflow: hidden;
}
.records-tags-measure {
position: absolute;
top: 0;
left: 0;
display: flex;
width: max-content;
gap: 4px;
pointer-events: none;
visibility: hidden;
}
.records-tags-measure > span {
display: inline-flex;
}
.records-tag {
display: inline-flex;
height: 23px;
max-width: 115px;
align-items: center;
flex-shrink: 0;
overflow: hidden;
cursor: pointer;
border: 1px solid color-mix(in srgb, var(--tag-base) 32%, var(--card));
border-radius: 8px;
padding: 0 7px;
color: color-mix(in srgb, var(--tag-base) 92%, var(--ink));
background: color-mix(in srgb, var(--tag-base) 18%, var(--card));
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-more-tag {
display: inline-flex;
height: 23px;
align-items: center;
flex-shrink: 0;
border: 1px solid var(--line-strong);
border-radius: 6px;
padding: 0 7px;
color: var(--ink-3);
background: var(--inset);
font-size: 11px;
font-weight: 500;
}
.records-more-tag {
border-color: var(--line-strong);
color: var(--ink-3);
background: var(--inset);
}
.records-muted {
color: var(--ink-3);
}
.records-strength {
gap: 8px;
color: var(--ink-2);
}
.records-strength-dot {
display: inline-block;
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
}
.records-link {
max-width: 100%;
gap: 5px;
overflow: hidden;
color: var(--highlight-ink);
text-overflow: ellipsis;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 3px;
transition: color 120ms ease-out, text-decoration-color 120ms ease-out;
}
.records-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.records-link svg {
flex-shrink: 0;
}
.records-link:hover,
.records-link:focus-visible {
color: var(--ink);
text-decoration-color: currentColor;
}
.records-table tfoot td {
position: sticky;
z-index: 4;
bottom: 0;
height: 35px;
background: var(--inset);
color: var(--ink-2);
font-size: 14px;
font-weight: 500;
line-height: 1;
}
.records-table tfoot .records-sticky-cell {
z-index: 6;
background: var(--inset);
}
.records-calculation-label {
color: var(--ink-2);
font-weight: 500;
}
.records-footer-value {
display: inline-flex;
height: 35px;
align-items: center;
vertical-align: middle;
}
.records-calculation-number {
margin-right: 3px;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.records-add-calculation {
height: 35px;
gap: 6px;
color: var(--ink-3);
font-size: 14px;
font-weight: 500;
line-height: 1;
transition: color 120ms ease-out, transform 140ms var(--ease-out-strong);
}
.records-add-calculation:hover {
color: var(--ink);
}
.records-average {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--ink-2);
}
.records-calculation-row-secondary td {
position: sticky;
bottom: 35px;
background: color-mix(in srgb, var(--inset) 70%, var(--card));
}
.records-calculation-row-secondary .records-sticky-cell {
position: sticky;
background: color-mix(in srgb, var(--inset) 70%, var(--card));
}
.records-footer {
display: flex;
min-height: 35px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px;
color: var(--ink-3);
font-size: 11.5px;
}
.records-footer strong {
color: var(--ink-2);
font-weight: 500;
}
.records-footer-hint {
display: inline-flex;
align-items: center;
gap: 5px;
opacity: 0;
transition: opacity 160ms ease-out;
}
.records-shell:hover .records-footer-hint {
opacity: 1;
}
.records-scroll-cue {
display: inline-flex;
color: var(--ink-3);
}
@media (max-width: 640px) {
.records-toolbar {
align-items: flex-start;
flex-direction: column;
}
.records-toolbar-left,
.records-toolbar-right {
width: 100%;
}
.records-toolbar-right {
justify-content: flex-end;
}
}Installation
pnpm dlx shadcn@latest add @ward/records-tablenpx shadcn@latest add @ward/records-tableyarn dlx shadcn@latest add @ward/records-tablebunx --bun shadcn@latest add @ward/records-tableAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/records-table.json
Add the Ward items it builds on.
npx shadcn@latest add @ward/foundation @ward/glide-menuCopy each file from the Code tab into:
components/ward/RecordsTable.tsxcomponents/ward/styles/records-table.css
Update the import paths to match your project.
Usage
import RecordsTable from "@/components/ward/RecordsTable";<RecordsTable
fill
rows={[
{ id: "aurora", name: "Aurora Works — Reykjavík", tags: ["Brand", "Seasonal"], last: "9 days ago", strength: "strong", website: "aurora-works.example.com" },
{ id: "kumo", name: "Kumo Studio — Tokyo", tags: ["B2C", "Cafe"], last: "3 weeks ago", strength: "weak" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | string | "Default" | Accepted for registry parity; the component has a single variant. |
rows | RecordRow[] | INITIAL_ROWS | Records shown in the grid, each with a name, tags, last contact, connection strength and optional website. |
fill | boolean | false | Makes the table stretch to fill the height of its parent instead of using a capped scroll height. |
Ward · Version 1.1.0 · Registry manifest