diff --git a/apps/web/src/page/settings/audit-log/icons.ts b/apps/web/src/page/settings/audit-log/icons.ts new file mode 100644 index 0000000000..40859201de --- /dev/null +++ b/apps/web/src/page/settings/audit-log/icons.ts @@ -0,0 +1,115 @@ +import type { AuditResources, AuditResourceType } from "@maple/domain/audit-actions" +import type { AuditActorType, AuditLogSource, AuditOutcome } from "@maple/domain/http" +import type { IconName } from "@maple/views/ui/icon" +import { Array as Arr, Record as Rec, String as Str } from "effect" + +// The audit log's glyphs: one per actor type, surface, outcome and kind of action, so a filter and +// the rows it lists carry the same picture. + +export const ACTOR_ICONS = { + user: "UserIcon", + api_key: "KeyIcon", + agent: "FaceRobotIcon", + system: "GearIcon", +} as const satisfies { readonly [Actor in AuditActorType]: IconName } + +export const SOURCE_ICONS = { + dashboard: "MonitorIcon", + api: "BracketsCurlyIcon", + mcp: "PlugIcon", + chat_platform: "ChatBubbleIcon", + system: "GearIcon", +} as const satisfies { readonly [Source in AuditLogSource]: IconName } + +export const OUTCOME_ICONS = { + allowed: "CircleCheckIcon", + denied: "CircleXmarkIcon", +} as const satisfies { readonly [Outcome in AuditOutcome]: IconName } + +type AuditVerb = (typeof AuditResources)[AuditResourceType]["verbs"][number] + +/** A glyph for every declared verb: a new verb fails the build until it has one. */ +const VERB_ICONS = { + created: "PlusIcon", + added: "PlusIcon", + registered: "PlusIcon", + submitted: "PlusIcon", + minted: "PlusIcon", + claimed: "PlusIcon", + install_started: "PlusIcon", + connect_started: "PlusIcon", + identity_link_started: "LinkIcon", + updated: "PencilIcon", + settings_updated: "PencilIcon", + zones_updated: "PencilIcon", + role_changed: "ShieldIcon", + status_changed: "PencilIcon", + catalog_curated: "PencilIcon", + components_reordered: "PencilIcon", + config_applied: "PencilIcon", + schema_applied: "PencilIcon", + organization_selected: "PencilIcon", + metrics_token_set: "KeyIcon", + region_chosen: "GlobeIcon", + triaged: "PencilIcon", + deleted: "TrashIcon", + removed: "TrashIcon", + removal_confirmed: "TrashIcon", + uninstalled: "TrashIcon", + disconnected: "TrashIcon", + identity_unlinked: "TrashIcon", + revoked: "LockIcon", + break_glass_revoked: "LockIcon", + cancelled: "SquareIcon", + ended: "SquareIcon", + rolled: "ArrowRotateClockwiseIcon", + rotated: "ArrowRotateClockwiseIcon", + break_glass_rotated: "ArrowRotateClockwiseIcon", + restarted: "ArrowRotateClockwiseIcon", + version_restored: "HistoryIcon", + catalog_refreshed: "ArrowRotateClockwiseIcon", + read: "EyeIcon", + called: "SquareTerminalIcon", + sql_executed: "DatabaseIcon", + request: "BracketsCurlyIcon", + tested: "MediaPlayIcon", + paused: "MediaPauseIcon", + resumed: "MediaPlayIcon", + published: "PaperPlaneIcon", + update_posted: "PaperPlaneIcon", + invite_sent: "PaperPlaneIcon", + resolved: "CheckIcon", + regression: "CircleWarningIcon", + state_change: "PencilIcon", + severity_change: "PencilIcon", + anomaly_linked: "LinkIcon", + lease_expired: "ClockIcon", + verification_started: "MediaPlayIcon", + verification_verdict: "ShieldCheckIcon", + comment: "ChatBubbleIcon", + agent_note: "ChatBubbleIcon", + ai_triage: "SparkleIcon", + fix_proposed: "SparkleIcon", + snooze: "BellSlashIcon", + unsnooze: "BellIcon", + assignment: "UserIcon", + claim: "UserIcon", + release: "UserIcon", + pr_linked: "PullRequestIcon", + pr_unlinked: "PullRequestIcon", + pr_merged: "PullRequestIcon", + merge: "BranchForkIcon", + unmerge: "BranchForkIcon", +} as const satisfies { readonly [Verb in AuditVerb]: IconName } + +const hasVerbIcon = (verb: string): verb is keyof typeof VERB_ICONS => + Rec.has(VERB_ICONS, verb) + +/** The glyph for "an action", and for one this build does not know. */ +export const ACTION_ICON = "BoltIcon" satisfies IconName + +/** The glyph for a `.` action, by its verb. */ +export const auditActionIcon = (action: string): IconName => { + const verb = Arr.lastNonEmpty(Str.split(".")(action)) + return hasVerbIcon(verb) ? VERB_ICONS[verb] : ACTION_ICON +} diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 01b479c786..5490506e8d 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -17,7 +17,9 @@ import { inlineCode } from "@maple/views/ui/inline-code" import { keyValue, keyValueList } from "@maple/views/ui/key-value" import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" import { Message, type Model } from "./model" -import { ACTOR_BADGES, SOURCE_LABELS } from "./view" +import { OUTCOME_ICONS, SOURCE_ICONS } from "./icons" +import { actorBadge, deniedBadge, SOURCE_LABELS } from "./view" +import { icon } from "@maple/views/ui/icon" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" @@ -164,6 +166,7 @@ const pivot = (h: H, key: string, value: string, what: string): Html => attributeFilterAction(h, { label: `Show every entry with this ${what}`, text: "Filter", + icon: "FilterIcon", onClick: Message.ClickedPivot({ key, value }), }) @@ -241,10 +244,16 @@ const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArra h.section( [h.Class("space-y-1.5")], [ - eyebrow(h, { variant: "mono", as: "h4" }, ["Origin"]), + eyebrow(h, { variant: "mono", as: "h4", icon: "GlobeIcon" }, ["Origin"]), keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ keyValue(h, { wrap: true, label: "Surface" }, [ - h.span([], [SOURCE_LABELS[entry.source]]), + valueRow(h, [ + icon(h, SOURCE_ICONS[entry.source], { + size: 13, + className: "text-muted-foreground shrink-0", + }), + h.span([], [SOURCE_LABELS[entry.source]]), + ]), ]), ...(entry.origin_ip === null ? [] @@ -293,7 +302,6 @@ export const auditDetail = ( timeZone: string, nowMs: number, ): Html => { - const actor = ACTOR_BADGES[entry.actor_type] const full = (value: string) => timestamp(h, { value, timeZone, nowMs, withYear: true, className: "tabular-nums" }) const actionText = auditActionLabel(entry.action) @@ -309,7 +317,7 @@ export const auditDetail = ( keyValue(h, { wrap: true, label: "Recorded" }, [full(entry.recorded_at)]), keyValue(h, { wrap: true, label: "Actor" }, [ valueRow(h, [ - badge(h, { variant: actor.variant, size: "sm" }, [actor.label]), + actorBadge(h, entry.actor_type), ...(actorName === null ? [] : [h.span([], [actorName])]), ...(entry.actor_id === null ? [] @@ -324,8 +332,11 @@ export const auditDetail = ( ...(actionText === null ? [] : [h.span([], [actionText])]), inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground" }, [entry.action]), entry.outcome === "denied" - ? badge(h, { variant: "crit", size: "sm" }, ["Denied"]) - : badge(h, { variant: "ok", size: "sm" }, ["Allowed"]), + ? deniedBadge(h) + : badge(h, { variant: "ok", size: "sm" }, [ + icon(h, OUTCOME_ICONS.allowed), + "Allowed", + ]), ...(actionText === null ? [] : [pivot(h, "action", entry.action, "action")]), ]), ...(entry.denial_reason === null @@ -379,6 +390,7 @@ export const auditDetail = ( "Entry id", ), textLink(h, { href: entryHref(entry) }, [ + icon(h, "LinkIcon", { size: 12 }), typography(h, { size: "2xs" }, ["Link to entry"]), ]), ]), @@ -390,7 +402,7 @@ export const auditDetail = ( h.section( [h.Class("space-y-1.5")], [ - eyebrow(h, { variant: "mono", as: "h4" }, ["Changes"]), + eyebrow(h, { variant: "mono", as: "h4", icon: "PencilIcon" }, ["Changes"]), changesTable(h, changes), ], ), @@ -401,7 +413,9 @@ export const auditDetail = ( h.section( [h.Class("space-y-1.5")], [ - eyebrow(h, { variant: "mono", as: "h4" }, ["Details"]), + eyebrow(h, { variant: "mono", as: "h4", icon: "CircleInfoIcon" }, [ + "Details", + ]), ...metadataList(h, metadata), ], ), diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 35d2865786..4dc7e7db23 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -33,7 +33,7 @@ import { avatarFallback, badge, separator } from "@maple/views/ui/display" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" import { eyebrow } from "@maple/views/ui/eyebrow" -import { icon } from "@maple/views/ui/icon" +import { icon, type IconName } from "@maple/views/ui/icon" import { listFooter } from "@maple/views/ui/list-footer" import { panel } from "@maple/views/ui/panel" import * as Popover from "@maple/views/ui/popover" @@ -47,10 +47,11 @@ import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { settingsSection } from "../kit/section" import { Message, type Model } from "./model" +import { ACTION_ICON, ACTOR_ICONS, auditActionIcon, OUTCOME_ICONS, SOURCE_ICONS } from "./icons" import { ANY, ANYONE, entryRowId } from "./update" import { auditDetail, memberName, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" +import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" import { Array as Arr, DateTime, String as Str, Option, Order, Record as Rec } from "effect" import { dayKey, dayLabel } from "../../kit/day-label" import { textLink } from "@maple/views/ui/text-link" @@ -64,23 +65,38 @@ export type ViewInputs = Readonly<{ apiKeys: ReadonlyArray }> +type FilterOption = Readonly<{ value: Value; label: string; icon?: IconName }> + const ACTOR_FILTERS = [ { value: "all", label: "All" }, - { value: "user", label: "Users" }, - { value: "api_key", label: "API keys" }, - { value: "agent", label: "Agents" }, - { value: "system", label: "System" }, -] as const + { value: "user", label: "Users", icon: ACTOR_ICONS.user }, + { value: "api_key", label: "API keys", icon: ACTOR_ICONS.api_key }, + { value: "agent", label: "Agents", icon: ACTOR_ICONS.agent }, + { value: "system", label: "System", icon: ACTOR_ICONS.system }, +] as const satisfies ReadonlyArray> const KIND_OPTIONS = [ - { value: "changes", label: "Changes" }, - { value: "access", label: "Data access" }, - { value: "all", label: "All" }, -] as const + { value: "changes", label: "Changes", icon: "PencilIcon" }, + { value: "access", label: "Data access", icon: "EyeIcon" }, + { value: "all", label: "All", icon: "LayersIcon" }, +] as const satisfies ReadonlyArray> const OUTCOME_FILTERS = [ { value: "all", label: "All" }, - { value: "allowed", label: "Allowed" }, - { value: "denied", label: "Denied" }, -] as const + { value: "allowed", label: "Allowed", icon: OUTCOME_ICONS.allowed }, + { value: "denied", label: "Denied", icon: OUTCOME_ICONS.denied }, +] as const satisfies ReadonlyArray> + +/** Segmented options with their glyph drawn before the label. */ +const withGlyphs = (h: H, options: ReadonlyArray>) => + Arr.map(options, (option) => + option.icon === undefined + ? { value: option.value, label: option.label } + : { value: option.value, label: option.label, icon: icon(h, option.icon) }, + ) + +/** The glyph a filter's trigger leads with, naming the filter once its value replaces the label. */ +const filterGlyph = (h: H, name: IconName): ReadonlyArray => [ + icon(h, name, { className: "text-muted-foreground shrink-0" }), +] export const SOURCE_LABELS = { dashboard: "Dashboard", @@ -90,7 +106,7 @@ export const SOURCE_LABELS = { system: "System", } as const satisfies { readonly [Source in AuditLogSource]: string } -export const ACTOR_BADGES = { +const ACTOR_BADGES = { user: { label: "User", variant: "secondary" }, api_key: { label: "API key", variant: "ok" }, agent: { label: "Agent", variant: "info" }, @@ -101,11 +117,12 @@ export const ACTOR_BADGES = { const COL = { time: "flex w-22 shrink-0 items-center gap-2", - actor: "min-w-0 flex-1 @md:w-38 @md:flex-none", + actor: "min-w-0 flex-1 @md:w-38 @md:flex-none @3xl:w-42", action: "min-w-0 basis-full pl-5 @md:basis-0 @md:flex-1 @md:pl-0", - resource: "hidden w-56 min-w-0 shrink-0 @3xl:block", - source: "hidden w-20 shrink-0 @xl:block", - outcome: "hidden w-15 shrink-0 @md:block", + // Shares the row's spare width with the action, so neither starves the other when it appears. + resource: "hidden min-w-0 @3xl:block @3xl:basis-0 @3xl:flex-1", + source: "hidden w-20 shrink-0 @2xl:block", + outcome: "hidden w-16 shrink-0 @md:block", } const initialsOf = (label: string) => (Str.startsWith("user_")(label) ? "?" : initialsFrom(label)) @@ -227,6 +244,7 @@ const filterSelect = ( value: string | undefined anyLabel: string ariaLabel: string + icon: IconName items: ReadonlyArray toMessage: (message: SelectMessage) => Message }>, @@ -251,6 +269,7 @@ const filterSelect = ( ariaLabel: options.ariaLabel, size: "sm", className: "w-auto min-w-36 max-w-64", + leading: filterGlyph(h, options.icon), renderValue: () => [ typography( h, @@ -323,6 +342,7 @@ const personFilter = ( ariaLabel: "Person", size: "sm", className: "w-auto min-w-40 max-w-64", + leading: filterGlyph(h, ACTOR_ICONS.user), renderValue: () => [typography(h, { truncate: true }, [label])], toView: (parts) => h.div([h.Class("contents")], parts), }, @@ -332,7 +352,6 @@ const personFilter = ( // ROWS const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { - const actor = ACTOR_BADGES[entry.actor_type] const name = actorDisplayName(model, entry) const isNamed = entry.actor_name !== null || memberName(model, entry.actor_id) !== null return h.div( @@ -344,7 +363,7 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { { className: "size-4 shrink-0" }, typography(h, { size: "4xs" }, [initialsOf(name ?? "")]), ) - : badge(h, { variant: actor.variant, size: "sm", className: "shrink-0" }, [actor.label]), + : actorBadge(h, entry.actor_type), ...(name === null ? [] : [ @@ -384,7 +403,19 @@ const actionLabel = (h: H, action: string, isQuiet: boolean): ReadonlyArray badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, ["Denied"]) +/** The refused outcome, as the rows and an entry's detail both show it. */ +export const deniedBadge = (h: H): Html => + badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, [ + icon(h, OUTCOME_ICONS.denied), + "Denied", + ]) + +/** What kind of actor made an entry: its glyph and its name. */ +export const actorBadge = (h: H, actorType: AuditActorType): Html => + badge(h, { variant: ACTOR_BADGES[actorType].variant, size: "sm", className: "shrink-0" }, [ + icon(h, ACTOR_ICONS[actorType]), + ACTOR_BADGES[actorType].label, + ]) const resourceCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { const display = withMemberName(model, entry, auditResourceDisplay(entry)) @@ -467,30 +498,45 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs ), actorCell(h, model, entry), h.div( - [h.Class(cn(COL.action, vstackClass({ shrink: true })))], + [h.Class(cn(COL.action, hstackClass({ gap: "2", align: "start" })))], [ - hstack(h, { gap: "1.5", shrink: true }, [ - vstack( - h, - { shrink: true }, - actionLabel(h, entry.action, isMixedView && isQuietAccess(entry)), + // What kind of thing happened, readable before the label is. + icon(h, auditActionIcon(entry.action), { + size: 14, + className: cn( + "mt-px shrink-0", + isDenied ? "text-severity-error" : "text-foreground-subtle", ), - ...(isDenied ? [h.span([h.Class("@md:hidden")], [deniedBadge(h)])] : []), + }), + vstack(h, { shrink: true }, [ + hstack(h, { gap: "1.5", shrink: true }, [ + vstack( + h, + { shrink: true }, + actionLabel(h, entry.action, isMixedView && isQuietAccess(entry)), + ), + ...(isDenied ? [h.span([h.Class("@md:hidden")], [deniedBadge(h)])] : []), + ]), + ...(isDenied && entry.denial_reason !== null + ? [ + truncatedText(h, { + text: entry.denial_reason, + className: typographyClass({ size: "2xs", tone: "muted" }), + }), + ] + : []), ]), - ...(isDenied && entry.denial_reason !== null - ? [ - truncatedText(h, { - text: entry.denial_reason, - className: typographyClass({ size: "2xs", tone: "muted" }), - }), - ] - : []), ], ), h.div([h.Class(COL.resource)], [resourceCell(h, model, entry)]), h.span( [h.Class(COL.source)], - [badge(h, { variant: "outline", size: "sm" }, [SOURCE_LABELS[entry.source]])], + [ + badge(h, { variant: "outline", size: "sm" }, [ + icon(h, SOURCE_ICONS[entry.source]), + SOURCE_LABELS[entry.source], + ]), + ], ), h.span([h.Class(COL.outcome)], isDenied ? [deniedBadge(h)] : []), ], @@ -579,7 +625,11 @@ const withDayHeadings = ( h.Role("heading"), h.AriaLevel(3), ], - [eyebrow(h, { variant: "mono" }, [dayLabel(atMs(entry), clock)])], + [ + eyebrow(h, { variant: "mono", icon: "CalendarIcon" }, [ + dayLabel(atMs(entry), clock), + ]), + ], ), ] : []), @@ -630,12 +680,18 @@ const lookupField = (h: H, model: Model): ReadonlyArray => [ onEnter: Message.SubmittedLookup(), onClear: Message.ClearedLookup(), clearLabel: "Clear lookup", - className: "h-8 w-full @lg:w-72", + className: "h-7 w-full", }), ...(model.isLookupUnknown ? [ - typography(h, { size: "2xs", tone: "muted", attributes: [h.Role("status")] }, [ - "Not something the log filters by. Paste a user, API key, agent, resource or request ID, or an action like dashboard.updated.", + hstack(h, { gap: "1.5", align: "start", attributes: [h.Role("status")] }, [ + icon(h, "CircleInfoIcon", { + size: 12, + className: "text-muted-foreground mt-0.5 shrink-0", + }), + typography(h, { size: "2xs", tone: "muted" }, [ + "Not something the log filters by. Paste a user, API key, agent, resource or request ID, or an action like dashboard.updated.", + ]), ]), ] : []), @@ -674,7 +730,7 @@ export const view = Submodel.defineView((model, inpu ariaLabel: "Show", value: kind, onChange: (value) => Message.ClickedKind({ value }), - options: KIND_OPTIONS, + options: withGlyphs(h, KIND_OPTIONS), }), typography(h, { size: "xs", tone: "muted", className: "hidden @2xl:block" }, [ KIND_DESCRIPTIONS[kind], @@ -696,6 +752,7 @@ export const view = Submodel.defineView((model, inpu value: filters.resourceType, anyLabel: "Any resource", ariaLabel: "Resource type", + icon: "CubeIcon", items: RESOURCE_TYPE_ITEMS, toMessage: (message) => Message.GotResourceTypeSelectMessage({ message }), }), @@ -708,6 +765,7 @@ export const view = Submodel.defineView((model, inpu value: filters.action, anyLabel: "Any action", ariaLabel: "Action", + icon: ACTION_ICON, items: actionItems(filters.resourceType), toMessage: (message) => Message.GotActionSelectMessage({ message }), }), @@ -721,12 +779,14 @@ export const view = Submodel.defineView((model, inpu value: filters.affectedUserId, anyLabel: "Any affected user", ariaLabel: "Affected user", + icon: "IdBadgeIcon", items: model.members, toMessage: (message) => Message.GotAffectedSelectMessage({ message }), }), ]), ]), - hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + // Top-aligned: the lookup's hint grows downward without dragging the filters with it. + hstack(h, { gap: "3", wrap: true, align: "start", justify: "between" }, [ hstack(h, { gap: "3", wrap: true, className: "min-w-0 max-w-full" }, [ segmentedSelect(h, { size: "sm", @@ -735,17 +795,22 @@ export const view = Submodel.defineView((model, inpu className: "max-w-full overflow-x-auto", value: filters.actorType ?? "all", onChange: (value) => Message.ClickedActorType({ value }), - options: ACTOR_FILTERS, + options: withGlyphs(h, ACTOR_FILTERS), }), segmentedSelect(h, { size: "sm", ariaLabel: "Outcome", value: filters.outcome ?? "all", onChange: (value) => Message.ClickedOutcome({ value }), - options: OUTCOME_FILTERS, + options: withGlyphs(h, OUTCOME_FILTERS), }), ]), - vstack(h, { gap: "1", className: "w-full @lg:w-auto" }, lookupField(h, model)), + // Takes what the filters beside it leave, and its own row once that is too little to type in. + vstack( + h, + { gap: "1", className: "min-w-0 grow basis-full @lg:max-w-72 @lg:basis-56" }, + lookupField(h, model), + ), ]), ...(chips.length === 0 ? [] : [hstack(h, { gap: "1.5", wrap: true }, chips)]), ]), diff --git a/packages/views/src/ui/attributes/attribute-row.ts b/packages/views/src/ui/attributes/attribute-row.ts index b81ffd29e1..353a4cb7f9 100644 --- a/packages/views/src/ui/attributes/attribute-row.ts +++ b/packages/views/src/ui/attributes/attribute-row.ts @@ -4,7 +4,7 @@ import { prettyJson, tryParseJson } from "../../lib/display-text" import { Option, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { copyButton } from "../copy-button" -import { icon } from "../icon" +import { icon, type IconName } from "../icon" import { cappedCode } from "../logs/log-message" import { type AttributeSource, copyStatusOf, isOpen, Message, type Model } from "./attributes-panel" @@ -158,7 +158,14 @@ const commitShaValue = (h: H, id: string, value: string): Html => /** The per-row hover verb (`AttributeFilterAction`): the `tag` badge as a button. */ export const attributeFilterAction = ( h: HtmlBuilder, - options: Readonly<{ label: string; text: string; onClick: M; stopPropagation?: boolean }>, + options: Readonly<{ + label: string + text: string + onClick: M + stopPropagation?: boolean + /** A glyph before the verb. */ + icon?: IconName + }>, ): Html => h.button( [ @@ -176,7 +183,7 @@ export const attributeFilterAction = ( h.DataAttribute("slot", "badge"), options.stopPropagation === true ? h.OnClick(options.onClick, stop) : h.OnClick(options.onClick), ], - [options.text], + [...(options.icon === undefined ? [] : [icon(h, options.icon)]), options.text], ) const filterAction = (h: H, label: string, onClick: Message, text: string): Html => diff --git a/packages/views/src/ui/eyebrow.ts b/packages/views/src/ui/eyebrow.ts index ade6b9747f..9bcd8b5845 100644 --- a/packages/views/src/ui/eyebrow.ts +++ b/packages/views/src/ui/eyebrow.ts @@ -3,6 +3,7 @@ import { eyebrowVariants } from "@maple/ui/styles/ui/eyebrow" import type { VariantProps } from "class-variance-authority" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { Array as Arr } from "effect" +import { icon, type IconName } from "./icon" export { eyebrowVariants } @@ -11,6 +12,8 @@ export interface EyebrowOptions { readonly as?: "span" | "div" | "p" | "ul" | "h2" | "h3" | "h4" | "h5" readonly className?: string readonly attributes?: ReadonlyArray> + /** A glyph before the label, for a group whose kind reads faster as a picture. */ + readonly icon?: IconName } // A trailing "(hint)" often holds units or syntax (`5m`, `AND`); uppercasing would change its meaning. @@ -33,9 +36,22 @@ export const eyebrow = ( options: EyebrowOptions, rawChildren: ReadonlyArray, ): Html => { - const children = Arr.flatMap(rawChildren, (child) => withHint(h, child)) + const label = Arr.flatMap(rawChildren, (child) => withHint(h, child)) + // With an icon the label is one flex item, so a "(hint)" keeps its own spacing. + const children = + options.icon === undefined + ? label + : [icon(h, options.icon, { size: 12, className: "shrink-0" }), h.span([], label)] + const isInline = options.as === undefined || options.as === "span" const attributes = [ - h.Class(cn(eyebrowVariants({ variant: options.variant }), options.className)), + h.Class( + cn( + eyebrowVariants({ variant: options.variant }), + options.icon !== undefined && (isInline ? "inline-flex" : "flex"), + options.icon !== undefined && "items-center gap-1.5", + options.className, + ), + ), h.DataAttribute("slot", "eyebrow"), ...(options.attributes ?? []), ]