+ html`
${keyMeta.label}
`,
);
@@ -47,4 +50,50 @@ export class Timelinekey extends LitElement {
return keyParts;
}
+
+ /**
+ * Calculate relative luminance of a hex color to determine if it's dark or light.
+ * Supports #RGB, #RGBA, #RRGGBB, and #RRGGBBAA formats.
+ * Uses WCAG formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
+ */
+ private getContrastingTextColor(hexColor: string): string {
+ // Remove # if present
+ let hex = hexColor.replace('#', '');
+
+ // Normalize to 6-digit RGB format
+ if (hex.length === 3) {
+ // #RGB -> #RRGGBB
+ hex = hex
+ .split('')
+ .map((char) => char + char)
+ .join('');
+ } else if (hex.length === 4) {
+ // #RGBA -> #RRGGBB (ignore alpha)
+ hex = hex
+ .substring(0, 3)
+ .split('')
+ .map((char) => char + char)
+ .join('');
+ } else if (hex.length === 8) {
+ // #RRGGBBAA -> #RRGGBB (ignore alpha)
+ hex = hex.substring(0, 6);
+ }
+
+ // Parse RGB values
+ const r = parseInt(hex.substring(0, 2), 16) / 255;
+ const g = parseInt(hex.substring(2, 4), 16) / 255;
+ const b = parseInt(hex.substring(4, 6), 16) / 255;
+
+ // Apply gamma correction for sRGB
+ const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
+ const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
+ const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
+
+ // W3C relative luminance formula
+ const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
+
+ // Use dark text for light backgrounds, light text for dark backgrounds
+ // Threshold of 0.179 corresponds to ~50% perceived brightness
+ return luminance > 0.179 ? '#1e1e1e' : '#e3e3e3';
+ }
}
diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts
index 4091fa67..10743084 100644
--- a/log-viewer/src/features/timeline/components/TimelineView.ts
+++ b/log-viewer/src/features/timeline/components/TimelineView.ts
@@ -5,8 +5,12 @@ import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import type { ApexLog } from '../../../core/log-parser/LogEvents.js';
+import { VSCodeExtensionMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
import { getSettings } from '../../settings/Settings.js';
-import { type TimelineGroup } from '../services/Timeline.js';
+import { type TimelineGroup, keyMap, setColors } from '../services/Timeline.js';
+
+import { DEFAULT_THEME_NAME, type TimelineColors } from '../themes/Themes.js';
+import { addCustomThemes, getTheme } from '../themes/ThemeSelector.js';
// styles
import { globalStyles } from '../../../styles/global.styles.js';
@@ -17,12 +21,30 @@ import './TimelineKey.js';
import './TimelineLegacy.js';
import './TimelineSkeleton.js';
+/* eslint-disable @typescript-eslint/naming-convention */
+interface ThemeSettings {
+ [key: string]: {
+ codeUnit: string;
+ workflow: string;
+ method: string;
+ flow: string;
+ dml: string;
+ soql: string;
+ system: string;
+ };
+}
+/* eslint-enable @typescript-eslint/naming-convention */
+
@customElement('timeline-view')
export class TimelineView extends LitElement {
@property()
timelineRoot: ApexLog | null = null;
- @property()
- timelineKeys: TimelineGroup[] = [];
+
+ @state()
+ activeTheme: string | null = null;
+
+ @state()
+ private timelineKeys: TimelineGroup[] = [];
@state()
private useLegacyTimeline: boolean | null = null;
@@ -47,25 +69,99 @@ export class TimelineView extends LitElement {
async connectedCallback() {
super.connectedCallback();
- const settings = await getSettings();
- this.useLegacyTimeline = settings.timeline.legacy;
+
+ VSCodeExtensionMessenger.listen<{ activeTheme: string }>((event) => {
+ const { cmd, payload } = event.data;
+ if (cmd === 'switchTimelineTheme' && this.activeTheme !== payload.activeTheme) {
+ this.setTheme(payload.activeTheme ?? DEFAULT_THEME_NAME);
+ }
+ });
+
+ getSettings().then((settings) => {
+ const { timeline } = settings;
+ this.useLegacyTimeline = timeline.legacy;
+
+ if (!this.useLegacyTimeline) {
+ addCustomThemes(this.toTheme(timeline.customThemes));
+ this.setTheme(timeline.activeTheme ?? DEFAULT_THEME_NAME);
+ } else {
+ setColors(timeline.colors);
+ this.timelineKeys = Array.from(keyMap.values());
+ }
+ });
}
render() {
- let timelineBody;
if (!this.timelineRoot || this.useLegacyTimeline === null) {
- timelineBody = html`
`;
- } else if (!this.useLegacyTimeline) {
- timelineBody = html`
+ `;
+ }
+
+ if (!this.useLegacyTimeline) {
+ return html`
+ `;
+ }
+ return html``;
- } else {
- timelineBody = html`
`;
+ .themeName=${this.activeTheme}
+ >
`;
+ }
+
+ private setTheme(themeName: string) {
+ this.activeTheme = themeName ?? DEFAULT_THEME_NAME;
+ this.timelineKeys = this.toTimelineKeys(getTheme(themeName));
+ }
+
+ private toTheme(themeSettings: ThemeSettings): { [key: string]: TimelineColors } {
+ const themes: { [key: string]: TimelineColors } = {};
+ for (const [name, colors] of Object.entries(themeSettings)) {
+ themes[name] = {
+ codeUnit: colors.codeUnit,
+ workflow: colors.workflow,
+ method: colors.method,
+ flow: colors.flow,
+ dml: colors.dml,
+ soql: colors.soql,
+ system: colors.system,
+ };
}
+ return themes;
+ }
- return html`
- ${timelineBody}
-
- `;
+ private toTimelineKeys(colors: TimelineColors): TimelineGroup[] {
+ return [
+ {
+ label: 'Code Unit',
+ fillColor: colors.codeUnit,
+ },
+ {
+ label: 'Workflow',
+ fillColor: colors.workflow,
+ },
+ {
+ label: 'Method',
+ fillColor: colors.method,
+ },
+ {
+ label: 'Flow',
+ fillColor: colors.flow,
+ },
+ {
+ label: 'DML',
+ fillColor: colors.dml,
+ },
+ {
+ label: 'SOQL',
+ fillColor: colors.soql,
+ },
+ {
+ label: 'System Method',
+ fillColor: colors.system,
+ },
+ ];
}
}
diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
index aa0ed286..0cc7a8f8 100644
--- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
+++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts
@@ -19,6 +19,7 @@
import type { ApexLog, LogEvent } from '../../../core/log-parser/LogEvents.js';
import { goToRow } from '../../call-tree/components/CalltreeView.js';
+import { getTheme } from '../themes/ThemeSelector.js';
import type {
EventNode,
FindEventDetail,
@@ -32,6 +33,10 @@ import { extractMarkers } from '../utils/marker-utils.js';
import { FlameChart } from './FlameChart.js';
import { TimelineTooltipManager } from './TimelineTooltipManager.js';
+interface ApexTimelineOptions extends TimelineOptions {
+ themeName?: string | null;
+}
+
export class ApexLogTimeline {
private flamechart: FlameChart;
private tooltipManager: TimelineTooltipManager | null = null;
@@ -51,19 +56,20 @@ export class ApexLogTimeline {
public async init(
container: HTMLElement,
apexLog: ApexLog,
- options: TimelineOptions = {},
+ options: ApexTimelineOptions = {},
): Promise
{
this.apexLog = apexLog;
this.options = options;
this.container = container;
+ const colorMap = this.themeToColors(options.themeName ?? '');
+ options.colors = colorMap;
+
// Create tooltip manager for Apex-specific tooltips
this.tooltipManager = new TimelineTooltipManager(container, {
enableFlip: true,
cursorOffset: 10,
- categoryColors: {
- ...options.colors,
- },
+ categoryColors: colorMap,
apexLog: apexLog,
});
@@ -135,9 +141,41 @@ export class ApexLogTimeline {
this.flamechart.resize(newWidth, newHeight);
}
+ /**
+ * Set timeline theme by name and apply colors.
+ * Retrieves theme colors from ThemeSelector and updates FlameChart.
+ */
+ public setTheme(themeName: string): void {
+ const colorMap = this.themeToColors(themeName);
+
+ // Update FlameChart colors (handles re-render)
+ this.flamechart.setColors(colorMap);
+
+ // Update TooltipManager colors if available
+ if (this.tooltipManager) {
+ this.tooltipManager.updateCategoryColors(colorMap);
+ }
+ }
+
+ private themeToColors(themeName: string) {
+ const theme = getTheme(themeName);
+ // Convert TimelineColors keys to the format expected by FlameChart
+ /* eslint-disable @typescript-eslint/naming-convention */
+ return {
+ 'Code Unit': theme.codeUnit,
+ Workflow: theme.workflow,
+ Method: theme.method,
+ Flow: theme.flow,
+ DML: theme.dml,
+ SOQL: theme.soql,
+ 'System Method': theme.system,
+ };
+ /* eslint-enable @typescript-eslint/naming-convention */
+ }
+
// ============================================================================
// APEX-SPECIFIC HANDLERS
- // ============================================================================
+ // ============================================================================}
/**
* Handle mouse move - show Apex-specific tooltips.
diff --git a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts b/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts
index 1d2c930b..63dc38b4 100644
--- a/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/EventBatchRenderer.ts
@@ -122,7 +122,7 @@ export class EventBatchRenderer {
}
// Set fill style for this batch
- gfx.setFillStyle({ color: batch.color });
+ gfx.setFillStyle({ color: batch.color, alpha: batch.alpha ?? 1 });
// Draw all rectangles in this batch with negative space separation
const gap = TIMELINE_CONSTANTS.RECT_GAP;
diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts
index 69a52ed4..4b69b868 100644
--- a/log-viewer/src/features/timeline/optimised/FlameChart.ts
+++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts
@@ -276,14 +276,16 @@ export class FlameChart {
}
// Create text label renderer (renders method names on rectangles)
- if (this.worldContainer) {
+ if (this.worldContainer && this.state) {
this.textLabelRenderer = new TextLabelRenderer(this.worldContainer);
await this.textLabelRenderer.loadFont();
+ this.textLabelRenderer.setBatches(this.state.batches);
// SearchTextLabelRenderer uses composition - delegates matched labels to TextLabelRenderer
this.searchTextLabelRenderer = new SearchTextLabelRenderer(
this.worldContainer,
this.textLabelRenderer,
+ this.state.batches,
);
// Enable zIndex sorting for proper layering
@@ -535,6 +537,30 @@ export class FlameChart {
this.requestRender();
}
+ /**
+ * Update timeline colors and request a re-render.
+ * Updates batch colors and re-renders the timeline.
+ */
+ public setColors(colors: Record): void {
+ if (!this.state) {
+ return;
+ }
+
+ // Update batch colors
+ for (const [category, batch] of this.state.batches) {
+ const colorValue = colors[category];
+ if (colorValue) {
+ const parsed = this.cssColorToPixi(colorValue);
+ batch.color = parsed.color;
+ batch.alpha = parsed.alpha;
+ batch.isDirty = true;
+ }
+ }
+
+ // Request re-render
+ this.requestRender();
+ }
+
// ============================================================================
// PRIVATE SETUP METHODS
// ============================================================================
@@ -692,9 +718,11 @@ export class FlameChart {
const categories = Object.keys(colors) as (keyof typeof colors)[];
for (const category of categories) {
+ const parsed = this.cssColorToPixi(colors[category] || '#000000');
batches.set(category, {
category,
- color: this.cssColorToPixi(colors[category] || '#000000'),
+ color: parsed.color,
+ alpha: parsed.alpha,
rectangles: [],
isDirty: true,
});
@@ -714,20 +742,42 @@ export class FlameChart {
};
}
- private cssColorToPixi(cssColor: string): number {
+ private cssColorToPixi(cssColor: string): { color: number; alpha: number } {
if (cssColor.startsWith('#')) {
- return parseInt(cssColor.slice(1), 16);
+ const hex = cssColor.slice(1);
+ if (hex.length === 8) {
+ const rgb = hex.slice(0, 6);
+ const a = parseInt(hex.slice(6, 8), 16) / 255;
+ return { color: parseInt(rgb, 16), alpha: a };
+ }
+ if (hex.length === 6) {
+ return { color: parseInt(hex, 16), alpha: 1 };
+ }
+ if (hex.length === 4) {
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ const a = hex[3]!;
+ return { color: parseInt(r + r + g + g + b + b, 16), alpha: parseInt(a + a, 16) / 255 };
+ }
+ if (hex.length === 3) {
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ return { color: parseInt(r + r + g + g + b + b, 16), alpha: 1 };
+ }
}
- const rgbMatch = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ const rgbMatch = cssColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d*(?:\.\d+)?))?\)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1] ?? '0', 10);
const g = parseInt(rgbMatch[2] ?? '0', 10);
const b = parseInt(rgbMatch[3] ?? '0', 10);
- return (r << 16) | (g << 8) | b;
+ const a = rgbMatch[4] ? parseFloat(rgbMatch[4]) : 1;
+ return { color: (r << 16) | (g << 8) | b, alpha: a };
}
- return 0x000000;
+ return { color: 0x000000, alpha: 1 };
}
// ============================================================================
diff --git a/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts
index 3f5b518a..e42e34d4 100644
--- a/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/SearchHighlightRenderer.ts
@@ -264,7 +264,7 @@ export class SearchHighlightRenderer {
/**
* Parse CSS color string to PixiJS numeric color.
- * Handles hex format (#RRGGBB, #RRGGBBAA) and falls back to default.
+ * Handles hex format (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) and falls back to default.
*
* @param cssColor - CSS color string
* @returns PixiJS numeric color (0xRRGGBB)
@@ -283,6 +283,20 @@ export class SearchHighlightRenderer {
if (hex.length === 6) {
return { color: parseInt(hex, 16), alpha: 1 };
}
+ if (hex.length === 4) {
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ const a = hex[3]!;
+ const alpha = parseInt(a + a, 16) / 255;
+ return { color: parseInt(r + r + g + g + b + b, 16), alpha };
+ }
+ if (hex.length === 3) {
+ const r = hex[0]!;
+ const g = hex[1]!;
+ const b = hex[2]!;
+ return { color: parseInt(r + r + g + g + b + b, 16), alpha: 1 };
+ }
}
// rgba() fallback
const rgba = cssColor.match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*(?:\.\d+)?))?\)/);
diff --git a/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts
index 1706b6c2..37260a0e 100644
--- a/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/SearchStyleRenderer.ts
@@ -75,7 +75,13 @@ export class SearchStyleRenderer {
continue;
}
- this.renderCategoryWithSearch(gfx, rectangles, batch.color, matchedEventIds);
+ this.renderCategoryWithSearch(
+ gfx,
+ rectangles,
+ batch.color,
+ batch.alpha ?? 1,
+ matchedEventIds,
+ );
}
}
@@ -110,12 +116,14 @@ export class SearchStyleRenderer {
* @param gfx - Graphics object for this category
* @param rectangles - Rectangles to render
* @param originalColor - Original category color
+ * @param originalAlpha - Original category alpha
* @param matchedEventIds - Set of matched event IDs
*/
private renderCategoryWithSearch(
gfx: PIXI.Graphics,
rectangles: PrecomputedRect[],
originalColor: number,
+ originalAlpha: number,
matchedEventIds: ReadonlySet,
): void {
const gap = TIMELINE_CONSTANTS.RECT_GAP;
@@ -135,7 +143,7 @@ export class SearchStyleRenderer {
}
}
if (hasMatched) {
- gfx.fill({ color: originalColor });
+ gfx.fill({ color: originalColor, alpha: originalAlpha });
}
// Draw non-matched events with greyscale
@@ -151,7 +159,7 @@ export class SearchStyleRenderer {
}
}
if (hasNonMatched) {
- gfx.fill({ color: greyColor });
+ gfx.fill({ color: greyColor, alpha: originalAlpha });
}
}
diff --git a/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts b/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts
index 02d760ef..67ea6897 100644
--- a/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/SearchTextLabelRenderer.ts
@@ -16,7 +16,7 @@
*/
import { BitmapText, Container } from 'pixi.js';
-import type { ViewportState } from '../types/flamechart.types.js';
+import type { RenderBatch, ViewportState } from '../types/flamechart.types.js';
import { TEXT_LABEL_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js';
import type { PrecomputedRect } from './RectangleManager.js';
import type { TextLabelRenderer } from './TextLabelRenderer.js';
@@ -37,20 +37,26 @@ export class SearchTextLabelRenderer {
/** Labels for unmatched events keyed by rectangle ID */
private labels: Map = new Map();
+ /** Batch color data for contrast calculation */
+ private batches: Map;
+
/**
* Create a new SearchTextLabelRenderer.
*
* @param parentContainer - The worldContainer to add labels to
* @param textLabelRenderer - TextLabelRenderer instance for rendering matched labels
+ * @param batches - Batch data for calculating contrasting text colors
*/
constructor(
parentContainer: Container,
private textLabelRenderer: TextLabelRenderer,
+ batches: Map,
) {
this.container = new Container();
this.container.zIndex = TEXT_LABEL_CONSTANTS.Z_INDEX;
this.container.label = 'SearchTextLabelRenderer';
parentContainer.addChild(this.container);
+ this.batches = batches;
}
/**
@@ -174,6 +180,13 @@ export class SearchTextLabelRenderer {
label.x = labelX;
label.y = rect.y + fontYPositionOffset;
label.alpha = DIMMED_ALPHA;
+
+ // Apply contrasting text color based on background
+ const batch = this.batches.get(rect.category);
+ if (batch) {
+ label.tint = this.getContrastingTextColor(batch.color);
+ }
+
label.visible = true;
}
}
@@ -234,4 +247,31 @@ export class SearchTextLabelRenderer {
text.slice(0, startChars) + TEXT_LABEL_CONSTANTS.ELLIPSIS + text.slice(text.length - endChars)
);
}
+
+ /**
+ * Calculate contrasting text color based on background luminance.
+ * Uses W3C relative luminance formula for accessibility compliance.
+ *
+ * @param bgColor - Background color in PixiJS format (0xRRGGBB)
+ * @returns Dark text color for light backgrounds, light text color for dark backgrounds
+ */
+ private getContrastingTextColor(bgColor: number): number {
+ const r = ((bgColor >> 16) & 0xff) / 255;
+ const g = ((bgColor >> 8) & 0xff) / 255;
+ const b = (bgColor & 0xff) / 255;
+
+ // Apply gamma correction for sRGB
+ const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
+ const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
+ const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
+
+ // W3C relative luminance formula
+ const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
+
+ // Use dark text for light backgrounds, light text for dark backgrounds
+ // Threshold of 0.179 corresponds to ~50% perceived brightness
+ return luminance > 0.179
+ ? TEXT_LABEL_CONSTANTS.FONT.LIGHT_THEME_COLOR
+ : TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR;
+ }
}
diff --git a/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts b/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts
index 4fb80d5c..cb764d2f 100644
--- a/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts
+++ b/log-viewer/src/features/timeline/optimised/TextLabelRenderer.ts
@@ -23,7 +23,7 @@
*/
import { BitmapFont, BitmapText, Container } from 'pixi.js';
-import type { ViewportState } from '../types/flamechart.types.js';
+import type { RenderBatch, ViewportState } from '../types/flamechart.types.js';
import { TEXT_LABEL_CONSTANTS, TIMELINE_CONSTANTS } from '../types/flamechart.types.js';
import type { PrecomputedRect } from './RectangleManager.js';
@@ -43,6 +43,9 @@ export class TextLabelRenderer {
/** Whether the font has been loaded/created */
private fontReady = false;
+ /** Batch color data for contrast calculation */
+ private batches: Map | null = null;
+
/**
* Create a new TextLabelRenderer.
*
@@ -73,7 +76,7 @@ export class TextLabelRenderer {
style: {
fontFamily: 'monospace',
fontSize: TEXT_LABEL_CONSTANTS.FONT.SIZE * 2, // Generate at 2x for quality
- fill: TEXT_LABEL_CONSTANTS.FONT.COLOR,
+ fill: TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR,
fontWeight: 'lighter',
},
chars,
@@ -82,6 +85,15 @@ export class TextLabelRenderer {
this.fontReady = true;
}
+ /**
+ * Set batch data for dynamic text color contrast calculation.
+ *
+ * @param batches - Map of category to RenderBatch with color information
+ */
+ public setBatches(batches: Map): void {
+ this.batches = batches;
+ }
+
/**
* Update label visibility and truncation for visible rectangles.
* Creates labels lazily for rectangles that are visible AND wide enough.
@@ -166,6 +178,13 @@ export class TextLabelRenderer {
label.x = labelX;
// Position near top of rectangle (in inverted Y space)
label.y = rect.y + fontYPositionOffset;
+
+ // Apply contrasting text color based on background
+ const batch = this.batches?.get(rect.category);
+ if (batch) {
+ label.tint = this.getContrastingTextColor(batch.color);
+ }
+
label.visible = true;
}
}
@@ -231,4 +250,31 @@ export class TextLabelRenderer {
text.slice(0, startChars) + TEXT_LABEL_CONSTANTS.ELLIPSIS + text.slice(text.length - endChars)
);
}
+
+ /**
+ * Calculate contrasting text color based on background luminance.
+ * Uses W3C relative luminance formula for accessibility compliance.
+ *
+ * @param bgColor - Background color in PixiJS format (0xRRGGBB)
+ * @returns Dark text color for light backgrounds, light text color for dark backgrounds
+ */
+ private getContrastingTextColor(bgColor: number): number {
+ const r = ((bgColor >> 16) & 0xff) / 255;
+ const g = ((bgColor >> 8) & 0xff) / 255;
+ const b = (bgColor & 0xff) / 255;
+
+ // Apply gamma correction for sRGB
+ const rLin = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
+ const gLin = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
+ const bLin = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
+
+ // W3C relative luminance formula
+ const luminance = 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
+
+ // Use dark text for light backgrounds, light text for dark backgrounds
+ // Threshold of 0.179 corresponds to ~50% perceived brightness
+ return luminance > 0.179
+ ? TEXT_LABEL_CONSTANTS.FONT.LIGHT_THEME_COLOR
+ : TEXT_LABEL_CONSTANTS.FONT.DARK_THEME_COLOR;
+ }
}
diff --git a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
index a9eb9724..55a03316 100644
--- a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
+++ b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts
@@ -136,6 +136,13 @@ export class TimelineTooltipManager {
this.currentTruncationMarker = null;
}
+ /**
+ * Update category colors (used when theme changes).
+ */
+ public updateCategoryColors(colors: Record): void {
+ this.options.categoryColors = colors;
+ }
+
/**
* Display tooltip with event information.
*/
diff --git a/log-viewer/src/features/timeline/services/Timeline.ts b/log-viewer/src/features/timeline/services/Timeline.ts
index fe261932..1dbbbbf3 100644
--- a/log-viewer/src/features/timeline/services/Timeline.ts
+++ b/log-viewer/src/features/timeline/services/Timeline.ts
@@ -17,10 +17,10 @@ interface TimelineColors {
'Code Unit': '#88AE58';
Workflow: '#51A16E';
Method: '#2B8F81';
- Flow: '#337986';
- DML: '#285663';
- SOQL: '#5D4963';
- 'System Method': '#5C3444';
+ Flow: '#5C8FA6';
+ DML: '#B06868';
+ SOQL: '#6D4C7D';
+ 'System Method': '#8D6E63';
}
/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/log-viewer/src/features/timeline/themes/ThemeSelector.ts b/log-viewer/src/features/timeline/themes/ThemeSelector.ts
new file mode 100644
index 00000000..7ae1bc97
--- /dev/null
+++ b/log-viewer/src/features/timeline/themes/ThemeSelector.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+import { DEFAULT_THEME_NAME, THEMES, type TimelineColors } from './Themes.js';
+const THEME_MAP = new Map(
+ THEMES.map((theme) => [theme.name, theme.colors]),
+);
+
+const DEFAULT_THEME = THEME_MAP.get(DEFAULT_THEME_NAME)!;
+
+export function getTheme(themeName: string): TimelineColors {
+ // Merge with default to ensure all colors are present
+ const theme = THEME_MAP.get(themeName) ?? {};
+ return {
+ ...getDefault(),
+ ...Object.fromEntries(Object.entries(theme).filter(([_, v]) => v !== null && v !== undefined)),
+ };
+}
+
+export function getDefault(): TimelineColors {
+ return DEFAULT_THEME;
+}
+
+export function addCustomThemes(customThemes: { [key: string]: TimelineColors }): void {
+ for (const [name, colors] of Object.entries(customThemes)) {
+ // Skip if theme with this name already exists, avoid overriding built-in themes
+ if (THEME_MAP.has(name)) {
+ continue;
+ }
+ THEME_MAP.set(name, colors);
+ }
+}
diff --git a/log-viewer/src/features/timeline/themes/Themes.ts b/log-viewer/src/features/timeline/themes/Themes.ts
new file mode 100644
index 00000000..a85bab7c
--- /dev/null
+++ b/log-viewer/src/features/timeline/themes/Themes.ts
@@ -0,0 +1,316 @@
+/*
+ * Copyright (c) 2025 Certinia Inc. All rights reserved.
+ */
+
+interface TimelineTheme {
+ name: string;
+ colors: TimelineColors;
+}
+
+/**
+ * Note: Eventual categories will be
+ * Apex , Code Unit, System, Automation, DML, SOQL, Validation, Callout, Visualforce
+ */
+
+export interface TimelineColors {
+ codeUnit: string;
+ workflow: string;
+ method: string;
+ flow: string;
+ dml: string;
+ soql: string;
+ system: string;
+ reserved1?: string; // Callouts
+ reserved2?: string; // Validation
+ reserved3?: string; // Visualforce
+}
+
+export const DEFAULT_THEME_NAME = '50 Shades of Green';
+export const THEMES: TimelineTheme[] = [
+ {
+ name: '50 Shades of Green Bright',
+ colors: {
+ codeUnit: '#9CCC65',
+ workflow: '#66BB6A',
+ method: '#26A69A',
+ flow: '#5BA4CF',
+ dml: '#E57373',
+ soql: '#BA68C8',
+ system: '#A1887F',
+ reserved1: '#FFB74D',
+ reserved2: '#4DD0E1',
+ reserved3: '#78909C',
+ },
+ },
+ {
+ name: '50 Shades of Green',
+ colors: {
+ codeUnit: '#88AE58',
+ workflow: '#51A16E',
+ method: '#2B8F81',
+ flow: '#5C8FA6',
+ dml: '#B06868',
+ soql: '#6D4C7D',
+ system: '#8D6E63',
+ reserved1: '#CCA033',
+ reserved2: '#80CBC4',
+ reserved3: '#546E7A',
+ },
+ },
+ {
+ name: 'Botanical Twilight',
+ colors: {
+ codeUnit: '#93B376',
+ workflow: '#5CA880',
+ method: '#708B91',
+ flow: '#458593',
+ dml: '#C26D6D',
+ soql: '#8D7494',
+ system: '#666266',
+ reserved1: '#D4A76A',
+ reserved2: '#A8C2BF',
+ reserved3: '#566E7A',
+ },
+ },
+ {
+ name: 'Catppuccin',
+ colors: {
+ codeUnit: '#8AADF4',
+ workflow: '#94E2D5',
+ method: '#C6A0F6',
+ flow: '#F5A97F',
+ dml: '#F38BA8',
+ soql: '#A6DA95',
+ system: '#5B6078',
+ reserved1: '#EED49F',
+ reserved2: '#ED8796',
+ reserved3: '#F5E0DC',
+ },
+ },
+ {
+ name: 'Chrome',
+ colors: {
+ codeUnit: '#7986CB',
+ method: '#EBD272',
+ workflow: '#80CBC4',
+ flow: '#5DADE2',
+ dml: '#AF7AC5',
+ soql: '#7DCEA0',
+ system: '#CFD8DC',
+ reserved1: '#D98880',
+ reserved2: '#F0B27A',
+ reserved3: '#90A4AE',
+ },
+ },
+ {
+ name: 'Dracula',
+ colors: {
+ codeUnit: '#bd93f9',
+ workflow: '#8be9fd',
+ method: '#6272A4',
+ flow: '#FF79C6',
+ dml: '#FFB86C',
+ soql: '#50FA7B',
+ system: '#44475A',
+ reserved1: '#f1fa8c',
+ reserved2: '#FF5555',
+ reserved3: '#9580FF',
+ },
+ },
+ {
+ name: 'Dusty Aurora',
+ colors: {
+ codeUnit: '#455A64',
+ workflow: '#8CBFA2',
+ method: '#56949C',
+ flow: '#7CA5C9',
+ dml: '#D68C79',
+ soql: '#A693BD',
+ system: '#8D8078',
+ reserved1: '#CDBD7A',
+ reserved2: '#D67E7E',
+ reserved3: '#90A4AE',
+ },
+ },
+ {
+ name: 'Firefox',
+ colors: {
+ codeUnit: '#B4B4B9',
+ workflow: '#C49FCF',
+ method: '#D5C266',
+ flow: '#75B5AA',
+ dml: '#E37F81',
+ soql: '#8DC885',
+ system: '#8F8585',
+ reserved1: '#8484D1',
+ reserved2: '#E8A956',
+ reserved3: '#5283A4',
+ },
+ },
+ {
+ name: 'Flame',
+ colors: {
+ codeUnit: '#B71C1C',
+ workflow: '#E65100',
+ method: '#F57C00',
+ flow: '#FF7043',
+ dml: '#F44336',
+ soql: '#FFCA28',
+ system: '#8D6E63',
+ reserved1: '#C2185B',
+ reserved2: '#8E24AA',
+ reserved3: '#5D4037',
+ },
+ },
+ {
+ name: 'Forest Floor',
+ colors: {
+ codeUnit: '#2A9D8F',
+ workflow: '#264653',
+ method: '#6D6875',
+ flow: '#E9C46A',
+ dml: '#F4A261',
+ soql: '#E76F51',
+ system: '#455A64',
+ reserved1: '#606C38',
+ reserved2: '#BC4749',
+ reserved3: '#9C6644',
+ },
+ },
+ {
+ name: 'Garish',
+ colors: {
+ codeUnit: '#722ED1',
+ workflow: '#52C41A',
+ method: '#1890FF',
+ flow: '#00BCD4',
+ dml: '#FF9100',
+ soql: '#EB2F96',
+ system: '#90A4AE',
+ reserved1: '#F5222D',
+ reserved2: '#FFC400',
+ reserved3: '#651FFF',
+ },
+ },
+ {
+ name: 'Material',
+ colors: {
+ codeUnit: '#BA68C8',
+ workflow: '#4FC3F7',
+ method: '#676E95',
+ flow: '#FFCC80',
+ dml: '#E57373',
+ soql: '#91B859',
+ system: '#A1887F',
+ reserved1: '#F48FB1',
+ reserved2: '#9FA8DA',
+ reserved3: '#80CBC4',
+ },
+ },
+ {
+ name: 'Modern',
+ colors: {
+ codeUnit: '#6E7599',
+ workflow: '#4A918E',
+ method: '#6A7B8C',
+ flow: '#C47C46',
+ dml: '#CC5E5E',
+ soql: '#5CA376',
+ system: '#948C84',
+ reserved1: '#B86B86',
+ reserved2: '#4D8CB0',
+ reserved3: '#756CA8',
+ },
+ },
+ {
+ name: 'Monokai Pro',
+ colors: {
+ codeUnit: '#9E86C8',
+ workflow: '#FF6188',
+ method: '#7B8CA6',
+ flow: '#FFD866',
+ dml: '#FC9867',
+ soql: '#A9DC76',
+ system: '#9E938D',
+ reserved1: '#AB9DF2',
+ reserved2: '#78DCE8',
+ reserved3: '#8F8B76',
+ },
+ },
+ {
+ name: 'Nord',
+ colors: {
+ codeUnit: '#81a1c1',
+ workflow: '#b48ead',
+ method: '#5e81ac',
+ flow: '#d08770',
+ dml: '#bf616a',
+ soql: '#a3be8c',
+ system: '#4c566a',
+ reserved1: '#ebcb8b',
+ reserved2: '#88c0d0',
+ reserved3: '#8fbcbb',
+ },
+ },
+ {
+ name: 'Nord Forest',
+ colors: {
+ codeUnit: '#5E81AC',
+ workflow: '#EBCB8B',
+ method: '#7B8C7C',
+ flow: '#BF616A',
+ dml: '#D08770',
+ soql: '#B48EAD',
+ system: '#8C7B7E',
+ reserved1: '#687585',
+ reserved2: '#88C0D0',
+ reserved3: '#81A1C1',
+ },
+ },
+ {
+ name: 'Okabe-Ito',
+ colors: {
+ codeUnit: '#0072B2',
+ workflow: '#332288',
+ method: '#56B4E9',
+ flow: '#D55E00',
+ dml: '#CC79A7',
+ soql: '#009E73',
+ system: '#E69F00',
+ reserved1: '#882255',
+ reserved2: '#117733',
+ reserved3: '#AA4499',
+ },
+ },
+ {
+ name: 'Salesforce',
+
+ colors: {
+ codeUnit: '#0176D3',
+ workflow: '#CE4A6B',
+ method: '#54698D',
+ flow: '#9050E9',
+ dml: '#D68128',
+ soql: '#04844B',
+ system: '#706E6B',
+ reserved1: '#D4B753',
+ reserved2: '#C23934',
+ reserved3: '#005FB2',
+ },
+ },
+ {
+ name: 'Solarized',
+ colors: {
+ codeUnit: '#268BD2',
+ workflow: '#2AA198',
+ method: '#586E75',
+ flow: '#6C71C4',
+ dml: '#DC322F',
+ soql: '#859900',
+ system: '#B58900',
+ reserved1: '#D33682',
+ reserved2: '#CB4B16',
+ reserved3: '#93a1a1',
+ },
+ },
+];
diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts
index cbebb5d8..a52a0e72 100644
--- a/log-viewer/src/features/timeline/types/flamechart.types.ts
+++ b/log-viewer/src/features/timeline/types/flamechart.types.ts
@@ -112,6 +112,9 @@ export interface RenderBatch {
/** PixiJS color value (0xRRGGBB). */
color: number;
+ /** Alpha transparency (0-1). */
+ alpha?: number;
+
/** Rectangles to render (only visible events). */
rectangles: PrecomputedRect[];
@@ -228,10 +231,10 @@ export const TIMELINE_CONSTANTS = {
'Code Unit': '#88AE58',
Workflow: '#51A16E',
Method: '#2B8F81',
- Flow: '#337986',
- DML: '#285663',
- SOQL: '#5D4963',
- 'System Method': '#5C3444',
+ Flow: '#5C8FA6',
+ DML: '#B06868',
+ SOQL: '#6D4C7D',
+ 'System Method': '#8D6E63',
} as TimelineColorMap,
/** Maximum zoom level (0.01ms = 10 microsecond visible width in nanoseconds). */
@@ -386,7 +389,8 @@ export const TEXT_LABEL_CONSTANTS = {
// COLOR: 0xd7d7d7,
// COLOR: 0x333333,
// COLOR: 0x000000,
- COLOR: 0xe3e3e3,
+ DARK_THEME_COLOR: 0xe3e3e3,
+ LIGHT_THEME_COLOR: 0x1e1e1e,
},
} as const;
/* eslint-enable @typescript-eslint/naming-convention */