diff --git a/apps/web/src/lab/entries/list-keys.ts b/apps/web/src/lab/entries/list-keys.ts new file mode 100644 index 0000000000..acf9f574fb --- /dev/null +++ b/apps/web/src/lab/entries/list-keys.ts @@ -0,0 +1,28 @@ +import { defineShellLab } from "../shell-lab" +import { + breadcrumbs, + currentPath, + Flags, + flags, + init, + Message, + Model, + subscriptions, + update, + view, +} from "../list-keys-lab" + +// `/lab/list-keys`: the list pages that take the row keys, over fixture rows (`?page=`). + +export const lab = defineShellLab("List keys", { + Model, + Message, + Flags, + flags, + init, + update, + view, + subscriptions, + currentPath, + breadcrumbs, +}) diff --git a/apps/web/src/lab/list-keys-fixture.ts b/apps/web/src/lab/list-keys-fixture.ts new file mode 100644 index 0000000000..c65fc14c22 --- /dev/null +++ b/apps/web/src/lab/list-keys-fixture.ts @@ -0,0 +1,234 @@ +import * as Policy from "@maple/domain/policy" +import { V2Investigation } from "@maple/domain/http/v2" +import { Array as Arr, Result, Schema } from "effect" +import type { PageContext } from "../page/contract" +import * as Hub from "../page/alerts/hub/update" +import { Message as HubMessage } from "../page/alerts/hub/message" +import * as Dashboards from "../page/dashboards/list/update" +import { Message as DashboardsMessage } from "../page/dashboards/list/message" +import type { ListDashboard } from "../page/dashboards/list/model" +import * as Investigations from "../page/investigations/list/update" +import { Message as InvestigationsMessage } from "../page/investigations/list/message" +import { hubAt, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../page/kit/alert-test-rows" +import * as Metrics from "../page/metrics/browse/update" +import { Message as MetricsMessage } from "../page/metrics/browse/message" +import type { MetricRow } from "../page/metrics/shared" +import * as Services from "../page/services/list/update" +import { Message as ServicesMessage } from "../page/services/list/message" +import type { ServiceRow } from "../page/services/list/model" +import { VERDICT_LAB_CASES } from "./verdict-fixture" + +// `/lab/list-keys`: each list page's Model as its reads would leave it, over a handful of fixture rows. + +const WINDOW = { startTime: "2026-09-30 12:00:00", endTime: "2026-10-01 12:00:00" } + +const service = ( + serviceName: string, + environment: string, + p95LatencyMs: number, + errorRate: number, +): ServiceRow => ({ + serviceName, + serviceNamespace: "", + environment, + commits: [], + p50LatencyMs: p95LatencyMs / 3, + p95LatencyMs, + p99LatencyMs: p95LatencyMs * 1.8, + errorRate, + throughput: 1200 / p95LatencyMs, + tracedThroughput: 1200 / p95LatencyMs, + hasSampling: false, + samplingWeight: 1, + spanCount: 4200, +}) + +const SERVICES = [ + service("checkout", "production", 182, 0.012), + service("payments", "production", 240, 0), + service("search", "production", 96, 0.002), + service("orders", "production", 310, 0), + service("checkout", "staging", 205, 0), + service("payments", "staging", 260, 0.03), +] + +/** Feeds `messages` through a page's update, keeping only the Model. */ +const fold = ( + entered: Model, + update: (model: Model, message: Message) => { readonly model: Model }, + messages: (model: Model) => ReadonlyArray, +): Model => Arr.reduce(messages(entered), entered, (model, message) => update(model, message).model) + +export const servicesFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: ServicesMessage) => + Services.update(model, message, context) + const windowed = step( + Services.init({ search: {} }, context).model, + ServicesMessage.CompletedResolveWindow({ window: WINDOW, nowMs: NOW, mode: "Load" }), + ).model + return fold(windowed, step, (model) => [ + ServicesMessage.CompletedFetchOverview({ + key: model.overview.key ?? "", + result: Result.succeed(SERVICES), + }), + ServicesMessage.CompletedFetchSparklines({ + key: model.sparklines.key ?? "", + result: Result.succeed({}), + }), + ServicesMessage.CompletedFetchAnomalies({ + key: model.anomalies.key ?? "", + result: Result.succeed([]), + }), + ServicesMessage.CompletedFetchFacets({ + key: model.facets.key ?? "", + result: Result.succeed({ + environments: [ + { name: "production", count: 4 }, + { name: "staging", count: 2 }, + ], + namespaces: [], + commitShas: [], + }), + }), + ServicesMessage.UpdatedIncidents({ orgId: context.orgId, incidents: [] }), + ServicesMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ traces: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const metric = (metricName: string, metricType: string, serviceName: string): MetricRow => ({ + metricName, + metricType, + serviceName, + metricDescription: "", + metricUnit: "", + dataPointCount: 18_400, + firstSeen: "2026-09-30 12:05:00", + lastSeen: "2026-10-01 11:58:00", + isMonotonic: false, +}) + +const METRICS = [ + metric("http.server.request.duration", "histogram", "checkout"), + metric("http.client.request.duration", "histogram", "payments"), + metric("process.cpu.time", "sum", "search"), + metric("process.memory.usage", "gauge", "orders"), + metric("db.client.connection.count", "gauge", "checkout"), + metric("queue.messages.published", "sum", "orders"), +] + +export const metricsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: MetricsMessage) => + Metrics.update(model, message, context) + const windowed = step( + Metrics.init({ search: { view: "table" } }, context).model, + MetricsMessage.CompletedResolveWindow({ window: WINDOW, nowMs: NOW, mode: "Load" }), + ).model + return fold(windowed, step, (model) => [ + MetricsMessage.CompletedFetchTable({ key: model.table.key ?? "", result: Result.succeed(METRICS) }), + MetricsMessage.CompletedFetchSummary({ key: model.summary.key ?? "", result: Result.succeed([]) }), + MetricsMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ metrics: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const BASE_INVESTIGATION = Arr.getUnsafe(VERDICT_LAB_CASES, 0).investigation +const decodeInvestigationId = Schema.decodeSync(V2Investigation.fields.id) + +/** A diagnosed investigation; a later `minute` lists it higher. */ +const investigation = (n: number, title: string, minute: number): V2Investigation => ({ + ...BASE_INVESTIGATION, + id: decodeInvestigationId(`${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111`), + snapshot: { ...BASE_INVESTIGATION.snapshot, title }, + updated_at: `2026-10-01T11:${minute}:00.000Z`, +}) + +const INVESTIGATIONS = [ + investigation(4, "TimeoutError: payment capture exceeded 30s", 52), + investigation(5, "Checkout p95 latency doubled after deploy 4f2c1a", 41), + investigation(6, "Search index lag over 5 minutes", 30), + investigation(7, "Export job retries exhausted", 18), +] + +export const investigationsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: InvestigationsMessage) => + Investigations.update(model, message, context) + return fold(Investigations.init({ search: {} }, context).model, step, (model) => [ + InvestigationsMessage.ReadClock({ nowMs: NOW }), + InvestigationsMessage.CompletedReadGrants({ + orgId: context.orgId, + grants: Policy.grantsOf(["org:engineer"]), + }), + InvestigationsMessage.CompletedFetchFirstPage({ + key: model.firstPage.key ?? "", + result: Result.succeed({ data: INVESTIGATIONS, hasMore: false, nextCursor: null }), + }), + InvestigationsMessage.CompletedLoadSignalPresence({ + result: Result.succeed({ traces: { status: "present", lastSeenMs: null } }), + }), + ]) +} + +const dashboard = (id: string, name: string, description: string, day: number): ListDashboard => ({ + id, + name, + description, + tags: [], + widgetCount: 8, + readsShort: "Traces · Errors", + readsFull: "Traces · Errors", + glyph: "PulseIcon", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: `2026-09-${day}T12:00:00.000Z`, +}) + +const DASHBOARDS = [ + dashboard("dash_platform", "Platform overview", "Cross-service health.", 30), + dashboard("dash_payment", "Payment service health", "Capture latency and declines.", 28), + dashboard("dash_checkout", "Checkout funnel", "Cart to order, step by step.", 25), + dashboard("dash_search", "Search quality", "Latency, zero results and index lag.", 21), + dashboard("dash_queues", "Queues and workers", "Depth, age and retries.", 14), +] + +export const dashboardsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: DashboardsMessage) => + Dashboards.update(model, message, context) + return fold(Dashboards.init({ search: {} }, context).model, step, () => [ + DashboardsMessage.CompletedReadClock({ nowMs: NOW, locale: "en-US" }), + DashboardsMessage.CompletedLoadPreferences({ favorites: ["dash_payment"], sort: "updated" }), + DashboardsMessage.UpdatedDashboards({ orgId: context.orgId, dashboards: DASHBOARDS }), + ]) +} + +const incident = (n: number, rule_name: string, fields: Parameters[0] = {}) => + incidentRow({ + id: `0199a003-0000-7000-8000-00000000000${n}`, + rule_id: ruleId(n), + incident_key: `rule-${n}:all`, + dedupe_key: `rule-${n}:all`, + rule_name, + ...fields, + }) + +export const alertsFixture = (context: PageContext) => { + const step = (model: Parameters[0], message: HubMessage) => + Hub.update(model, message, context) + return fold(Hub.init(hubAt({}), context).model, step, () => [ + HubMessage.ReadClock({ nowMs: NOW }), + HubMessage.UpdatedRules({ orgId: ORG, rows: Arr.map([1, 2, 3, 4], (n) => ruleRow(n)) }), + HubMessage.UpdatedRuleStates({ orgId: ORG, rows: Arr.map([1, 2, 3, 4], (n) => stateRow(n)) }), + HubMessage.UpdatedIncidents({ + orgId: ORG, + rows: [ + incident(1, "Payment p95 latency"), + incident(2, "Checkout p95 latency", { severity: "warning", last_observed_value: 912 }), + incident(3, "Search p95 latency", { severity: "warning", last_observed_value: 860 }), + incident(4, "Orders p95 latency", { hold_reason: "no_data" }), + ], + }), + HubMessage.UpdatedDestinations({ orgId: ORG, rows: [] }), + HubMessage.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + ]) +} diff --git a/apps/web/src/lab/list-keys-lab.ts b/apps/web/src/lab/list-keys-lab.ts new file mode 100644 index 0000000000..5cb3946ab0 --- /dev/null +++ b/apps/web/src/lab/list-keys-lab.ts @@ -0,0 +1,249 @@ +import { OrgId } from "@maple/domain" +import { Array as Arr, Effect, Option, Schema } from "effect" +import { Command, Subscription, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as HubMessage } from "../page/alerts/hub/message" +import { Model as HubModel } from "../page/alerts/hub/model" +import { subscriptions as hubSubscriptions } from "../page/alerts/hub/subscription" +import { update as hubUpdate } from "../page/alerts/hub/update" +import { view as hubView } from "../page/alerts/hub/view" +import { Message as DashboardsMessage } from "../page/dashboards/list/message" +import { Model as DashboardsModel } from "../page/dashboards/list/model" +import { subscriptions as dashboardsSubscriptions } from "../page/dashboards/list/subscription" +import { update as dashboardsUpdate } from "../page/dashboards/list/update" +import { view as dashboardsView } from "../page/dashboards/list/view" +import { Message as InvestigationsMessage } from "../page/investigations/list/message" +import { Model as InvestigationsModel } from "../page/investigations/list/model" +import { subscriptions as investigationsSubscriptions } from "../page/investigations/list/subscription" +import { update as investigationsUpdate } from "../page/investigations/list/update" +import { view as investigationsView } from "../page/investigations/list/view" +import { Message as MetricsMessage } from "../page/metrics/browse/message" +import { Model as MetricsModel } from "../page/metrics/browse/model" +import { subscriptions as metricsSubscriptions } from "../page/metrics/browse/subscription" +import { update as metricsUpdate } from "../page/metrics/browse/update" +import { view as metricsView } from "../page/metrics/browse/view" +import { Message as ServicesMessage } from "../page/services/list/message" +import { Model as ServicesModel } from "../page/services/list/model" +import { subscriptions as servicesSubscriptions } from "../page/services/list/subscription" +import { update as servicesUpdate } from "../page/services/list/update" +import { view as servicesView } from "../page/services/list/view" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import type { PageContext } from "../page/contract" +import { ORG } from "../page/kit/alert-test-rows" +import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" +import { + alertsFixture, + dashboardsFixture, + investigationsFixture, + metricsFixture, + servicesFixture, +} from "./list-keys-fixture" + +// `/lab/list-keys`: the list pages that take the row keys (J / K / arrows, Enter, Escape), each +// mounted over fixture rows with its own key subscription. `?page=` picks one: services, metrics, +// investigations, dashboards, alerts. Reads, writes and URL changes are dropped, so Enter and the +// filters go nowhere here. + +export const LabPage = Schema.Literals(["services", "metrics", "investigations", "dashboards", "alerts"]) +export type LabPage = typeof LabPage.Type + +export const Flags = Schema.Struct({ page: LabPage, timeZone: Schema.String }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ + page: Option.getOrElse( + Schema.decodeUnknownOption(LabPage)(new URLSearchParams(window.location.search).get("page")), + (): LabPage => "services", + ), + timeZone, +})) + +export const Model = Schema.Struct({ + page: LabPage, + timeZone: Schema.String, + services: ServicesModel, + metrics: MetricsModel, + investigations: InvestigationsModel, + dashboards: DashboardsModel, + alerts: HubModel, +}) +export type Model = typeof Model.Type + +/** The dashboards Message can carry a browser `File`, which has no codec: held as it is. */ +const DashboardsMessageValue = Schema.declare(Schema.is(DashboardsMessage)) + +export const Message = defineMessageUnion({ + GotServicesMessage: { message: ServicesMessage }, + GotMetricsMessage: { message: MetricsMessage }, + GotInvestigationsMessage: { message: InvestigationsMessage }, + GotDashboardsMessage: { message: DashboardsMessageValue }, + GotAlertsMessage: { message: HubMessage }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return + +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) + +/** A page's reads, writes and clock are dropped: the fixture is all the data there is. */ +const DATA_COMMAND = + /^(Resolve|Fetch|Load(?!ChartRuntime)|Read|Create|Duplicate|Export|Import|Delete|Save|Toggle|End|Push|Dismiss)/ + +const keepLocal = ( + commands: ReadonlyArray> | undefined, + lift: (message: M) => Lifted, +) => + Command.mapMessages( + Arr.filter(commands ?? [], (command) => !DATA_COMMAND.test(command.name)), + lift, + ) + +const toServices = (message: ServicesMessage) => Message.GotServicesMessage({ message }) +const toMetrics = (message: MetricsMessage) => Message.GotMetricsMessage({ message }) +const toInvestigations = (message: InvestigationsMessage) => Message.GotInvestigationsMessage({ message }) +const toDashboards = (message: DashboardsMessage) => Message.GotDashboardsMessage({ message }) +const toAlerts = (message: HubMessage) => Message.GotAlertsMessage({ message }) + +export const init = (flags: Flags): Return => { + const context = contextOf(flags.timeZone) + return { + model: { + page: flags.page, + timeZone: flags.timeZone, + services: servicesFixture(context), + metrics: metricsFixture(context), + investigations: investigationsFixture(context), + dashboards: dashboardsFixture(context), + alerts: alertsFixture(context), + }, + // The services table paints once the chart runtime is in. + commands: Command.mapMessages([LoadChartRuntime()], toServices), + } +} + +export const update = (model: Model, message: Message): Return => { + const context = contextOf(model.timeZone) + return Message.match(message, { + GotServicesMessage: ({ message: inner }) => { + const next = servicesUpdate(model.services, inner, context) + return { + model: { ...model, services: next.model }, + commands: keepLocal(next.commands, toServices), + } + }, + GotMetricsMessage: ({ message: inner }) => { + const next = metricsUpdate(model.metrics, inner, context) + return { model: { ...model, metrics: next.model }, commands: keepLocal(next.commands, toMetrics) } + }, + GotInvestigationsMessage: ({ message: inner }) => { + const next = investigationsUpdate(model.investigations, inner, context) + return { + model: { ...model, investigations: next.model }, + commands: keepLocal(next.commands, toInvestigations), + } + }, + GotDashboardsMessage: ({ message: inner }) => { + const next = dashboardsUpdate(model.dashboards, inner, context) + return { + model: { ...model, dashboards: next.model }, + commands: keepLocal(next.commands, toDashboards), + } + }, + GotAlertsMessage: ({ message: inner }) => { + const next = hubUpdate(model.alerts, inner, context) + return { model: { ...model, alerts: next.model }, commands: keepLocal(next.commands, toAlerts) } + }, + }) +} + +const LABEL = { + services: "Services", + metrics: "Metrics", + investigations: "Investigations", + dashboards: "Dashboards", + alerts: "Alerts", +} satisfies Record + +export const breadcrumbs = (model: Model) => [{ label: LABEL[model.page], isPageHeading: true }] + +/** The page's own path, so the sidebar marks it. */ +export const currentPath = (model: Model): string => `/${model.page}` + +export const view = (model: Model, h: HtmlBuilder): Html => { + const viewInputs = { context: contextOf(model.timeZone) } + switch (model.page) { + case "services": + return h.submodel({ + slotId: "lab-list-keys-services", + model: model.services, + view: servicesView, + viewInputs, + toParentMessage: toServices, + }) + case "metrics": + return h.submodel({ + slotId: "lab-list-keys-metrics", + model: model.metrics, + view: metricsView, + viewInputs, + toParentMessage: toMetrics, + }) + case "investigations": + return h.submodel({ + slotId: "lab-list-keys-investigations", + model: model.investigations, + view: investigationsView, + viewInputs, + toParentMessage: toInvestigations, + }) + case "dashboards": + return h.submodel({ + slotId: "lab-list-keys-dashboards", + model: model.dashboards, + view: dashboardsView, + viewInputs, + toParentMessage: toDashboards, + }) + case "alerts": + return h.submodel({ + slotId: "lab-list-keys-alerts", + model: model.alerts, + view: hubView, + viewInputs, + toParentMessage: toAlerts, + }) + } +} + +/** A page's `{ model, context }` while it is the one on screen. */ +const shown = (model: Model, page: LabPage, pageModel: PageModel) => + model.page === page + ? Option.some({ model: pageModel, context: contextOf(model.timeZone) }) + : Option.none() + +/** Only each page's key stream: its live collections have nothing to sync here. */ +export const subscriptions = Subscription.aggregate()( + Subscription.lift({ servicesKeys: servicesSubscriptions.listKeys })({ + read: (model) => shown(model, "services", model.services), + toParentMessage: toServices, + }), + Subscription.lift({ metricsKeys: metricsSubscriptions.listKeys })({ + read: (model) => shown(model, "metrics", model.metrics), + toParentMessage: toMetrics, + }), + Subscription.lift({ investigationsKeys: investigationsSubscriptions.listKeys })({ + read: (model) => shown(model, "investigations", model.investigations), + toParentMessage: toInvestigations, + }), + Subscription.lift({ dashboardsKeys: dashboardsSubscriptions.listKeys })({ + read: (model) => shown(model, "dashboards", model.dashboards), + toParentMessage: toDashboards, + }), + Subscription.lift({ alertsKeys: hubSubscriptions.listKeys })({ + read: (model) => shown(model, "alerts", model.alerts), + toParentMessage: toAlerts, + }), +) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 2c67573b32..0e509f4086 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/list-keys", + title: "List keys", + description: + "The services, metrics table, investigations, dashboards and alerts Inbox lists over fixture rows, each taking the row keys: J / K / arrows move the focus, Enter opens, Escape clears (`?page=services|metrics|investigations|dashboards|alerts`).", + kind: "lab", + session: "none", + }, { path: "/lab/alerts-hub", title: "Alerts hub", diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 540a3bbd5e..fba08e30b4 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -1,4 +1,8 @@ -import type { AlertDestinationDocument, AlertIncidentDocument } from "@maple/domain/http" +import { + type AlertDestinationDocument, + type AlertIncidentDocument, + isManagedSignalType, +} from "@maple/domain/http" import { type AlertsOverviewReady, deriveOverview } from "@maple/web-data/alerts/overview" import { buildRuleStatesByRuleId, @@ -9,6 +13,7 @@ import { import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" +import { sortIncidents } from "../shared/sort" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" import type { ScopeSuggestions } from "../silence-dialog/dialog" @@ -100,6 +105,41 @@ export const anomalyOverviewOf = (model: Model): Option.Option signal === "anomaly" || signal === "anomalies" ? "anomaly" : null +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +export const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + +/** What still needs someone first, then what someone is on, then what waits on data. */ +export const inboxOrder = ( + openIncidents: ReadonlyArray, +): ReadonlyArray => { + const all = sortIncidents(openIncidents) + return [ + ...Arr.filter(all, (i) => i.holdReason === null && i.acknowledgedAt === null), + ...Arr.filter(all, (i) => i.holdReason === null && i.acknowledgedAt !== null), + ...Arr.filter(all, (i) => i.holdReason !== null), + ] +} + +/** The open incidents the Inbox lists, in its order: what the list keys step over. */ +export const inboxIncidentsOf = (model: Model): ReadonlyArray => + model.tab !== "inbox" || resolveSignal(model.search.signal) !== null + ? [] + : Option.match(overviewOf(model), { + onNone: () => [], + onSome: (data) => (isFirstRun(model, data, "inbox") ? [] : inboxOrder(data.openIncidents)), + }) + /** A signal view lists open incidents unless the link asks for resolved or all of them. */ export const resolveIncidentStatus = (status: string | undefined): IncidentStatusFilter => status === "resolved" || status === "all" ? status : "open" diff --git a/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts b/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts new file mode 100644 index 0000000000..26ebdc6cdc --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-list-keys.scene.test.ts @@ -0,0 +1,132 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Array as Arr, Schema } from "effect" +import { + Command, + expect as expectView, + expectOutMessage, + given, + scene, + selector, + Subscription, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { PageOutMessage, type PageContext } from "../../contract" +import { hubAt, type HubAt, incidentRow, NOW, ORG, ruleRow, stateRow } from "../../kit/alert-test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" +import { view as pageView } from "./view" + +// The Inbox's open incidents take the list keys: J / K / arrows step, Enter opens, Escape lets go. + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const incidentId = (n: number) => `0199a003-0000-7000-8000-00000000000${n}` +const iso = (minutesAgo: number) => new Date(NOW - minutesAgo * 60_000).toISOString() + +// Inbox order: the critical one, then the warning, then the one held for data. +const CRITICAL = incidentRow({ id: incidentId(1), rule_name: "Payment p95 latency" }) +const WARNING = incidentRow({ + id: incidentId(2), + rule_id: "0199a002-0000-7000-8000-000000000001", + incident_key: "rule-1:all", + dedupe_key: "rule-1:all", + rule_name: "Checkout p95 latency", + severity: "warning", +}) +const HELD = incidentRow({ + id: incidentId(3), + rule_id: "0199a002-0000-7000-8000-000000000003", + incident_key: "rule-3:all", + dedupe_key: "rule-3:all", + rule_name: "Search p95 latency", + hold_reason: "no_data", + held_since: iso(5), +}) + +const loaded = (at: HubAt = {}, incidents = [HELD, WARNING, CRITICAL]): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2), ruleRow(3)] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [stateRow(1), stateRow(2), stateRow(3)] }), + Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + ], + init(hubAt(at), context).model, + (model, message) => pageUpdate(model, message).model, + ) + +const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) +const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) +const focusedRow = selector("[data-focused]") +const pressed = (model: Model, ...keys: ReadonlyArray): Model => + Arr.reduce(keys, model, (next, key) => pageUpdate(next, Message.PressedListKey({ key })).model) + +describe("alerts inbox keys", () => { + test("J and K move the focus through the open incidents in the Inbox's order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment p95 latency"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Search p95 latency"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused incident", () => { + scene( + { update: pageUpdate, view }, + given(pressed(loaded(), "j", "j")), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: `/alerts/incidents/${incidentId(2)}`, + isReplace: false, + }), + ), + ) + }) + + test("the focus stays on its incident when a new one lands above it", () => { + const focused = pressed(loaded({}, [WARNING]), "j") + const grown = pageUpdate( + focused, + Message.UpdatedIncidents({ orgId: ORG, rows: [WARNING, CRITICAL] }), + ).model + scene( + { update: pageUpdate, view }, + given(grown), + expectView(focusedRow).toContainText("Checkout p95 latency"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Payment p95 latency"), + ) + }) + + test("another section has no incident rows to step over, and leaving the Inbox drops the focus", () => { + expect(pressed(loaded({ tab: "rules" }), "j").focusedIncidentId).toBeNull() + const focused = pressed(loaded(), "j") + expect(focused.focusedIncidentId).toBe(incidentId(1)) + expect(routeChanged(focused, hubAt({ tab: "rules" }), context).model.focusedIncidentId).toBeNull() + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 71ba17656c..8e11c59d28 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -57,6 +57,9 @@ export const Message = defineMessageUnion({ UpdatedDestinations: rows(AlertDestinationRowSchema), FailedLoadAlerts: { orgId: Schema.String }, ClickedRetryLoad: {}, + /** A list key over the Inbox's open incidents: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, CompletedFetchDeliveries: { key: Schema.String, result: LoadOutcome(Schema.Array(AlertDeliveryEventDocument)), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 9348526d11..da370705bd 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -72,6 +72,8 @@ export const Model = Schema.Struct({ hasToggleFailed: Schema.Boolean, /** The rules or incidents stream stopped, or stalled, before its first answer. */ isLoadFailed: Schema.Boolean, + /** Inbox: the keyboard-focused open incident, by id (the live list reorders under it). */ + focusedIncidentId: Schema.NullOr(Schema.String), /** Routing: destinations with a test or delete in flight, and the one awaiting delete confirmation. */ testingDestinationIds: Schema.Array(AlertDestinationId), /** Inbox: incidents with an acknowledge or unacknowledge request in flight. */ diff --git a/apps/web/src/page/alerts/hub/subscription.ts b/apps/web/src/page/alerts/hub/subscription.ts index 99cc5e8399..1cf6a27a43 100644 --- a/apps/web/src/page/alerts/hub/subscription.ts +++ b/apps/web/src/page/alerts/hub/subscription.ts @@ -6,6 +6,8 @@ import * as DestinationDialog from "../destination/dialog" import * as ChatConnectStream from "../shared/chat-connect-stream" import { collectionLoadFailed } from "../../kit/collection-load" import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../../kit/alert-collections" +import { listKeys } from "../../kit/list-keys" +import { inboxIncidentsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -19,6 +21,19 @@ const HUB_LOAD_TIMEOUT_MS = 10_000 export const subscriptions = Subscription.make, Message, Resources>()( (entry) => ({ + /** The Inbox's list keys while it lists open incidents; an open menu keeps its arrows. */ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: inboxIncidentsOf(model).length > 0 && !model.newRuleMenu.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), rules: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b98878bb1b..c87c3ffaeb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -27,7 +27,7 @@ import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" -import { alertsHref, createHref, ruleHref } from "../../kit/alert-links" +import { alertsHref, createHref, incidentHref, ruleHref } from "../../kit/alert-links" import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT, chainHref } from "../shared/escalation-chains" import { editScheduleHref, @@ -69,7 +69,8 @@ import { } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" -import { resolveStatus } from "./derive" +import { inboxIncidentsOf, resolveStatus } from "./derive" +import { focusedIndexOf, listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { Message, type RuleBucket } from "./message" import type { Model } from "./model" import { acceptGrants, canManageAlerts } from "../../kit/session-grants" @@ -314,6 +315,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { togglingRuleId: null, hasToggleFailed: false, isLoadFailed: false, + focusedIncidentId: null, testingDestinationIds: [], acknowledgingIncidentIds: [], deletingDestinationIds: [], @@ -385,7 +387,11 @@ export const init = (route: HubRoute, context: PageContext): Return => { } export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { - const sheet = followSilenceSheet({ ...model, ...fromRoute(route) }, context) + const next = fromRoute(route) + // Another tab or signal view is another row set: the Inbox focus starts over. + const focusedIncidentId = + next.tab === model.tab && next.search.signal === model.search.signal ? model.focusedIncidentId : null + const sheet = followSilenceSheet({ ...model, ...next, focusedIncidentId }, context) const routing = requestRoutingDeliveries(sheet.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") @@ -411,6 +417,31 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext } } +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollAlertsHubFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** The Inbox's list keys: Enter follows the focused incident's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const ids = Array.map(inboxIncidentsOf(model), (incident) => incident.id) + const action = listKeyAction(key, focusedIndexOf(ids, model.focusedIncidentId), ids.length) + if (action._tag === "Focus") { + const focusedIncidentId = action.index === null ? null : (ids[action.index] ?? null) + return { + model: { ...model, focusedIncidentId }, + commands: focusedIncidentId === null ? [] : [ScrollFocusedRow()], + } + } + const id = action._tag === "Open" ? ids[action.index] : undefined + return id === undefined + ? { model } + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ url: incidentHref(id), isReplace: false }), + } +} + const navigate = (search: Model["search"], tab: Model["tab"]): PageOutMessage => PageOutMessage.RequestedNavigation({ url: alertsHref(search, tab), isReplace: false }) @@ -1092,6 +1123,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model: { ...model, isLoadFailed: true } } : { model }, // A stopped shape syncs again only on fresh collections. + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ClickedRetryLoad: () => ({ model: { ...model, isLoadFailed: false }, outMessage: PageOutMessage.RequestedReconnectOrgCollections(), diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index deecc00eb2..d8b7a95056 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -33,7 +33,7 @@ import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table" import { severityBadge } from "../shared/badges" import { HEADROOM_FILL, HEADROOM_TEXT } from "../shared/headroom-tone" import { incidentHref, ruleHref } from "../../kit/alert-links" -import { sortIncidents } from "../shared/sort" +import { inboxOrder } from "./derive" import { msIso } from "../../kit/alert-time" import { gridListRow } from "../../kit/grid-list-row" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -70,16 +70,12 @@ export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, acknowledge: InboxAcknowledge, + /** The keyboard-focused open incident, when the page has one. */ + focusedId: string | null = null, ): Html => { const { rules, incidents, openIncidents, derivedByRuleId, statesByRule, timelineRange } = data const now = timelineRange.max - const all = sortIncidents(openIncidents) - // What still needs someone first, then what someone is on, then what waits on data. - const sorted = [ - ...Arr.filter(all, (i) => i.holdReason === null && !isAcknowledged(i)), - ...Arr.filter(all, (i) => i.holdReason === null && isAcknowledged(i)), - ...Arr.filter(all, (i) => i.holdReason !== null), - ] + const sorted = inboxOrder(openIncidents) const spots = deriveBlindSpots(rules, derivedByRuleId) const lanes = fireLanes(incidents, timelineRange) const held = Arr.filter(sorted, (i) => i.holdReason !== null).length @@ -148,7 +144,7 @@ export const inboxTab = ( ]) : sections, ] - : [openIncidentsList(h, sorted, now, acknowledge), sections]), + : [openIncidentsList(h, sorted, now, acknowledge, focusedId), sections]), ], ) } @@ -170,6 +166,7 @@ const openIncidentsList = ( incidents: ReadonlyArray, now: number, acknowledge: InboxAcknowledge, + focusedId: string | null, ) => borderedBox(h, { overflow: "x-auto" }, [ h.div( @@ -194,7 +191,9 @@ const openIncidentsList = ( dividedList( h, { as: "ul", bordered: false }, - Arr.map(incidents, (incident) => openIncidentRow(h, incident, now, acknowledge)), + Arr.map(incidents, (incident) => + openIncidentRow(h, incident, now, acknowledge, incident.id === focusedId), + ), ), ], ), @@ -205,11 +204,12 @@ const openIncidentRow = ( incident: AlertIncidentDocument, now: number, acknowledge: InboxAcknowledge, + isFocused: boolean, ): Html => { const isHeld = incident.holdReason !== null const acked = acknowledgedLine(incident) const tone = incident.severity === "critical" ? "text-severity-error" : "text-severity-warn" - return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID }, [ + return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID, isFocused }, [ h.span([ h.AriaHidden(true), h.Class( diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ec31779fb5..1529e3bfef 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -1,4 +1,4 @@ -import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" +import type { AlertIncidentId } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" @@ -25,6 +25,7 @@ import { anomalyOverviewOf, destinationsOf, isAboutService, + isFirstRun, overviewOf, resolveIncidentStatus, resolveSignal, @@ -178,20 +179,6 @@ const primaryLink = (h: H, href: string, label: string): Html => const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) -/** - * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list - * (an org with only monitors or SLOs has no visible rules but may have incidents to show). - */ -const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { - if (data.rules.length > 0) return false - if (tab === "rules") return true - return ( - resolveSignal(model.search.signal) === null && - !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && - !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") - ) -} - /** * The header's one primary action: what the open section lists. A section whose empty state * already offers it shows none until it has rows, and a viewer who can't create it sees none. @@ -314,7 +301,7 @@ const hubBody = ( acknowledge, }), }) - : inboxTab(h, data, acknowledge) + : inboxTab(h, data, acknowledge, model.focusedIncidentId) return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), ...filters, body]) } return rulesTab(h, model, { diff --git a/apps/web/src/page/dashboards/list/list.scene.test.ts b/apps/web/src/page/dashboards/list/list.scene.test.ts index 9b6ba93730..5efd1f0c46 100644 --- a/apps/web/src/page/dashboards/list/list.scene.test.ts +++ b/apps/web/src/page/dashboards/list/list.scene.test.ts @@ -1,6 +1,17 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + Command, + expect as expectView, + expectOutMessage, + given, + role, + scene, + selector, + Subscription, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { PageOutMessage, type PageContext } from "../../contract" import { CreateDashboard, DeleteDashboard, SavePreferences, SaveTags } from "./command" @@ -8,7 +19,7 @@ import * as TagEditor from "../kit/tag-editor" import * as Combobox from "@maple/views/ui/combobox" import { Message } from "./message" import type { ListDashboard, Model } from "./model" -import { init, routeChanged, update } from "./update" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -228,3 +239,84 @@ describe("dashboards list", () => { ) }) }) + +describe("dashboards list keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("[data-focused]") + // Newest edit first: Payment, Platform, Checkout. + const boards = [ + dashboard({ updatedAt: "2026-09-29T12:00:00.000Z" }), + dashboard({ + id: "dash_payment", + name: "Payment service health", + updatedAt: "2026-10-01T11:00:00.000Z", + }), + dashboard({ id: "dash_checkout", name: "Checkout health", updatedAt: "2026-09-20T12:00:00.000Z" }), + ] + const pressed = (model: Model, ...keys: ReadonlyArray): Model => + Arr.reduce(keys, model, (next, key) => pageUpdate(next, Message.PressedListKey({ key })).model) + + test("J and K move the focus through the rows in their sorted order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded({}, boards)), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment service health"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Platform overview"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Checkout health"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Platform overview"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter follows the focused row's link", () => { + scene( + { update: pageUpdate, view }, + given(pressed(loaded({}, boards), "j", "j")), + expectView(role("link", { name: /Platform overview/ })).toHaveAttr( + "href", + "/dashboards/dash_platform", + ), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ url: "/dashboards/dash_platform", isReplace: false }), + ), + ) + }) + + test("starred rows come first, and the focus stays on its dashboard when a star moves it", () => { + const focused = pressed(loaded({}, boards), "j", "j", "j") + const starred = pageUpdate(focused, Message.ToggledFavorite({ id: "dash_checkout" })).model + scene( + { update: pageUpdate, view }, + given(starred), + expectView(focusedRow).toContainText("Checkout health"), + press("k"), + scrolled(), + // Already the first row: the starred section leads the list. + expectView(focusedRow).toContainText("Checkout health"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Payment service health"), + ) + }) + + test("a new search or sort starts the focus over", () => { + const focused = pressed(loaded({}, boards), "j") + expect(focused.focusedId).toBe("dash_payment") + expect(routeChanged(focused, { search: { q: "health" } }).model.focusedId).toBeNull() + expect(routeChanged(focused, { search: { sort: "name-asc" } }).model.focusedId).toBeNull() + }) +}) diff --git a/apps/web/src/page/dashboards/list/message.ts b/apps/web/src/page/dashboards/list/message.ts index 584b4ad5f1..76cc3738b3 100644 --- a/apps/web/src/page/dashboards/list/message.ts +++ b/apps/web/src/page/dashboards/list/message.ts @@ -35,6 +35,9 @@ export const Message = defineMessageUnion({ result: Schema.Result(Schema.Array(ListDashboard), Schema.String), }, ClickedRetrySync: {}, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ChangedSearchDraft: { value: Schema.String }, ElapsedSearchDebounce: { generation: Schema.Number }, ChangedScope: { scope: Scope }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index 4e4bf10c4a..1ef63ebe31 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -75,5 +75,7 @@ export const Model = Schema.Struct({ isCreating: Schema.Boolean, /** The live list never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ fallback: Fallback, + /** The keyboard-focused row, by id: the live list reorders under it. None until J or K is pressed. */ + focusedId: Schema.NullOr(Schema.String), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/dashboards/list/subscription.ts b/apps/web/src/page/dashboards/list/subscription.ts index 6245f2f09e..0e9093ecd7 100644 --- a/apps/web/src/page/dashboards/list/subscription.ts +++ b/apps/web/src/page/dashboards/list/subscription.ts @@ -7,6 +7,8 @@ import type { Resources } from "../../../platform/resources" import type { PageSubscriptionInput } from "../../contract" import { orgLive } from "../../../data/collections/org-collections" import { loadTimeout } from "../kit/live-sync" +import { listKeys } from "../../kit/list-keys" +import { focusableOf } from "./update" import { Message } from "./message" import { toListDashboard } from "./derive" import type { ListDashboard, Model } from "./model" @@ -37,6 +39,19 @@ export const subscriptions = Subscription.make, Mes dependenciesToStream: ({ orgId }) => dashboards(orgId), }, ), + /** The list's keys while it shows rows; an open menu keeps its arrows. */ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: focusableOf(model).length > 0 && !model.sortMenu.isOpen && !model.rowMenu.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), /** A live list still loading after the bounded wait has failed. */ loadWatchdog: entry( { orgId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/page/dashboards/list/update.ts b/apps/web/src/page/dashboards/list/update.ts index 47bae5aa09..93ba57755a 100644 --- a/apps/web/src/page/dashboards/list/update.ts +++ b/apps/web/src/page/dashboards/list/update.ts @@ -24,7 +24,8 @@ import { } from "./command" import { countLabel } from "@maple/ui/lib/format" import * as TagEditor from "../kit/tag-editor" -import { displayName, queryOf, tagsOf } from "./derive" +import { displayName, queryOf, sectionsOf, tagsOf } from "./derive" +import { focusedIndexOf, listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import * as Combobox from "@maple/views/ui/combobox" import { INITIAL_LOCALE } from "@maple/views/lib/locale" import { Message, type WriteFailure } from "./message" @@ -61,6 +62,7 @@ export const init = (route: Route, _context: PageContext): Return => ({ persistenceError: null, isCreating: false, fallback: { _tag: "Idle" }, + focusedId: null, }, commands: [ReadClock(), LoadPreferences()], }) @@ -72,6 +74,8 @@ export const routeChanged = (model: Model, route: Route): Return => { model: { ...model, search: route.search, + // A new search, tag, scope or sort is a new row set: the focus starts over. + focusedId: null, searchDraft: query === Str.trim(model.searchDraft) ? model.searchDraft : query, tagFilter: { ...model.tagFilter, selected: tagsOf(route.search) }, }, @@ -113,6 +117,31 @@ export const isLoadFailed = (model: Model): boolean => export const isReadOnly = (model: Model): boolean => model.persistenceError !== null || isDegraded(model) +/** The rows on screen, starred first: what the list keys step over. */ +export const focusableOf = (model: Model): ReadonlyArray => { + const listed = listedOf(model) + if (listed === null || model.nowMs === null || !model.isPrefsLoaded) return [] + const sections = sectionsOf(model, listed) + return [...sections.favorited, ...sections.others] +} + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollDashboardsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter follows the focused row's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const ids = Arr.map(focusableOf(model), (dashboard) => dashboard.id) + const action = listKeyAction(key, focusedIndexOf(ids, model.focusedId), ids.length) + if (action._tag === "Focus") { + const focusedId = action.index === null ? null : (ids[action.index] ?? null) + return { model: { ...model, focusedId }, commands: focusedId === null ? [] : [ScrollFocusedRow()] } + } + const id = action._tag === "Open" ? ids[action.index] : undefined + return id === undefined ? { model } : navigateTo(model, dashboardHref(id)) +} + const navigateWithToast = (model: Model, url: string, title: string): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ @@ -313,6 +342,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model } : navigateSearch(model, { q: trimmed === "" ? undefined : trimmed }) }, + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ChangedScope: ({ scope }) => navigateSearch(model, { scope: scope === "all" ? undefined : scope }), ClickedClearSearch: () => navigateSearch({ ...model, searchDraft: "" }, { q: undefined }), ClickedShowAllTags: () => navigateSearch(model, { tags: undefined }), diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index bb2b77b7f4..d7d559b318 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -15,6 +15,7 @@ import { import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "@maple/views/ui/row-focus" import { listSectionHeader, toolbarSearch } from "../kit/list-parts" import * as Menu from "@maple/views/ui/menu" import * as MenuView from "@maple/views/ui/menu-view" @@ -193,6 +194,7 @@ type RowContext = Readonly<{ model: Model; nowMs: number; timeZone: string }> /** One link for the whole row, then the star and the kebab above it. */ const dashboardRow = (h: H, context: RowContext, dashboard: ListDashboard, isFavorite: boolean): Html => { + const isFocused = context.model.focusedId === dashboard.id const isEmpty = dashboard.widgetCount === 0 const widgets = `${dashboard.widgetCount} ${pluralize(dashboard.widgetCount, "widget")}` const name = displayName(dashboard.name) @@ -200,8 +202,12 @@ const dashboardRow = (h: H, context: RowContext, dashboard: ListDashboard, isFav dashboard.id, [ h.Class( - "group relative flex items-center border-b border-border last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-within:bg-surface-sunken", + cn( + ROW_FOCUSED_CLASS, + "group relative flex items-center border-b border-border last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-within:bg-surface-sunken", + ), ), + ...rowFocusAttributes(h, isFocused), ], [ h.span( diff --git a/apps/web/src/page/errors/hub/selection.ts b/apps/web/src/page/errors/hub/selection.ts index 0c056fbff6..24ebb88be0 100644 --- a/apps/web/src/page/errors/hub/selection.ts +++ b/apps/web/src/page/errors/hub/selection.ts @@ -8,6 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import * as QuickPick from "../kit/quick-pick" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { SEVERITY_ORDER } from "../../kit/issue-severity" +import { listStepOf, steppedFocus } from "../../kit/list-keys" import { FetchPage, FetchServiceCounts, RunHubWrite } from "./command" import { signalsOf } from "./derive" import { Message } from "./message" @@ -73,15 +74,8 @@ export const pressedListKey = ( case "K": case "ArrowUp": { if (ids.length === 0) return { model } - const isDown = key === "j" || key === "J" || key === "ArrowDown" - const next = isDown - ? current < 0 - ? 0 - : Math.min(current + 1, ids.length - 1) - : current <= 0 - ? 0 - : current - 1 - return { model: { ...model, focusedId: ids[next] ?? model.focusedId } } + const next = steppedFocus(current, listStepOf(key) ?? 1, ids.length) + return { model: { ...model, focusedId: ids[next ?? -1] ?? model.focusedId } } } case "Enter": { const focused = ids[current] diff --git a/apps/web/src/page/investigations/list/derive.ts b/apps/web/src/page/investigations/list/derive.ts index 680320f96e..66833f133b 100644 --- a/apps/web/src/page/investigations/list/derive.ts +++ b/apps/web/src/page/investigations/list/derive.ts @@ -45,6 +45,12 @@ export const isLoadingMoreOf = (model: Model): boolean => { return last !== undefined && (AsyncData.isLoading(last.page.data) || AsyncData.isIdle(last.page.data)) } +/** The rows on screen once the first page has answered: what the list keys step over. */ +export const focusableOf = (model: Model): ReadonlyArray => + AsyncData.isSuccess(model.firstPage.data) || AsyncData.isRefreshing(model.firstPage.data) + ? visibleOf(model, allOf(model)) + : [] + export const visibleOf = (model: Model, all: ReadonlyArray) => { const { search } = model const view = viewOf(search) diff --git a/apps/web/src/page/investigations/list/index.ts b/apps/web/src/page/investigations/list/index.ts index 0deb1e72de..0e92e5b427 100644 --- a/apps/web/src/page/investigations/list/index.ts +++ b/apps/web/src/page/investigations/list/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { view } from "./view" @@ -14,6 +15,7 @@ export const page = definePage( update, routeChanged, view, + subscriptions, breadcrumbs: () => [{ label: "Investigations", isPageHeading: true }], }, ) diff --git a/apps/web/src/page/investigations/list/list-keys.scene.test.ts b/apps/web/src/page/investigations/list/list-keys.scene.test.ts new file mode 100644 index 0000000000..94abd5e0ae --- /dev/null +++ b/apps/web/src/page/investigations/list/list-keys.scene.test.ts @@ -0,0 +1,133 @@ +import { OrgId } from "@maple/domain" +import { V2Investigation } from "@maple/domain/http/v2" +import { Array as Arr, Result, Schema } from "effect" +import { + Command, + expect as expectView, + expectOutMessage, + given, + Mount, + scene, + selector, + Subscription, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { MeasureTabIndicator, Message as TabsMessage } from "@maple/views/ui/tabs" +import { VERDICT_LAB_CASES } from "../../../lab/verdict-fixture" +import { type PageContext, PageOutMessage } from "../../contract" +import { MountInvestigationRows } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" +import { view as pageView } from "./view" + +// J / K / arrows step through the rows on screen, Enter opens the focused one, Escape lets go. + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const base = Arr.getUnsafe(VERDICT_LAB_CASES, 0).investigation +const decodeId = Schema.decodeSync(V2Investigation.fields.id) + +/** A diagnosed (Active) investigation; a later `minute` sorts it higher. */ +const investigation = (n: number, title: string, minute: number): V2Investigation => ({ + ...base, + id: decodeId(`${n}${n}${n}${n}${n}${n}${n}${n}-1111-4111-8111-111111111111`), + snapshot: { ...base.snapshot, title }, + updated_at: `2026-09-18T09:${minute}:00.000Z`, +}) + +const newest = investigation(4, "Checkout latency regression", 30) +const older = investigation(5, "Search index lag", 20) +const oldest = investigation(6, "Export job retries", 10) + +const loaded = (): Model => { + const entered = init({ search: {} }, context).model + return pageUpdate( + entered, + Message.CompletedFetchFirstPage({ + key: entered.firstPage.key ?? "", + result: Result.succeed({ data: [oldest, newest, older], hasMore: false, nextCursor: null }), + }), + ).model +} + +const measured = () => + Mount.resolve( + MeasureTabIndicator, + TabsMessage.MeasuredIndicator({ + rect: { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 }, + }), + ) +const rowsMounted = () => Mount.resolve(MountInvestigationRows, Message.ClickedRow({ id: newest.id })) +const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) +const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) +const focusedRow = selector("[data-focused]") + +describe("investigations list keys", () => { + test("J and K move the focus through the rows in their sorted order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + measured(), + rowsMounted(), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Checkout latency regression"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("Search index lag"), + press("j"), + scrolled(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("Export job retries"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("Search index lag"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused investigation as a click on its row does", () => { + const focused = Arr.reduce( + ["j", "j"], + loaded(), + (model, key) => pageUpdate(model, Message.PressedListKey({ key })).model, + ) + const entered = pageUpdate(focused, Message.PressedListKey({ key: "Enter" })) + expect(entered.outMessage).toEqual( + pageUpdate(focused, Message.ClickedRow({ id: older.id })).outMessage, + ) + scene( + { update: pageUpdate, view }, + given(focused), + measured(), + rowsMounted(), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ url: `/investigations/${older.id}`, isReplace: false }), + ), + ) + }) + + test("Enter with no focused row opens nothing", () => { + expect(pageUpdate(loaded(), Message.PressedListKey({ key: "Enter" })).outMessage).toBeUndefined() + }) + + test("a new view, search or sort starts the focus over", () => { + const focused = pageUpdate(loaded(), Message.PressedListKey({ key: "j" })).model + expect(focused.focusedIndex).toBe(0) + expect(routeChanged(focused, { search: { q: "search" } }, context).model.focusedIndex).toBeNull() + expect(routeChanged(focused, { search: { view: "history" } }, context).model.focusedIndex).toBeNull() + expect(routeChanged(focused, { search: { dir: "asc" } }, context).model.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/investigations/list/message.ts b/apps/web/src/page/investigations/list/message.ts index 2dafe6dbd6..3979c70d9f 100644 --- a/apps/web/src/page/investigations/list/message.ts +++ b/apps/web/src/page/investigations/list/message.ts @@ -28,6 +28,9 @@ export const Message = defineMessageUnion({ ElapsedSearchDebounce: { generation: Schema.Number }, ClickedClearFilters: {}, ClickedRow: { id: Schema.String }, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, GotTabsMessage: { message: Tabs.Message }, GotKindSelectMessage: { message: Select.Message }, diff --git a/apps/web/src/page/investigations/list/model.ts b/apps/web/src/page/investigations/list/model.ts index 21b28fb134..72060938c1 100644 --- a/apps/web/src/page/investigations/list/model.ts +++ b/apps/web/src/page/investigations/list/model.ts @@ -49,5 +49,7 @@ export const Model = Schema.Struct({ tabs: Tabs.Model, kindSelect: Select.Model, sortSelect: Select.Model, + /** The keyboard-focused row; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/investigations/list/subscription.ts b/apps/web/src/page/investigations/list/subscription.ts new file mode 100644 index 0000000000..8750b97d79 --- /dev/null +++ b/apps/web/src/page/investigations/list/subscription.ts @@ -0,0 +1,23 @@ +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" +import { focusableOf } from "./derive" +import { Message } from "./message" +import type { Model } from "./model" + +// The list's keys, from anywhere on the page, while it shows rows. An open select keeps its arrows. + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: + focusableOf(model).length > 0 && !model.kindSelect.isOpen && !model.sortSelect.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) : Stream.empty, + }, + ), +})) diff --git a/apps/web/src/page/investigations/list/update.ts b/apps/web/src/page/investigations/list/update.ts index dec4b78ba0..a90b4b06f1 100644 --- a/apps/web/src/page/investigations/list/update.ts +++ b/apps/web/src/page/investigations/list/update.ts @@ -21,7 +21,9 @@ import { ReadInvestigationsGrants, WaitInvestigationsSearch, } from "./command" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { + focusableOf, isLoadingMoreOf, KIND_FILTER_VALUES, nextCursorOf, @@ -64,6 +66,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): grants: isClerkAuthEnabled ? [] : Policy.allPermissions, searchDraft: search.q ?? "", searchGeneration: 0, + focusedIndex: null, tabs: Tabs.init({ idBase: 0, values: VIEWS, selected: viewOf(search) }), kindSelect: Select.init("investigations-kind", { value: search.kind ?? "all", @@ -103,6 +106,8 @@ export const routeChanged = ( model: { ...model, search, + // A new view, filter, search or sort is a new row set: the focus starts over. + focusedIndex: null, searchDraft: (Str.trim(model.searchDraft) || undefined) === search.q ? model.searchDraft @@ -123,6 +128,32 @@ const navigate = (model: Model, search: Search): Return => ({ outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace: false }), }) +const openedRow = (model: Model, id: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: hrefOf(AppRoute.Investigation({ id, search: {} })), + isReplace: false, + }), +}) + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollInvestigationsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter opens the focused row exactly as a click on it does. */ +const pressedListKey = (model: Model, key: string): Return => { + const rows = focusableOf(model) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedRow(model, row.id) +} + const isKind = (value: string): value is NonNullable => Arr.some(KIND_FILTER_VALUES, (kind) => kind !== "all" && kind === value) @@ -222,13 +253,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re : { model } }, ClickedClearFilters: () => navigate(model, { ...model.search, kind: undefined, q: undefined }), - ClickedRow: ({ id }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: hrefOf(AppRoute.Investigation({ id, search: {} })), - isReplace: false, - }), - }), + ClickedRow: ({ id }) => openedRow(model, id), + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), CompletedLoadSignalPresence: ({ result }) => ({ model: { ...model, presence: AsyncData.settle(model.presence, result) }, }), diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index f81184a2c3..e64d512d68 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -245,7 +245,13 @@ const rowFinding = (h: H, investigation: V2Investigation): Html => { ) } -const row = (h: H, investigation: V2Investigation, nowMs: number, timeZone: string): Html => { +const row = ( + h: H, + investigation: V2Investigation, + nowMs: number, + timeZone: string, + isFocused: boolean, +): Html => { const headline = investigationHeadline(investigation) const scope = investigationScope(investigation) return listRow( @@ -253,6 +259,7 @@ const row = (h: H, investigation: V2Investigation, nowMs: number, timeZone: stri { as: "li", divided: true, + isFocused, className: "cursor-pointer gap-3.5 py-3.5", leading: hstack(h, { align: "center", justify: "center", as: "span", className: "w-5" }, [ kindMarker(h, investigation.subject, investigation.seeded_by), @@ -565,7 +572,9 @@ export const view = Submodel.defineView((model, vstack( h, { as: "ul", attributes: [h.OnMount(MountInvestigationRows())] }, - Arr.map(visible, (investigation) => row(h, investigation, model.nowMs, timeZone)), + Arr.map(visible, (investigation, index) => + row(h, investigation, model.nowMs, timeZone, model.focusedIndex === index), + ), ), ...(hasMore ? [ diff --git a/apps/web/src/page/kit/grid-list-row.ts b/apps/web/src/page/kit/grid-list-row.ts index 3c74c56e55..d4b91d56b1 100644 --- a/apps/web/src/page/kit/grid-list-row.ts +++ b/apps/web/src/page/kit/grid-list-row.ts @@ -1,19 +1,33 @@ import { cn } from "@maple/ui/lib/utils" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "@maple/views/ui/row-focus" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" /** * One `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on - * hover and press, positioned so a stretched row link covers it. + * hover and press, positioned so a stretched row link covers it. `isFocused` marks the list + * page's keyboard-focused row. */ export const gridListRow = ( h: HtmlBuilder, - options: Readonly<{ key: string; grid: string; attributes?: ReadonlyArray> }>, + options: Readonly<{ + key: string + grid: string + isFocused?: boolean + attributes?: ReadonlyArray> + }>, children: ReadonlyArray, ): Html => h.li( [ h.Key(options.key), - h.Class(cn(options.grid, "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken")), + h.Class( + cn( + options.grid, + ROW_FOCUSED_CLASS, + "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken", + ), + ), + ...rowFocusAttributes(h, options.isFocused), ...(options.attributes ?? []), ], children, diff --git a/apps/web/src/page/kit/list-keys.test.ts b/apps/web/src/page/kit/list-keys.test.ts new file mode 100644 index 0000000000..fc4c7765c0 --- /dev/null +++ b/apps/web/src/page/kit/list-keys.test.ts @@ -0,0 +1,63 @@ +import { Array as Arr } from "effect" +import { describe, expect, test } from "vitest" +import { focusedIndexOf, listKeyAction, listStepOf, steppedFocus } from "./list-keys" + +describe("steppedFocus", () => { + test("no focus goes to the first row in either direction", () => { + expect(steppedFocus(null, 1, 3)).toBe(0) + expect(steppedFocus(null, -1, 3)).toBe(0) + }) + + test("a step moves one row and holds at both ends", () => { + expect(steppedFocus(0, 1, 3)).toBe(1) + expect(steppedFocus(2, 1, 3)).toBe(2) + expect(steppedFocus(1, -1, 3)).toBe(0) + expect(steppedFocus(0, -1, 3)).toBe(0) + }) + + test("a focus past a shrunken list comes back to its last row", () => { + expect(steppedFocus(9, 1, 3)).toBe(2) + expect(steppedFocus(9, -1, 3)).toBe(2) + }) + + test("an empty list keeps what it had", () => { + expect(steppedFocus(null, 1, 0)).toBeNull() + expect(steppedFocus(4, -1, 0)).toBe(4) + }) +}) + +describe("listStepOf", () => { + test("J and the down arrow step forward, K and the up arrow back, anything else not at all", () => { + expect(Arr.map(["j", "J", "ArrowDown"], listStepOf)).toEqual([1, 1, 1]) + expect(Arr.map(["k", "K", "ArrowUp"], listStepOf)).toEqual([-1, -1, -1]) + expect(Arr.map(["Enter", "Escape", "x"], listStepOf)).toEqual([null, null, null]) + }) +}) + +describe("listKeyAction", () => { + test("a step key moves the focus, and does nothing over no rows", () => { + expect(listKeyAction("j", null, 2)).toEqual({ _tag: "Focus", index: 0 }) + expect(listKeyAction("ArrowDown", 0, 2)).toEqual({ _tag: "Focus", index: 1 }) + expect(listKeyAction("k", 1, 2)).toEqual({ _tag: "Focus", index: 0 }) + expect(listKeyAction("j", null, 0)).toEqual({ _tag: "None" }) + }) + + test("Enter opens the focused row, and nothing without one or past the rows", () => { + expect(listKeyAction("Enter", 1, 2)).toEqual({ _tag: "Open", index: 1 }) + expect(listKeyAction("Enter", null, 2)).toEqual({ _tag: "None" }) + expect(listKeyAction("Enter", 5, 2)).toEqual({ _tag: "None" }) + }) + + test("Escape clears the focus", () => { + expect(listKeyAction("Escape", 1, 2)).toEqual({ _tag: "Focus", index: null }) + }) +}) + +describe("focusedIndexOf", () => { + test("the focused id's place among the rows now, none once it left", () => { + expect(focusedIndexOf(["a", "b", "c"], "c")).toBe(2) + expect(focusedIndexOf(["c", "a"], "c")).toBe(0) + expect(focusedIndexOf(["a", "b"], "gone")).toBeNull() + expect(focusedIndexOf(["a", "b"], null)).toBeNull() + }) +}) diff --git a/apps/web/src/page/kit/list-keys.ts b/apps/web/src/page/kit/list-keys.ts new file mode 100644 index 0000000000..441d6ed903 --- /dev/null +++ b/apps/web/src/page/kit/list-keys.ts @@ -0,0 +1,115 @@ +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" +import { LIST_ROW_SELECTOR } from "@maple/views/ui/row-focus" +import { Array as Arr, Effect, Option, type Stream } from "effect" +import { Command, Dom, Render } from "foldkit" +import type { CallableTaggedStruct } from "foldkit/schema" + +// Keyboard row navigation for a list page: the document key stream (J, K, the arrows, Enter and Escape), the focus step and the scroll that keeps the focused row in view. + +const NAVIGATION_KEYS = ["j", "J", "k", "K", "ArrowDown", "ArrowUp", "Enter", "Escape"] + +/** A focused control keeps its own Enter. */ +const keepsEnter = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false + const control = target.closest("a, button, [role='tab'], [role='menuitem'], [role='option'], tr") + return control instanceof HTMLElement && control.tabIndex >= 0 +} + +export type ListKeyOptions = Readonly<{ + toMessage: (key: string) => M + /** The page's own keys beside the navigation set. */ + extraKeys?: ReadonlyArray + /** Whether something else owns this key right now; by default an open dialog owns them all. */ + isBlocked?: (key: string) => boolean +}> + +/** + * A row that holds DOM focus (from a Tab or a click) keeps Enter for itself, so a step would leave + * two focused rows and Enter on the wrong one. Stepping hands the list the only focus. + */ +const releaseRowFocus = (): void => { + const active = document.activeElement + if (active instanceof HTMLElement && active.closest(LIST_ROW_SELECTOR) !== null) active.blur() +} + +/** The list's keys from anywhere on the page, while no field has focus and no modifier is held. */ +export const listKeys = (options: ListKeyOptions): Stream.Stream => { + const keys = new Set([...NAVIGATION_KEYS, ...(options.extraKeys ?? [])]) + const isBlocked = options.isBlocked ?? isDialogOpen + // A page stops this stream while its own select is open and starts it again as the select closes: + // the Escape that closed it is still on its way up to the document, and is not the list's. + const startedAt = performance.now() + return Dom.streamFromEventFilterMapPreventDefault({ + target: () => document, + type: "keydown", + filterMapEvent: (event) => { + const key = pressedKey(event) + if (!keys.has(key) || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) + return Option.none() + if (event.timeStamp < startedAt || isEditableTarget(event.target)) return Option.none() + if (key === "Enter" && keepsEnter(event.target)) return Option.none() + if (isBlocked(key)) return Option.none() + if (listStepOf(key) !== null) releaseRowFocus() + return Option.some(options.toMessage(key)) + }, + }) +} + +/** What a navigation key steps by: down and J forward, up and K back. */ +export const listStepOf = (key: string): 1 | -1 | null => + key === "j" || key === "J" || key === "ArrowDown" + ? 1 + : key === "k" || key === "K" || key === "ArrowUp" + ? -1 + : null + +/** The focus after a step over `count` rows: none goes to the first row, both ends hold. */ +export const steppedFocus = (current: number | null, step: 1 | -1, count: number): number | null => { + if (count <= 0) return current + if (current === null || current < 0) return 0 + return Math.max(0, Math.min(current + step, count - 1)) +} + +/** What a list key asks of the page that pressed it. */ +export type ListKeyAction = + | Readonly<{ _tag: "Focus"; index: number | null }> + | Readonly<{ _tag: "Open"; index: number }> + | Readonly<{ _tag: "None" }> + +/** J / K / arrows move the focus, Enter opens the focused row, Escape clears it. */ +export const listKeyAction = (key: string, focusedIndex: number | null, count: number): ListKeyAction => { + const step = listStepOf(key) + if (step !== null) + return count > 0 + ? { _tag: "Focus", index: steppedFocus(focusedIndex, step, count) } + : { _tag: "None" } + if (key === "Enter" && focusedIndex !== null && focusedIndex < count) + return { _tag: "Open", index: focusedIndex } + return key === "Escape" ? { _tag: "Focus", index: null } : { _tag: "None" } +} + +/** A live list reorders under its focus, so it keeps the row's id: that id's place among the rows now. */ +export const focusedIndexOf = (ids: ReadonlyArray, focusedId: string | null): number | null => + focusedId === null ? null : Option.getOrNull(Arr.findFirstIndex(ids, (id) => id === focusedId)) + +const FOCUSED_ROW = "[data-focused]" + +/** A page's scroll that keeps its focused row in view, under its own command name. */ +export const scrollFocusedRowCommand = ( + name: Name, + completed: CallableTaggedStruct>, +) => + Command.define(name, { + messages: [completed], + // After the commit that marks the row. A page that draws its rows twice (a table, and a list + // below `md`) marks both, so the one with a box is the one to scroll; none has nothing to do. + execute: Effect.gen(function* () { + yield* Render.afterCommit + const visible = Arr.findFirst( + [...document.querySelectorAll(FOCUSED_ROW)], + (row) => row.getClientRects().length > 0, + ) + if (Option.isSome(visible)) visible.value.scrollIntoView({ block: "nearest" }) + return completed() + }), + }) diff --git a/apps/web/src/page/metrics/browse/browse.scene.test.ts b/apps/web/src/page/metrics/browse/browse.scene.test.ts index bd7480f27a..f86b4719a1 100644 --- a/apps/web/src/page/metrics/browse/browse.scene.test.ts +++ b/apps/web/src/page/metrics/browse/browse.scene.test.ts @@ -1,13 +1,26 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr, Record as Rec } from "effect" -import { expect as expectView, given, label, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { + Command, + expect as expectView, + expectOutMessage, + given, + label, + Mount, + role, + scene, + selector, + Subscription, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { PageOutMessage, type PageContext } from "../../contract" import type { MetricRow } from "../shared" import { Message } from "./message" import type { Model } from "./model" -import { init, routeChanged, update } from "./update" +import { init, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -197,3 +210,72 @@ describe("metrics explorer", () => { ) }) }) + +describe("metrics table keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("[data-focused]") + const rows = [row("http.server.duration", "histogram"), row("process.cpu.time", "sum")] + + /** The table view with its rows in. */ + const tableLoaded = (): Model => { + const model = windowed({ view: "table" }) + return pageUpdate( + model, + Message.CompletedFetchTable({ key: model.table.key ?? "", result: Result.succeed(rows) }), + ).model + } + + test("J and K move the focus through the table rows, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(tableLoaded()), + themeResolved(), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("http.server.duration"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("process.cpu.time"), + press("ArrowUp"), + scrolled(), + expectView(focusedRow).toContainText("http.server.duration"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + ) + }) + + test("Enter follows the focused row's link", () => { + scene( + { update: pageUpdate, view }, + given(tableLoaded()), + themeResolved(), + press("j"), + scrolled(), + expectView(role("link", { name: "http.server.duration" })).toHaveAttr( + "href", + "/metrics/http.server.duration?type=histogram", + ), + press("Enter"), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/metrics/http.server.duration?type=histogram", + isReplace: false, + }), + ), + ) + }) + + test("the grid view has no rows to step over", () => { + const pressed = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })) + expect(pressed.model.focusedIndex).toBeNull() + }) + + test("a new search starts the focus over", () => { + const focused = pageUpdate(tableLoaded(), Message.PressedListKey({ key: "j" })).model + expect(focused.focusedIndex).toBe(0) + const searched = routeChanged(focused, { search: { view: "table", q: "cpu" } }, context).model + expect(searched.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/metrics/browse/index.ts b/apps/web/src/page/metrics/browse/index.ts index ed3bd2e361..3250a01a7f 100644 --- a/apps/web/src/page/metrics/browse/index.ts +++ b/apps/web/src/page/metrics/browse/index.ts @@ -1,6 +1,7 @@ import { definePage } from "../../contract" import { Message } from "./message" import { Model } from "./model" +import { subscriptions } from "./subscription" import { contextChanged, init, routeChanged, update } from "./update" import { view } from "./view" @@ -15,6 +16,7 @@ export const page = definePage( routeChanged, contextChanged, view, + subscriptions, breadcrumbs: () => [{ label: "Metrics", isPageHeading: true }], }, ) diff --git a/apps/web/src/page/metrics/browse/message.ts b/apps/web/src/page/metrics/browse/message.ts index 01c3eb7915..0655cb3fdc 100644 --- a/apps/web/src/page/metrics/browse/message.ts +++ b/apps/web/src/page/metrics/browse/message.ts @@ -31,6 +31,9 @@ export const Message = defineMessageUnion({ ClickedLoadMorePages: {}, ClickedLoadMoreTable: {}, ClickedMetricCard: { metricName: Schema.String, metricType: Schema.String }, + /** A list key over the table: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ClickedClearFilters: {}, CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, ...ChartEnvMessages, diff --git a/apps/web/src/page/metrics/browse/model.ts b/apps/web/src/page/metrics/browse/model.ts index 2484352c27..48e599507b 100644 --- a/apps/web/src/page/metrics/browse/model.ts +++ b/apps/web/src/page/metrics/browse/model.ts @@ -59,5 +59,7 @@ export const Model = Schema.Struct({ ...ChartEnvFields, refreshVersion: Schema.Number, isReloading: Schema.Boolean, + /** The table's keyboard-focused row; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/metrics/browse/subscription.ts b/apps/web/src/page/metrics/browse/subscription.ts new file mode 100644 index 0000000000..5e37fc5d06 --- /dev/null +++ b/apps/web/src/page/metrics/browse/subscription.ts @@ -0,0 +1,22 @@ +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" +import { Message } from "./message" +import type { Model } from "./model" +import { tableRows } from "./update" + +// The table view's list keys, from anywhere on the page; the grid has none. An open select keeps its arrows. + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + hasRows: tableRows(model).length > 0 && !model.typeSelect.isOpen, + }), + dependenciesToStream: ({ hasRows }) => + hasRows ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) : Stream.empty, + }, + ), +})) diff --git a/apps/web/src/page/metrics/browse/update.ts b/apps/web/src/page/metrics/browse/update.ts index 51c9e5ca2c..2a7087da84 100644 --- a/apps/web/src/page/metrics/browse/update.ts +++ b/apps/web/src/page/metrics/browse/update.ts @@ -17,7 +17,8 @@ import { SignalPresenceData } from "@maple/views/data/signal-presence" import { routeWindowStep, snapModeOf } from "../../kit/route-window" import * as TimeRangePicker from "../../kit/time-range-picker" import type { PresetValue } from "../../kit/time-controls" -import { SPARKLINE_METRIC_TYPES } from "../shared" +import { type MetricRow, SPARKLINE_METRIC_TYPES } from "../shared" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { CompleteReloadSpin, DebounceSearch, @@ -130,7 +131,7 @@ const requestAll = (model: Model, context: PageContext, window: TimeWindow, mode /** A new search, type or window remounts the grid or table. */ const remounted = (model: Model, isNewQuery: boolean): Model => { - const reset = { ...model, visiblePages: 1, tableLimit: TABLE_PAGE_SIZE } + const reset = { ...model, visiblePages: 1, tableLimit: TABLE_PAGE_SIZE, focusedIndex: null } // A new window keeps the previous rows on screen while it loads. return isNewQuery ? { ...reset, catalog: Keyed.idle(), table: Keyed.idle() } : reset } @@ -160,6 +161,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): sparklines: {}, table: Keyed.idle(), tableLimit: TABLE_PAGE_SIZE, + focusedIndex: null, summary: Keyed.idle(), searchInput: route.search.q ?? "", searchGeneration: 0, @@ -231,6 +233,38 @@ const metricHref = (model: Model, metricName: string, metricType: string) => /** The table's row link: the page's own time params plus the metric's type. */ export const metricLink = metricHref +const openedMetric = (model: Model, metricName: string, metricType: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: metricHref(model, metricName, metricType), + isReplace: false, + }), +}) + +/** The table's rows on screen (none in the grid view): what the list keys step over. */ +export const tableRows = (model: Model): ReadonlyArray => + (model.search.view ?? "grid") === "table" + ? Option.getOrElse(AsyncData.getData(model.table.data), () => []).slice(0, model.tableLimit) + : [] + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollMetricsFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter follows the focused row's link. */ +const pressedListKey = (model: Model, key: string): Return => { + const rows = tableRows(model) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedMetric(model, row.metricName, row.metricType) +} + const liftTypeSelect = (model: Model, message: Select.Message): Return => { const next = Select.update(model.typeSelect, message) const lifted: Return = { @@ -320,13 +354,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re const next = { ...model, tableLimit: model.tableLimit + TABLE_PAGE_SIZE } return next.window === null ? { model: next } : requestAll(next, context, next.window, "Load") }, - ClickedMetricCard: ({ metricName, metricType }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: metricHref(model, metricName, metricType), - isReplace: false, - }), - }), + ClickedMetricCard: ({ metricName, metricType }) => openedMetric(model, metricName, metricType), + PressedListKey: ({ key }) => pressedListKey(model, key), + CompletedScrollFocusedRow: () => ({ model }), ClickedClearFilters: () => patchSearch(model, { ...model.search, q: undefined, type: undefined }), CompletedLoadSignalPresence: ({ result }) => ({ model: { ...model, presence: AsyncData.settle(model.presence, result) }, diff --git a/apps/web/src/page/metrics/browse/view-table.ts b/apps/web/src/page/metrics/browse/view-table.ts index eee1d3a380..d11a6ddd7b 100644 --- a/apps/web/src/page/metrics/browse/view-table.ts +++ b/apps/web/src/page/metrics/browse/view-table.ts @@ -15,6 +15,7 @@ import { tableRow, } from "@maple/views/ui/table" import { tableSkeleton } from "@maple/views/ui/table-skeleton" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { truncatedText } from "@maple/views/ui/truncated-text" import { emptyValue, serviceDot } from "@maple/views/ui/values" import { metricTypeBadge } from "@maple/views/ui/metric-type" @@ -64,7 +65,7 @@ export const loadingTable = (h: H): Html => tableClassName: "table-fixed", }) -const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string): Html => +const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string, isFocused: boolean): Html => tableRow( h, [ @@ -126,6 +127,7 @@ const metricRow = (h: H, model: Model, metric: MetricRow, timeZone: string): Htm ), ], "hover:bg-surface-sunken active:bg-surface-sunken", + rowFocusAttributes(h, isFocused), ) /** The loaded table: rows up to `tableLimit`, dimmed while the next page loads. */ @@ -156,7 +158,9 @@ export const metricsTable = ( ]), tableBody( h, - Arr.map(metrics, (metric) => metricRow(h, model, metric, timeZone)), + Arr.map(metrics, (metric, index) => + metricRow(h, model, metric, timeZone, model.focusedIndex === index), + ), ), ]), ], diff --git a/apps/web/src/page/services/list/derive.ts b/apps/web/src/page/services/list/derive.ts index b7263bcd06..e5983dae43 100644 --- a/apps/web/src/page/services/list/derive.ts +++ b/apps/web/src/page/services/list/derive.ts @@ -141,6 +141,15 @@ export const groupServices = (services: ReadonlyArray, mode: GroupBy ) : [[NO_NAMESPACE, groupByEnvironment(services)]] +/** The rows in the order the table draws them, group by group: what the list keys step over. */ +export const orderedServices = ( + services: ReadonlyArray, + mode: GroupBy, +): ReadonlyArray => + Arr.flatMap(groupServices(services, mode), ([, environments]) => + Arr.flatMap(environments, ([, rows]) => rows), + ) + const MIN_DEPLOY_COMPARE_SPANS = 50 const ROLLOUT_MIN_SHARE_PCT = 5 diff --git a/apps/web/src/page/services/list/message.ts b/apps/web/src/page/services/list/message.ts index 45e2c86ad7..277293b0ec 100644 --- a/apps/web/src/page/services/list/message.ts +++ b/apps/web/src/page/services/list/message.ts @@ -62,6 +62,9 @@ export const Message = defineMessageUnion({ ClickedClearNamespacePin: {}, RemovedFilterChip: { param: FilterParam }, ClickedServiceRow: { serviceName: Schema.String, environment: Schema.String }, + /** A list key from the page: J / K / arrows, Enter, Escape. */ + PressedListKey: { key: Schema.String }, + CompletedScrollFocusedRow: {}, ClickedServiceLink: { url: Schema.String }, GotFilterSectionsMessage: { message: FilterSections.Message }, GotSparklineMessage: { message: Chart.Message }, diff --git a/apps/web/src/page/services/list/model.ts b/apps/web/src/page/services/list/model.ts index 50148800ac..4636ce08d5 100644 --- a/apps/web/src/page/services/list/model.ts +++ b/apps/web/src/page/services/list/model.ts @@ -94,5 +94,7 @@ export const Model = Schema.Struct({ /** Reloads so far (non-zero stops snapping) and the 600 ms spinner. */ refreshVersion: Schema.Number, isReloading: Schema.Boolean, + /** The keyboard-focused row, by its place in the table's order; none until J or K is pressed. */ + focusedIndex: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/services/list/services.scene.test.ts b/apps/web/src/page/services/list/services.scene.test.ts index 4223d4d2ae..8cf1100841 100644 --- a/apps/web/src/page/services/list/services.scene.test.ts +++ b/apps/web/src/page/services/list/services.scene.test.ts @@ -3,6 +3,7 @@ import { Result, Schema, Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import { click, + Command, expect as expectView, expectOutMessage, given, @@ -10,6 +11,8 @@ import { Mount, role, scene, + selector, + Subscription, text, withViewInputs, } from "foldkit/scene" @@ -28,7 +31,7 @@ import { } from "./command" import { Message } from "./message" import type { Model, ServiceRow } from "./model" -import { init, overviewInput, routeChanged, update } from "./update" +import { init, overviewInput, routeChanged, ScrollFocusedRow, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -297,3 +300,66 @@ describe("services list", () => { ) }) }) + +describe("services list keys", () => { + const press = (key: string) => Subscription.emit(Message.PressedListKey({ key })) + const scrolled = () => Command.resolve(ScrollFocusedRow, Message.CompletedScrollFocusedRow()) + const focusedRow = selector("tr[data-focused]") + // Below `md` the same rows draw as a list of links; it marks the same one. + const focusedListRow = selector("a[data-focused]") + // Grouped by environment, production first: checkout, then the staging cart. + const rows = [row("cart", "staging"), row("checkout")] + + test("J and K move the focus through the rows in the table's order, and Escape clears it", () => { + scene( + { update: pageUpdate, view }, + given(loaded(rows)), + themeResolved(), + Mount.resolve(MountTable, Message.PaintedTable()), + expectView(focusedRow).toBeAbsent(), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("checkout"), + expectView(focusedListRow).toContainText("checkout"), + press("ArrowDown"), + scrolled(), + expectView(focusedRow).toContainText("cart"), + expectView(focusedListRow).toContainText("cart"), + press("j"), + scrolled(), + expectView(focusedRow).toContainText("cart"), + press("k"), + scrolled(), + expectView(focusedRow).toContainText("checkout"), + press("Escape"), + expectView(focusedRow).toBeAbsent(), + expectView(focusedListRow).toBeAbsent(), + ) + }) + + test("Enter opens the focused service as a click on its row does", () => { + const focused = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })).model + const clicked = pageUpdate( + focused, + Message.ClickedServiceRow({ serviceName: "checkout", environment: "production" }), + ) + const entered = pageUpdate(focused, Message.PressedListKey({ key: "Enter" })) + expect(entered.outMessage).toEqual(clicked.outMessage) + expect(entered.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/services/checkout?environments=%5B%22production%22%5D", + isReplace: false, + }), + ) + }) + + test("Enter with no focused row opens nothing", () => { + expect(pageUpdate(loaded(rows), Message.PressedListKey({ key: "Enter" })).outMessage).toBeUndefined() + }) + + test("a new filter starts the focus over", () => { + const focused = pageUpdate(loaded(rows), Message.PressedListKey({ key: "j" })).model + const filtered = routeChanged(focused, { search: { environments: ["staging"] } }, context).model + expect(filtered.focusedIndex).toBeNull() + }) +}) diff --git a/apps/web/src/page/services/list/subscription.ts b/apps/web/src/page/services/list/subscription.ts index e33e733eed..0fea233817 100644 --- a/apps/web/src/page/services/list/subscription.ts +++ b/apps/web/src/page/services/list/subscription.ts @@ -1,13 +1,27 @@ -import { Schema } from "effect" +import { Schema, Stream } from "effect" import { AsyncData, Subscription } from "foldkit" import type { Resources } from "../../../platform/resources" import type { PageSubscriptionInput } from "../../contract" import { incidentsLoadFailedStream, incidentsStream } from "../../kit/service-health" import { Message } from "./message" import type { Model } from "./model" +import { listKeys } from "../../kit/list-keys" +import { focusableServices } from "./update" export const subscriptions = Subscription.make, Message, Resources>()( (entry) => ({ + listKeys: entry( + { hasRows: Schema.Boolean }, + { + modelToDependencies: ({ model, context }) => ({ + hasRows: focusableServices(model, context).length > 0, + }), + dependenciesToStream: ({ hasRows }) => + hasRows + ? listKeys({ toMessage: (key) => Message.PressedListKey({ key }) }) + : Stream.empty, + }, + ), incidents: entry( { orgId: Schema.String }, { diff --git a/apps/web/src/page/services/list/update.ts b/apps/web/src/page/services/list/update.ts index 94ec04e971..810991f2cb 100644 --- a/apps/web/src/page/services/list/update.ts +++ b/apps/web/src/page/services/list/update.ts @@ -28,10 +28,19 @@ import { type OverviewInput, ResolveWindow, } from "./command" -import { commitsKey, displayedServices, healthSummary, isServiceHealth, type Search } from "./derive" +import { + commitsKey, + displayedServices, + healthSummary, + isServiceHealth, + orderedServices, + resolveGroupBy, + type Search, +} from "./derive" +import { listKeyAction, scrollFocusedRowCommand } from "../../kit/list-keys" import { type Facet, Message } from "./message" import { editFacet, excludedValue, onlyValue, toggledValue } from "../../kit/facets" -import type { Model } from "./model" +import type { Model, ServiceRow } from "./model" import { anomaliesKey, failedIncidents, IncidentsData, receivedIncidents } from "../../kit/service-health" import { clickedCopySha, @@ -208,6 +217,43 @@ const serviceHref = (model: Model, serviceName: string, environment: string): st }), ) +const openedService = (model: Model, serviceName: string, environment: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: serviceHref(model, serviceName, environment), + isReplace: false, + }), +}) + +/** The rows the table draws, in its order: what the list keys step over. */ +export const focusableServices = (model: Model, context: PageContext): ReadonlyArray => { + const overview = AsyncData.getData(model.overview.data) + if (Option.isNone(overview)) return [] + const services = displayedServices(overview.value, summaryOf(model), model.search) + return orderedServices( + services, + resolveGroupBy(model.search.groupBy, services, context.pinnedNamespace !== null), + ) +} + +export const ScrollFocusedRow = scrollFocusedRowCommand( + "ScrollServicesFocusedRow", + Message.CompletedScrollFocusedRow, +) + +/** Enter opens the focused row exactly as a click on it does. */ +const pressedListKey = (model: Model, key: string, context: PageContext): Return => { + const rows = focusableServices(model, context) + const action = listKeyAction(key, model.focusedIndex, rows.length) + if (action._tag === "Focus") + return { + model: { ...model, focusedIndex: action.index }, + commands: action.index === null ? [] : [ScrollFocusedRow()], + } + const row = action._tag === "Open" ? rows[action.index] : undefined + return row === undefined ? { model } : openedService(model, row.serviceName, row.environment) +} + const navigate = (model: Model, search: Search): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ @@ -240,6 +286,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): nowMs: 0, refreshVersion: 0, isReloading: false, + focusedIndex: null, overview: Keyed.idle(), sparklines: Keyed.idle(), rowRevision: 0, @@ -283,6 +330,7 @@ export const revive = (model: Model, route: { readonly search: Search }, context search: route.search, refreshVersion: 0, isReloading: false, + focusedIndex: null, rowRevision: model.rowRevision + 1, overview: Keyed.forgetFailure(model.overview), sparklines: Keyed.forgetFailure(model.sparklines), @@ -334,7 +382,8 @@ export const routeChanged = ( context: PageContext, ): Return => { if (searchHref(model.search) === searchHref(route.search)) return { model } - const next = { ...model, search: route.search, rowRevision: model.rowRevision + 1 } + // A new search is a new row set: the focus starts over. + const next = { ...model, search: route.search, rowRevision: model.rowRevision + 1, focusedIndex: null } // Re-resolves only on a window change; filters and grouping keep the window. if (!isWindowChange(model.search, route.search) && model.window !== null) return followData(context)(requestAll(next, context, model.window, "Load")) @@ -445,13 +494,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re navigate(model, { ...windowOnly(model.search), groupBy: model.search.groupBy }), ClickedGroupBy: ({ groupBy }) => groupBy === model.search.groupBy ? { model } : navigate(model, { ...model.search, groupBy }), - ClickedServiceRow: ({ serviceName, environment }) => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: serviceHref(model, serviceName, environment), - isReplace: false, - }), - }), + ClickedServiceRow: ({ serviceName, environment }) => openedService(model, serviceName, environment), + PressedListKey: ({ key }) => pressedListKey(model, key, context), + CompletedScrollFocusedRow: () => ({ model }), ClickedServiceLink: ({ url }) => ({ model, outMessage: PageOutMessage.RequestedNavigation({ url, isReplace: false }), diff --git a/apps/web/src/page/services/list/view-table.ts b/apps/web/src/page/services/list/view-table.ts index 8295e61805..2e31e2eed8 100644 --- a/apps/web/src/page/services/list/view-table.ts +++ b/apps/web/src/page/services/list/view-table.ts @@ -23,6 +23,7 @@ import { skeleton } from "@maple/views/ui/skeleton" import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "@maple/views/ui/table" import { tableSkeleton } from "@maple/views/ui/table-skeleton" import { errorRateValue, latencyValue, serviceDot } from "@maple/views/ui/values" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { signalEmptyState } from "../../kit/signal-empty-state" import { presenceOf } from "@maple/views/data/signal-presence" import { @@ -36,6 +37,7 @@ import { type HealthSummary, healthOf, NO_NAMESPACE, + orderedServices, resolveGroupBy, type ServiceHealth, } from "./derive" @@ -197,6 +199,7 @@ const serviceRow = ( subtitle: string, baselines: ReturnType | undefined, groupPath: string, + isFocused: boolean, ): Html => { const health = healthOf(ctx.summary, service) const series = FoldkitAsyncData.getData(ctx.model.sparklines.data).pipe( @@ -345,6 +348,7 @@ const serviceRow = ( ), [ h.Key(rowKey), + ...rowFocusAttributes(h, isFocused), h.Tabindex(0), h.OnClick(open), h.OnKeyDownPreventDefault((key) => @@ -423,6 +427,8 @@ const desktopTable = ( Option.map(FoldkitAsyncData.getData(ctx.model.baseline.data), baselineMapOf), ) const subtitleFor = (service: ServiceRow) => (mode === "namespace" ? "" : service.serviceNamespace) + const focused = + ctx.model.focusedIndex === null ? undefined : orderedServices(services, mode)[ctx.model.focusedIndex] const body = services.length === 0 ? [tableRow(h, [tableCell(h, [emptyState(h, ctx)], "p-0", [h.Attribute("colspan", "7")])])] @@ -472,6 +478,7 @@ const desktopTable = ( subtitleFor(service), baselines, `${namespace}/${environment}`, + service === focused, ), ), ]), @@ -498,8 +505,10 @@ const mobileList = ( ctx: TableContext, services: ReadonlyArray, mode: "namespace" | "environment", -): Html => - h.div( +): Html => { + const focused = + ctx.model.focusedIndex === null ? undefined : orderedServices(services, mode)[ctx.model.focusedIndex] + return h.div( [h.Class("overflow-hidden rounded-md border md:hidden")], services.length === 0 ? [emptyState(h, ctx)] @@ -557,6 +566,7 @@ const mobileList = ( h, { isSelected: false, + isFocused: service === focused, href: serviceHref(ctx.model, service), className: "flex min-h-11 items-center justify-between gap-3 border-b px-3 py-2.5 last:border-b-0 hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", @@ -693,6 +703,7 @@ const mobileList = ( ), ), ) +} const SKELETON_COLUMNS = [ { header: "Service", skeleton: "w-32" }, diff --git a/apps/web/src/page/traces/list/subscription.ts b/apps/web/src/page/traces/list/subscription.ts index 81f7a830b9..e509aa9f82 100644 --- a/apps/web/src/page/traces/list/subscription.ts +++ b/apps/web/src/page/traces/list/subscription.ts @@ -1,7 +1,8 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" -import { Option, Schema, Stream } from "effect" -import { Dom, Subscription } from "foldkit" +import { isDialogOpen } from "@maple/ui/lib/keyboard" +import { Schema, Stream } from "effect" +import { Subscription } from "foldkit" import type { PageSubscriptionInput } from "../../contract" +import { listKeys } from "../../kit/list-keys" import { MOBILE_QUERY } from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -9,29 +10,13 @@ import { allRows } from "./update" // List navigation over the rows and the peek sheet's keys (↑/↓/J/K step, Enter opens the page): document keys while no field has focus. The peek's keys work with its sheet open. -const KEYS = new Set(["j", "J", "k", "K", "ArrowDown", "ArrowUp", "Enter", "Escape", "f", "F"]) - -/** A focused control keeps its own Enter. */ -const keepsEnter = (target: EventTarget | null): boolean => { - if (!(target instanceof HTMLElement)) return false - const control = target.closest("a, button, [role='tab'], [role='menuitem'], [role='option'], tr") - return control instanceof HTMLElement && control.tabIndex >= 0 -} - const keys = (isPeeking: boolean): Stream.Stream => - Dom.streamFromEventFilterMapPreventDefault({ - target: () => document, - type: "keydown", - filterMapEvent: (event) => { - if (!KEYS.has(event.key) || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) - return Option.none() - if (isEditableTarget(event.target)) return Option.none() - if (event.key === "Enter" && keepsEnter(event.target)) return Option.none() - // The sheet's Escape is the Dialog's; without a peek, a dialog owns the keyboard. - if (isPeeking ? event.key === "Escape" : isDialogOpen()) return Option.none() - const isMobile = window.matchMedia(MOBILE_QUERY).matches - return Option.some(Message.PressedListKey({ key: event.key, isMobile })) - }, + listKeys({ + extraKeys: ["f", "F"], + // The sheet's Escape is the Dialog's; without a peek, a dialog owns the keyboard. + isBlocked: (key) => (isPeeking ? key === "Escape" : isDialogOpen()), + toMessage: (key) => + Message.PressedListKey({ key, isMobile: window.matchMedia(MOBILE_QUERY).matches }), }) export const subscriptions = Subscription.make, Message>()((entry) => ({ diff --git a/apps/web/src/page/traces/list/update.ts b/apps/web/src/page/traces/list/update.ts index adb6b70ba0..d78e6e38f9 100644 --- a/apps/web/src/page/traces/list/update.ts +++ b/apps/web/src/page/traces/list/update.ts @@ -40,6 +40,7 @@ import { Message } from "./message" import type { Model, Pagination, TraceRow } from "./model" import { durationDraftOf } from "./search-edits" import { appendedPage, fetchingNext, freshLaterPages, pagedRows } from "../../kit/infinite-pages" +import { listStepOf, steppedFocus } from "../../kit/list-keys" type Return = PageReturn type Cmd = Command.Command @@ -317,24 +318,14 @@ const openRow = (model: Model, index: number): Return => { /** List navigation over the rows, and the peek's own keys while it is open. */ const pressedListKey = (model: Model, key: string, isMobile: boolean, context: PageContext): Return => { - const isDown = key === "j" || key === "J" || key === "ArrowDown" - const isUp = key === "k" || key === "K" || key === "ArrowUp" + const step = listStepOf(key) if (model.search.peek !== undefined && !isMobile) { - if (isDown || isUp) return steppedPeek(model, isDown ? 1 : -1) + if (step !== null) return steppedPeek(model, step) return key === "Enter" ? openPeekedTrace(model) : { model } } const count = allRows(model).length - if ((isDown || isUp) && count > 0) { - const current = model.focusedIndex ?? -1 - const next = isDown - ? current < 0 - ? 0 - : Math.min(current + 1, count - 1) - : current <= 0 - ? 0 - : current - 1 - return { model: { ...model, focusedIndex: next } } - } + if (step !== null && count > 0) + return { model: { ...model, focusedIndex: steppedFocus(model.focusedIndex, step, count) } } if (key === "Enter" && model.focusedIndex !== null) return isMobile ? openRow(model, model.focusedIndex) : peekRow(model, model.focusedIndex) if (key === "Escape") return { model: { ...model, focusedIndex: null } } diff --git a/apps/web/src/page/traces/list/view-row.ts b/apps/web/src/page/traces/list/view-row.ts index b52d8c3c3c..09138d0cdc 100644 --- a/apps/web/src/page/traces/list/view-row.ts +++ b/apps/web/src/page/traces/list/view-row.ts @@ -16,6 +16,7 @@ import { servicePills, traceStatusBadge } from "@maple/views/ui/trace-badges" import { button } from "@maple/views/ui/button" import { laneTableCell, rowLink, tableCell, tableRow } from "@maple/views/ui/table" import { latencyValue } from "@maple/views/ui/values" +import { rowFocusAttributes } from "@maple/views/ui/row-focus" import { listRelativeTime } from "../../kit/relative-time" import { Message } from "./message" import type { TraceRow } from "./model" @@ -143,12 +144,12 @@ export const traceRow = ( ], cn( state.isActive && "bg-surface-strong", - "hover:bg-surface-sunken active:bg-surface-sunken data-[focused]:bg-surface-strong data-[focused]:ring-1 data-[focused]:ring-ring data-[focused]:ring-inset cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", + "hover:bg-surface-sunken active:bg-surface-sunken cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", ), [ // Keyed by index so a scrolled-in row is a new node the virtualizer measures. h.Key(`row-${index}`), - ...(state.isFocused ? [h.DataAttribute("focused", "true")] : []), + ...rowFocusAttributes(h, state.isFocused), h.DataAttribute("index", String(index)), h.Tabindex(0), h.OnKeyDownPreventDefault((key) => diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index aff12112f2..5b4aa4b3ad 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -138,6 +138,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/resizable.ts` `panelId`, `handleId`, `resize`, `view` (stateful: Model, init, update): `react-resizable-panels` 4 (`@maple/ui/resizable`): one panel group, sizes in percent. - `packages/views/src/ui/reveal-icon.ts` `revealIcon`: `revealIcon`: a muted trailing icon (an external-link or "go" arrow) hidden until its `group` row is hovered or keyboard-focused. - `packages/views/src/ui/row-actions-menu.ts` `rowActionsMenu`: A row's overflow menu of actions. +- `packages/views/src/ui/row-focus.ts` `rowFocusAttributes`: The keyboard-focused row: the `data-focused` marker a row kit sets and the look it draws for it. - `packages/views/src/ui/rule.ts` `stripClass`, `strip`, `dividedList`, `fillRule`: Hairline rules: a `strip` (a band inside a panel or list, ruled off from its neighbours), a `dividedList` (a bordered box with a rule between its items) and a `fillRule`. - `packages/views/src/ui/scroll-area.ts` `applyScrollOverflow`, `trackScrollbar`, `scrollArea`: Base UI hides the viewport's native scrollbar with this class and an injected rule (styles.css). - `packages/views/src/ui/scroll-region.ts` `scrollRegion`, `fillRegion`: `ScrollRegion`: the rest of a flex column's height as a plain native-scrollbar overflow region (`min-h-0 flex-1 overflow-y-auto`); `scrollArea` is the one with the custom scroll... @@ -246,6 +247,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/k8s-chart.ts` `k8sChart`: The Kubernetes metric chart and the detail-chart wrappers around it: one metric at a time, 280 tall, series legend, in a chart card. - `apps/web/src/page/kit/keyed-step.ts` `keyed`, `keyedSlot`, `applySteps`, `runSteps`, `seriesSteps`, `slotStep`, `chain`, `chainAll`: One request slot's step and the way steps compose: the Model threaded through, the Commands concatenated, the last OutMessage kept. - `apps/web/src/page/kit/last-by.ts` `lastBy`: A derivation that views call on every message, recomputed only when one of its inputs changes. +- `apps/web/src/page/kit/list-keys.ts` `listKeys`, `listStepOf`, `steppedFocus`, `listKeyAction`, `focusedIndexOf`, `scrollFocusedRowCommand`: Keyboard row navigation for a list page: the document key stream (J, K, the arrows, Enter and Escape), the focus step and the scroll that keeps the focused row in view. - `apps/web/src/page/kit/list-toolbar.ts` `listToolbar`, `toolbarCount`, `toolbarStat`: A list page's row of view tabs, then search, filters and the count readout. - `apps/web/src/page/kit/markdown.ts` `escapeDirectives`, `parseMarkdownTexts`: The one markdown parse every page uses (chat, agent sessions). - `apps/web/src/page/kit/mcp-client-config.ts` `isMcpClient`, `mcpClientConfig`, `claudeCodeCommand`, `cursorInstallUrl`, `vscodeInstallUrl`: The `mcpServers` entry each client reads. diff --git a/packages/views/src/ui/list-row.styles.ts b/packages/views/src/ui/list-row.styles.ts index 110e179c2a..cc60e271f4 100644 --- a/packages/views/src/ui/list-row.styles.ts +++ b/packages/views/src/ui/list-row.styles.ts @@ -1,5 +1,6 @@ import { cva } from "class-variance-authority" import { cn } from "@maple/ui/lib/utils" +import { ROW_FOCUSED_CLASS } from "./row-focus" // The 2px left lane a selectable row draws its selection in, reserved on every row and painted // only on the selected one so contents never shift as selection moves (the sidebar's idiom). @@ -12,7 +13,7 @@ export function rowSelectedClass(selected: boolean | undefined): string { } export const listRowVariants = cva( - "flex w-full min-w-0 items-center gap-3 text-left outline-none hover:bg-surface-sunken active:bg-surface-sunken focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset", + `${ROW_FOCUSED_CLASS} flex w-full min-w-0 items-center gap-3 text-left outline-none hover:bg-surface-sunken active:bg-surface-sunken focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset`, { defaultVariants: { density: "default", divided: false }, variants: { diff --git a/packages/views/src/ui/list-row.ts b/packages/views/src/ui/list-row.ts index 6ab5aa84e0..17d0b66bc8 100644 --- a/packages/views/src/ui/list-row.ts +++ b/packages/views/src/ui/list-row.ts @@ -10,6 +10,7 @@ import { rowSelectedClass, } from "./list-row.styles" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { rowFocusAttributes } from "./row-focus" // Icon + title + meta + trailing row. @@ -20,6 +21,8 @@ export interface ListRowOptions { /** Hairline between rows (the last row drops it). */ readonly divided?: boolean readonly selected?: boolean + /** The list page's keyboard-focused row. */ + readonly isFocused?: boolean readonly leading?: Child readonly title: Child /** Secondary line under the title. */ @@ -58,6 +61,7 @@ export const listRow = ( ): Html => { const attributes = [ ...(options.selected === true ? [h.AriaCurrent("true"), h.DataAttribute("selected", "")] : []), + ...rowFocusAttributes(h, options.isFocused), h.Attribute( "class", cn( diff --git a/packages/views/src/ui/row-focus.ts b/packages/views/src/ui/row-focus.ts new file mode 100644 index 0000000000..c4aa806614 --- /dev/null +++ b/packages/views/src/ui/row-focus.ts @@ -0,0 +1,20 @@ +import type { Attribute, HtmlBuilder } from "foldkit/html" + +/** + * The keyboard-focused row: the `data-focused` marker a row kit sets and the look it draws for it. + * A list page moves it with J / K (`page/kit/list-keys`); it is not DOM focus. + */ +export const ROW_FOCUSED_CLASS = + "data-[focused]:bg-surface-strong data-[focused]:ring-1 data-[focused]:ring-ring data-[focused]:ring-inset" + +/** `data-list-row` on every row of a keyed list, so the keys can tell one that holds DOM focus. */ +export const LIST_ROW_SELECTOR = "[data-list-row]" + +/** A keyed list's row: `data-list-row`, and `data-focused` on the focused one. Nothing for a row no list keys. */ +export const rowFocusAttributes = ( + h: HtmlBuilder, + isFocused: boolean | undefined, +): ReadonlyArray> => + isFocused === undefined + ? [] + : [h.DataAttribute("list-row", ""), ...(isFocused ? [h.DataAttribute("focused", "true")] : [])] diff --git a/packages/views/src/ui/selectable-row.ts b/packages/views/src/ui/selectable-row.ts index accc3adbd8..b9715b2510 100644 --- a/packages/views/src/ui/selectable-row.ts +++ b/packages/views/src/ui/selectable-row.ts @@ -1,5 +1,6 @@ import { cn } from "@maple/ui/lib/utils" import { rowSelectedClass } from "./list-row.styles" +import { ROW_FOCUSED_CLASS, rowFocusAttributes } from "./row-focus" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" // `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection @@ -7,6 +8,8 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" export type SelectableRowOptions = Readonly<{ isSelected: boolean + /** The list page's keyboard-focused row. */ + isFocused?: boolean /** The row is a link to this URL. */ href?: string /** A click anywhere on the row, for a row whose stretched `pressable` sits under columns with their own controls. */ @@ -21,8 +24,9 @@ export const selectableRow = ( children: ReadonlyArray, ): Html => { const attributes = [ - h.Class(cn(rowSelectedClass(options.isSelected), options.className)), + h.Class(cn(rowSelectedClass(options.isSelected), ROW_FOCUSED_CLASS, options.className)), h.DataAttribute("slot", "selectable-row"), + ...rowFocusAttributes(h, options.isFocused), ...(options.onClick === undefined ? [] : [h.OnClick(options.onClick)]), ...(options.attributes ?? []), ] diff --git a/packages/views/src/ui/table.styles.ts b/packages/views/src/ui/table.styles.ts index 66cacaeb2b..6431ecd244 100644 --- a/packages/views/src/ui/table.styles.ts +++ b/packages/views/src/ui/table.styles.ts @@ -1,3 +1,5 @@ +import { ROW_FOCUSED_CLASS } from "./row-focus" + export const tableContainerStyles = "relative w-full" export const tableContainerScrollStyles = "overflow-x-auto" @@ -18,8 +20,7 @@ export const tableBodyStyles = // Body rows divide on the subtle hairline so the header rule reads as the stronger line. The hover is // unprefixed so a row's own `hover:bg-*` merges over it (card rows paint their cells instead). -export const tableRowStyles = - "relative border-b border-border-subtle in-data-[slot=table-header]:border-border in-data-[variant=bare]:border-0 in-data-[variant=bare]:hover:bg-transparent in-data-[variant=bare]:active:bg-transparent hover:bg-surface-sunken active:bg-surface-sunken data-[state=selected]:bg-surface-strong in-data-[variant=card]:hover:bg-transparent in-data-[variant=card]:active:bg-transparent in-data-[variant=card]:data-[state=selected]:bg-transparent" +export const tableRowStyles = `${ROW_FOCUSED_CLASS} relative border-b border-border-subtle in-data-[slot=table-header]:border-border in-data-[variant=bare]:border-0 in-data-[variant=bare]:hover:bg-transparent in-data-[variant=bare]:active:bg-transparent hover:bg-surface-sunken active:bg-surface-sunken data-[state=selected]:bg-surface-strong in-data-[variant=card]:hover:bg-transparent in-data-[variant=card]:active:bg-transparent in-data-[variant=card]:data-[state=selected]:bg-transparent` // Column labels share the eyebrow overline token (size, case, tracking, colour) with list headers. // Div-based tables (infra) use the label type alone.