You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR hyperdxio/hyperdx#2265 shipped a static color picker for number tiles: the tile's value renders in the picked palette token, with fallback to the default text color when unset.
The next slice of HDX-1360 is conditional color rules: the user defines an ordered list of conditions, each carrying a color, and the displayed value gets the color of the last matching rule. When no rule matches, the tile's static color (from #2265) applies.
This matches the Grafana thresholds model: rules are evaluated in order, the LAST match wins. The schema is intentionally generic so a future table-tile slice can attach the same condition primitive on a per-column basis without schema change.
Open a "Conditional colors" section in the Display Settings drawer.
Add up to 10 ordered rules. Each rule: operator + value + palette-token color + optional label.
Drag rules to reorder (reuses the existing @dnd-kit/sortable + verticalListSortingStrategy pattern from packages/app/src/components/DashboardDndContext.tsx).
Save. All choices persist as palette tokens and round-trip via internal save.
At render time, the number tile evaluates rules in order; the LAST matching rule's color wins; if nothing matches, it falls back to the static tile color from #2265; if that is unset, it falls back to the default text color.
This is identical to Grafana panel thresholds, where rules are sorted ascending and "the last threshold whose value is at or below the current data value applies."
Why last-match-wins, not first-match: when the user lists ascending ranges (>= 0 green, >= 100 yellow, >= 500 red), they list them in natural order and the highest matching range wins automatically. Exact-match overrides (e.g. eq "CRIT" red) go LAST and override any range. The user controls priority via order.
Schema (common-utils)
Add to packages/common-utils/src/types.ts, alongside ChartPaletteTokenSchema:
constColorConditionSchema=z.discriminatedUnion('operator',[// Numeric ordered operatorsz.object({operator: z.enum(['gt','gte','lt','lte']),value: z.number().finite(),color: ChartPaletteTokenSchema,label: z.string().max(40).optional(),}),z.object({operator: z.literal('between'),value: z.tuple([z.number().finite(),z.number().finite()]),color: ChartPaletteTokenSchema,label: z.string().max(40).optional(),}),// Equality (number OR string)z.object({operator: z.enum(['eq','neq']),value: z.union([z.number().finite(),z.string().max(200)]),color: ChartPaletteTokenSchema,label: z.string().max(40).optional(),}),// String operators (used by future table tile; allowed at schema level on all tiles)z.object({operator: z.enum(['contains','startsWith','endsWith']),value: z.string().min(1).max(200),color: ChartPaletteTokenSchema,label: z.string().max(40).optional(),}),z.object({operator: z.literal('regex'),value: z.string().min(1).max(500).refine(v=>{try{newRegExp(v);returntrue;}catch{returnfalse;}},{message: 'Invalid regex pattern'},),color: ChartPaletteTokenSchema,label: z.string().max(40).optional(),}),]);exporttypeColorCondition=z.infer<typeofColorConditionSchema>;export{ColorConditionSchema};
Then add colorRules to SharedChartSettingsSchema next to the existing color:
Stays at the shared level (same pattern as color and numberFormat). The UI gates by tile type. The schema does NOT discriminate on tile type for colorRules; future table tile uses the same primitive.
Export ColorConditionSchema so the future table-tile slice and the eventual external-API parity PR both reuse it.
Resolver (app)
New helper in packages/app/src/utils.ts, next to resolveChartColor from #2265:
evaluateColorCondition(value, rule) walks the discriminated union:
Numeric ops (gt, gte, lt, lte, between): return false if typeof value !== 'number'.
eq / neq: strict comparison; cross-type returns false ("5" vs 5 is no match).
String ops (contains, startsWith, endsWith, regex): return false if typeof value !== 'string'. regex uses new RegExp(rule.value).test(value) wrapped in try/catch (bad regex returns false at runtime; schema-side .refine is best-effort, runtime guard is the last line of defense).
Number tile in v1 always passes a numeric value, so string ops become no-ops in practice; they exist in the schema for table-tile reuse.
UI (app)
In packages/app/src/components/ChartDisplaySettingsDrawer.tsx, add a "Conditional colors" section below the existing "Color" section (the AC3 one shipped in #2265). Gate visibility on displayType === DisplayType.Number, mirroring how the "Color" section is gated at lines 110-116 (the showGroupByColumnsOnLeft / isTimeChart pattern).
New component: packages/app/src/components/ColorRulesEditor.tsx. Layout:
Header: "Conditional colors" with a small hint: "Falls back to the tile color when no rule matches."
Sortable list of rules. Each row contains:
Drag handle (left).
Operator dropdown: >, >=, <, <=, between, =, ≠. String operators (contains, startsWith, endsWith, regex) are hidden on number tile; they become available when the same editor is reused on a table tile in a future slice.
Value input(s): single number input for ordered ops; two number inputs for between; single text-or-number input for eq/neq.
"Add rule" button at the bottom. Disabled at 10 rules.
Sortable list: use @dnd-kit/sortable with verticalListSortingStrategy, MouseSensor (activationConstraint: { distance: 8 }) + TouchSensor (activationConstraint: { delay: 200, tolerance: 5 }). Copy the activation pattern verbatim from DashboardDndContext.tsx. Each rule row uses useSortable({ id: rule.localId }). Rule localIds are generated client-side via crypto.randomUUID() (or an existing helper if one exists in the repo); they are stripped before save (rules persist as a plain array; array index is identity).
Wire to the form via Controller like the existing color picker: <Controller name="colorRules" control={control} render={...}>. Pass through EditTimeChartForm's displaySettings memo and handleUpdateDisplaySettings writeback, mirroring how color is wired in #2265.
Render (app)
In packages/app/src/components/DBNumberChart.tsx, call resolveConditionalColor(displayedValue, config.colorRules, config.color) once per render. Apply via the existing Mantine <Text c={getColorFromCSSToken(...)}> pattern shipped in #2265.
When the tile is in a state with no value to evaluate (loading, error, empty), keep current behavior; do not apply rules.
Reuse design (table tile, future slice)
ColorConditionSchema is universal. A future table-tile slice attaches per-column rules:
// On TableChartConfig (future, NOT in this issue)
columnColorRules: z.record(z.string(),z.array(ColorConditionSchema).max(10)).optional()
The same resolveConditionalColor works per-cell once the per-column rules are passed. The same ColorRulesEditor component is reused, wrapped in a per-column tab UI.
That work is OUT OF SCOPE for this issue. The design constraint is "do not preclude it." The implementation here must keep the schema and resolver in shared locations the future table slice can import.
Zod .tuple([num, num]) (first <= second is a UI hint only; the schema does not enforce ordering, so an inverted between can be intentional)
String operators reject empty value
Zod .min(1)
label max 40 chars
Zod .max(40)
colorRules is allowed at the schema level on all tile types (kept universal for future table reuse). The UI in v1 only renders the section when displayType === DisplayType.Number. External-API parity (separate ticket; see References) mirrors this.
Test matrix
Schema tests (common-utils)
Positive:
Each operator parses with a valid value and a valid palette token.
colorRules accepts 0, 1, 5, 10 rules.
between parses with first > second (allowed; UI hint only).
NO changes to API schema (packages/api/src/utils/zod.ts) or external-API round-trip (packages/api/src/routers/external-api/v2/utils/dashboards.ts) in this issue. External-API parity is a SEPARATE follow-up; open it after this lands (mirror the #2359 / #2265 pattern).
Tier expectation
Tier 3 (multi-layer: common-utils + app). Same shape as #2265, which landed at Tier 3 (840 prod lines; story files count toward the classifier per .github/scripts/pr-triage-classify.js:31-36). Predict locally:
If the diff approaches Tier 4 (> 1000 prod lines, agent-prefix > 400), split into:
Schema-only PR (dormant colorRules field on SharedChartSettingsSchema). Tier 2.
UI PR (resolver, editor, drawer integration, render). Tier 2 or 3 depending on size.
Keep story files lean.
Branch and PR conventions
Branch: alex/HDX-1360-number-conditional-colors. Do NOT use claude/, agent/, cursor/, or ai/ prefixes; those tag the PR as agent-branch and tighten the Tier-2 threshold to < 50 lines AND <= 3 prod files.
Push directly to hyperdxio/hyperdx (no fork); PR alex:alex/HDX-1360-number-conditional-colors -> main.
Commits authored with the operator's git identity. Co-Authored-By: Claude ... trailers ALLOWED on hyperdx (per repo policy).
PR body: first person, no em-dashes, no AI hallmarks ("Let me", "I'll go ahead", "comprehensive", "robust", "seamless", "leverage"). Run python3 ~/nerve-workspace/scripts/prose-lint.py before opening.
Changeset: required. yarn changeset with a minor bump (user-facing feature).
Link this issue in the PR body. Add before/after screenshots (light + dark, both themes).
Pre-PR checklist
make ci-lint && make ci-unit pass locally.
Schema positive + negative tests pass.
Resolver tests cover last-match-wins, mixed types, null value, bad regex.
python3 ~/nerve-workspace/scripts/prose-lint.py clean on PR body.
Storybook stories cover light + dark for both hyperdx and clickstack themes.
Manual verification: open a dashboard, edit a number tile, set rules gte 100 chart-warning, gte 500 chart-error (in that order). Confirm value 50 renders the static color, 200 renders warning, 1000 renders error.
Outcome doc: notes/outcomes/2026-05-tile-colors-and-thresholds.md (this is AC4, generalized 2026-05-30).
Per-feature code map: notes/repo-conventions/hyperdx/tile-styling.md (section "Number tile threshold color"; the original AC4 sketch is the predecessor of this design).
Existing drag-and-drop pattern to copy: packages/app/src/components/DashboardDndContext.tsx.
Context
PR hyperdxio/hyperdx#2265 shipped a static color picker for number tiles: the tile's value renders in the picked palette token, with fallback to the default text color when unset.
The next slice of HDX-1360 is conditional color rules: the user defines an ordered list of conditions, each carrying a color, and the displayed value gets the color of the last matching rule. When no rule matches, the tile's static color (from #2265) applies.
This matches the Grafana thresholds model: rules are evaluated in order, the LAST match wins. The schema is intentionally generic so a future table-tile slice can attach the same condition primitive on a per-column basis without schema change.
notes/outcomes/2026-05-tile-colors-and-thresholds.md(this is AC4, generalized 2026-05-30).notes/repo-conventions/hyperdx/tile-styling.md.Goal
A dashboard editor can:
@dnd-kit/sortable+verticalListSortingStrategypattern frompackages/app/src/components/DashboardDndContext.tsx).At render time, the number tile evaluates rules in order; the LAST matching rule's color wins; if nothing matches, it falls back to the static tile color from #2265; if that is unset, it falls back to the default text color.
Operator semantics (Grafana style, last-match-wins)
color(from [HDX-1360] feat(app): number tile static color picker #2265) is used; otherwise the default text color.This is identical to Grafana panel thresholds, where rules are sorted ascending and "the last threshold whose value is at or below the current data value applies."
Why last-match-wins, not first-match: when the user lists ascending ranges (
>= 0 green,>= 100 yellow,>= 500 red), they list them in natural order and the highest matching range wins automatically. Exact-match overrides (e.g.eq "CRIT" red) go LAST and override any range. The user controls priority via order.Schema (common-utils)
Add to
packages/common-utils/src/types.ts, alongsideChartPaletteTokenSchema:Then add
colorRulestoSharedChartSettingsSchemanext to the existingcolor:Stays at the shared level (same pattern as
colorandnumberFormat). The UI gates by tile type. The schema does NOT discriminate on tile type forcolorRules; future table tile uses the same primitive.Export
ColorConditionSchemaso the future table-tile slice and the eventual external-API parity PR both reuse it.Resolver (app)
New helper in
packages/app/src/utils.ts, next toresolveChartColorfrom #2265:evaluateColorCondition(value, rule)walks the discriminated union:gt,gte,lt,lte,between): return false iftypeof value !== 'number'.eq/neq: strict comparison; cross-type returns false ("5"vs5is no match).contains,startsWith,endsWith,regex): return false iftypeof value !== 'string'.regexusesnew RegExp(rule.value).test(value)wrapped intry/catch(bad regex returns false at runtime; schema-side.refineis best-effort, runtime guard is the last line of defense).Number tile in v1 always passes a numeric value, so string ops become no-ops in practice; they exist in the schema for table-tile reuse.
UI (app)
In
packages/app/src/components/ChartDisplaySettingsDrawer.tsx, add a "Conditional colors" section below the existing "Color" section (the AC3 one shipped in #2265). Gate visibility ondisplayType === DisplayType.Number, mirroring how the "Color" section is gated at lines 110-116 (theshowGroupByColumnsOnLeft/isTimeChartpattern).New component:
packages/app/src/components/ColorRulesEditor.tsx. Layout:>,>=,<,<=,between,=,≠. String operators (contains,startsWith,endsWith,regex) are hidden on number tile; they become available when the same editor is reused on a table tile in a future slice.between; single text-or-number input foreq/neq.ColorSwatchInputfrom [HDX-1360] feat(app): number tile static color picker #2265).Sortable list: use
@dnd-kit/sortablewithverticalListSortingStrategy,MouseSensor(activationConstraint: { distance: 8 }) +TouchSensor(activationConstraint: { delay: 200, tolerance: 5 }). Copy the activation pattern verbatim fromDashboardDndContext.tsx. Each rule row usesuseSortable({ id: rule.localId }). RulelocalIds are generated client-side viacrypto.randomUUID()(or an existing helper if one exists in the repo); they are stripped before save (rules persist as a plain array; array index is identity).Wire to the form via
Controllerlike the existing color picker:<Controller name="colorRules" control={control} render={...}>. Pass throughEditTimeChartForm'sdisplaySettingsmemo andhandleUpdateDisplaySettingswriteback, mirroring howcoloris wired in #2265.Render (app)
In
packages/app/src/components/DBNumberChart.tsx, callresolveConditionalColor(displayedValue, config.colorRules, config.color)once per render. Apply via the existing Mantine<Text c={getColorFromCSSToken(...)}>pattern shipped in #2265.When the tile is in a state with no value to evaluate (loading, error, empty), keep current behavior; do not apply rules.
Reuse design (table tile, future slice)
ColorConditionSchemais universal. A future table-tile slice attaches per-column rules:The same
resolveConditionalColorworks per-cell once the per-column rules are passed. The sameColorRulesEditorcomponent is reused, wrapped in a per-column tab UI.That work is OUT OF SCOPE for this issue. The design constraint is "do not preclude it." The implementation here must keep the schema and resolver in shared locations the future table slice can import.
Validation rules
colorRulesarray length 0 to 10.max(10)operatorfrom the unionvalue)value)valuefinite (no NaN, no Infinity).finite()colormust be a knownChartPaletteToken.refine(v => { try { new RegExp(v); return true; } catch { return false; } })betweentuple has exactly 2 values.tuple([num, num])(first <= second is a UI hint only; the schema does not enforce ordering, so an invertedbetweencan be intentional).min(1)labelmax 40 chars.max(40)colorRulesis allowed at the schema level on all tile types (kept universal for future table reuse). The UI in v1 only renders the section whendisplayType === DisplayType.Number. External-API parity (separate ticket; see References) mirrors this.Test matrix
Schema tests (common-utils)
Positive:
colorRulesaccepts 0, 1, 5, 10 rules.betweenparses with first > second (allowed; UI hint only).regexparses with a valid pattern.eqparses with both number and string values.Negative:
NaNandInfinityrejected on numeric ops.contains/startsWith/endsWith/regexrejected.label> 40 chars rejected.Resolver tests (app)
Positive:
fallback.eqwith cross-type (rule value"5", runtime value5): no match.null/undefinedvalue: returnsfallback.Negative:
try/catch, treated as no match.UI tests (app)
Positive:
gt, colorchart-1).onDragEnd; or use the dnd-kit testing approach used indashboardSections.test.tsx).between, text-or-number input foreq/neq.Negative:
regexrule's value input) shows a visual error indicator; resolvertry/catchensures runtime safety.Integration test (DBNumberChart)
Mirror
__tests__/DBNumberChart.test.tsxfrom #2265. Add a case that:color: 'chart-success'ANDcolorRules: [{ operator: 'gte', value: 100, color: 'chart-warning' }, { operator: 'gte', value: 500, color: 'chart-error' }].chart-success(no rule matches; fallback to tile color).chart-warning(first rule matches, second doesn't; last match is rule 1).chart-error(both rules match; last match wins).File locations
packages/common-utils/src/types.tsColorConditionSchema,ColorCondition. AddcolorRulestoSharedChartSettingsSchema. Export both.packages/common-utils/src/__tests__/types.test.ts(or wherever schema tests live)packages/app/src/utils.tsresolveConditionalColorandevaluateColorCondition.packages/app/src/components/ColorRulesEditor.tsxpackages/app/src/components/ColorRulesEditor.stories.tsxhyperdxandclickstackthemes in light + dark.packages/app/src/components/__tests__/ColorRulesEditor.test.tsxpackages/app/src/components/ChartDisplaySettingsDrawer.tsxdisplayType === DisplayType.Number.packages/app/src/components/DBEditTimeChartForm/EditTimeChartForm.tsxcolorRulesthroughdisplaySettingsmemo and writeback.packages/app/src/components/DBNumberChart.tsxresolveConditionalColor.packages/app/src/components/__tests__/DBNumberChart.test.tsxNO changes to API schema (
packages/api/src/utils/zod.ts) or external-API round-trip (packages/api/src/routers/external-api/v2/utils/dashboards.ts) in this issue. External-API parity is a SEPARATE follow-up; open it after this lands (mirror the #2359 / #2265 pattern).Tier expectation
Tier 3 (multi-layer: common-utils + app). Same shape as #2265, which landed at Tier 3 (840 prod lines; story files count toward the classifier per
.github/scripts/pr-triage-classify.js:31-36). Predict locally:node ~/nerve-workspace/scripts/predict-tier.js --base origin/mainIf the diff approaches Tier 4 (> 1000 prod lines, agent-prefix > 400), split into:
colorRulesfield onSharedChartSettingsSchema). Tier 2.Keep story files lean.
Branch and PR conventions
alex/HDX-1360-number-conditional-colors. Do NOT useclaude/,agent/,cursor/, orai/prefixes; those tag the PR as agent-branch and tighten the Tier-2 threshold to < 50 lines AND <= 3 prod files.hyperdxio/hyperdx(no fork); PRalex:alex/HDX-1360-number-conditional-colors->main.Co-Authored-By: Claude ...trailers ALLOWED on hyperdx (per repo policy).python3 ~/nerve-workspace/scripts/prose-lint.pybefore opening.yarn changesetwith aminorbump (user-facing feature).Pre-PR checklist
make ci-lint && make ci-unitpass locally.DBNumberChart.test.tsxcovers the success / warning / error scenario.node ~/nerve-workspace/scripts/predict-tier.js --base origin/mainconfirms Tier 3 (or split).python3 ~/nerve-workspace/scripts/prose-lint.pyclean on PR body.hyperdxandclickstackthemes.gte 100 chart-warning,gte 500 chart-error(in that order). Confirm value 50 renders the static color, 200 renders warning, 1000 renders error.ascasts. Noanytypes.References
notes/outcomes/2026-05-tile-colors-and-thresholds.md(this is AC4, generalized 2026-05-30).notes/repo-conventions/hyperdx/tile-styling.md(section "Number tile threshold color"; the original AC4 sketch is the predecessor of this design).packages/app/src/components/DashboardDndContext.tsx.packages/app/package.json:@dnd-kit/core ^6.3.1,@dnd-kit/sortable ^10.0.0,@dnd-kit/utilities ^3.2.2.Out of scope
packages/api/src/utils/zod.ts, OpenAPI regen, round-trip tests). Open a separate ticket after this lands, mirroring [HDX-1360] External API parity for number-tile color #2359.clickhouse-docs. Open after API parity merges so both authoring surfaces are covered together.Acceptance criteria (this issue)
ColorConditionSchemadefined in common-utils and exported.colorRulesadded toSharedChartSettingsSchema(optional, max 10).resolveConditionalColorhelper exported frompackages/app/src/utils.ts; last-match-wins; falls back tocolorthen to default.ChartDisplaySettingsDrawer, gated on number tile.@dnd-kit/sortable, copyingDashboardDndContext.tsxsensors and strategy.DBNumberChart.test.tsxcovers the success / warning / error scenario.