diff --git a/packages/cli/src/ai-context/references/configure-supporting-constructs.md b/packages/cli/src/ai-context/references/configure-supporting-constructs.md index 5bfe0172..7095303e 100644 --- a/packages/cli/src/ai-context/references/configure-supporting-constructs.md +++ b/packages/cli/src/ai-context/references/configure-supporting-constructs.md @@ -21,6 +21,7 @@ - A v3 status page has no cards or services. Its structure is declared with `StatusPageV3Component` constructs that point at the page via `statusPage`; nest a `SERVICE` under a `GROUP` via `parent`. - `StatusPageV3AutomationRule` opens one incident impacting the listed components when a check whose tags overlap with the rule's `tags` fails, and resolves it on recovery. Requires the automated incident management add-on. - A logical id deployed as a `StatusPage` cannot be redeployed as a `StatusPageV3` (or vice versa); use a new logical id. +- `themeColors` overrides the page's colors per theme (`light` and `dark`) with hex strings; only the colors you set change, the rest keep the defaults. Requires custom theme colors on the plan. - Type-specific settings are top-level props checked against `type`: both types take `showHistoricalData` (default `true`); only a `GROUP` takes `expandedByDefault` (default `false`). Omit them to keep the defaults. ```ts @@ -31,6 +32,10 @@ const statusPage = new StatusPageV3('example-status-page-v3', { url: 'example-status-page-v3', customDomain: 'status.example.com', defaultTheme: 'AUTO', + themeColors: { + light: { linkFontColor: '#005AC2', primaryButtonBackgroundColor: '#151A1E' }, + dark: { linkFontColor: '#248AFF' }, + }, }) const webApp = new StatusPageV3Component('example-web-app-group', { diff --git a/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts b/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts index 6ecf5a20..eba95bc1 100644 --- a/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts +++ b/packages/cli/src/constructs/__tests__/status-page-v3-codegen.spec.ts @@ -152,6 +152,37 @@ describe('StatusPageV3 codegen', () => { expect(ruleSource).toContain('targetImpact: \'MAJOR_OUTAGE\'') }) + it('generates the stored theme colors per theme', async () => { + const sources = await generate(rootDirectory, [ + { + type: 'status-page', + logicalId: 'acme', + payload: { + ...page, + themeColors: { + light: { linkFontColor: '#005AC2', primaryButtonBackgroundColor: '#151A1E' }, + dark: { bodyBackgroundColor: '#14171C' }, + }, + }, + }, + ]) + + const source = sources['resources/status-pages/acme-status.check.ts'] + expect(source).toMatch(/themeColors: \{\s*light: \{\s*linkFontColor: '#005AC2',/) + expect(source).toMatch(/primaryButtonBackgroundColor: '#151A1E'/) + expect(source).toMatch(/dark: \{\s*bodyBackgroundColor: '#14171C'/) + }) + + it('leaves theme colors out when the page has none', async () => { + const sources = await generate(rootDirectory, [ + { type: 'status-page', logicalId: 'acme', payload: { ...page, themeColors: null } }, + { type: 'status-page', logicalId: 'acme-empty', payload: { ...page, name: 'Empty', url: 'empty', themeColors: { light: {} } } }, + ]) + + expect(sources['resources/status-pages/acme-status.check.ts']).not.toContain('themeColors') + expect(sources['resources/status-pages/empty.check.ts']).not.toContain('themeColors') + }) + it('leaves settings that only restate the backend defaults implicit', async () => { const sources = await generate(rootDirectory, [ { diff --git a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts index acd34155..c785e3de 100644 --- a/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts +++ b/packages/cli/src/constructs/__tests__/status-page-v3.spec.ts @@ -41,6 +41,72 @@ describe('StatusPageV3', () => { expect(page.synthesize()).not.toHaveProperty('cards') }) + it('synthesizes partial theme colors as given and accepts them', async () => { + const page = new StatusPageV3('acme', { + name: 'ACME', + url: 'acme-status', + themeColors: { + light: { linkFontColor: '#005AC2', primaryButtonBackgroundColor: '#f00' }, + dark: { bodyBackgroundColor: '#000' }, + }, + }) + + // Only what is set: the backend fills the defaults for the rest. + expect(page.synthesize().themeColors).toEqual({ + light: { linkFontColor: '#005AC2', primaryButtonBackgroundColor: '#f00' }, + dark: { bodyBackgroundColor: '#000' }, + }) + + const diagnostics = new Diagnostics() + await page.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(false) + }) + + it('leaves theme colors out when not set', async () => { + const page = new StatusPageV3('acme', { name: 'ACME', url: 'acme-status' }) + expect(page.synthesize().themeColors).toBeUndefined() + + const diagnostics = new Diagnostics() + await page.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(false) + }) + + it('rejects theme colors that are not hex colors', async () => { + const page = new StatusPageV3('acme', { + name: 'ACME', + url: 'acme-status', + themeColors: { light: { linkFontColor: 'red' }, dark: { borderColor: '#12345' } }, + }) + + const diagnostics = new Diagnostics() + await page.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + const messages = diagnostics.observations.map(o => o.message) + expect(messages).toEqual(expect.arrayContaining([ + expect.stringContaining('themeColors.light.linkFontColor'), + expect.stringContaining('themeColors.dark.borderColor'), + ])) + }) + + it('rejects unknown color properties and malformed groups', async () => { + // Loosely typed on purpose: TypeScript already stops this. + const props: any = { + name: 'ACME', + url: 'acme-status', + themeColors: { light: { linkColor: '#000' }, dark: 'black' }, + } + const page = new StatusPageV3('acme', props) + + const diagnostics = new Diagnostics() + await page.validate(diagnostics) + expect(diagnostics.isFatal()).toBe(true) + const messages = diagnostics.observations.map(o => o.message) + expect(messages).toEqual(expect.arrayContaining([ + expect.stringContaining('themeColors.light.linkColor'), + expect.stringContaining('themeColors.dark'), + ])) + }) + it('should produce a diagnostic if the same logicalId is used twice', async () => { const project = newProject() diff --git a/packages/cli/src/constructs/internal/status-page-v3-theme-colors.ts b/packages/cli/src/constructs/internal/status-page-v3-theme-colors.ts new file mode 100644 index 00000000..c842805a --- /dev/null +++ b/packages/cli/src/constructs/internal/status-page-v3-theme-colors.ts @@ -0,0 +1,25 @@ +import type { StatusPageV3ThemeColorGroup } from '../status-page-v3.js' + +// Typed against the public group so a colour added to one cannot be missed +// in the other; the backend palette has the same twelve entries. +const properties: Record = { + bodyBackgroundColor: true, + headerBackgroundColor: true, + headerFontColor: true, + titleFontColor: true, + bodyFontColor: true, + bodyFontColorMuted: true, + navigationFontColor: true, + linkFontColor: true, + cardBackgroundColor: true, + borderColor: true, + primaryButtonBackgroundColor: true, + primaryButtonFontColor: true, +} + +export const statusPageV3ThemeColorProperties = Object.keys(properties) as (keyof StatusPageV3ThemeColorGroup)[] + +export const statusPageV3Themes = ['light', 'dark'] as const + +// Same format the backend accepts: #RGB or #RRGGBB, case-insensitive. +export const hexColorPattern = /^#([0-9A-F]{3}|[0-9A-F]{6})$/i diff --git a/packages/cli/src/constructs/status-page-v3-codegen.ts b/packages/cli/src/constructs/status-page-v3-codegen.ts index 7a4628b6..74a4c9bf 100644 --- a/packages/cli/src/constructs/status-page-v3-codegen.ts +++ b/packages/cli/src/constructs/status-page-v3-codegen.ts @@ -1,6 +1,8 @@ import { Codegen, Context } from './internal/codegen/index.js' +import { statusPageV3ThemeColorProperties, statusPageV3Themes } from './internal/status-page-v3-theme-colors.js' import { decl, expr, GeneratedFile, ident, Value } from '../sourcegen/index.js' import { StatusPageTheme } from './status-page.js' +import type { StatusPageV3ThemeColors } from './status-page-v3.js' export interface StatusPageV3Resource { id: string @@ -20,10 +22,34 @@ export interface StatusPageV3Resource { footerText?: string | null googleAnalyticsTag?: string | null allowIndexing?: boolean | null + themeColors?: StatusPageV3ThemeColors | null } const construct = 'StatusPageV3' +// The stored palette is complete, so every color is generated as-is: the CLI +// does not know the backend defaults and must not guess which ones to omit. +function themeColorEntries (themeColors: StatusPageV3ThemeColors): Array<[string, Array<[string, string]>]> { + const themes: Array<[string, Array<[string, string]>]> = [] + for (const theme of statusPageV3Themes) { + const colors = themeColors[theme] + if (!colors) { + continue + } + const entries: Array<[string, string]> = [] + for (const property of statusPageV3ThemeColorProperties) { + const value = colors[property] + if (value) { + entries.push([property, value]) + } + } + if (entries.length > 0) { + themes.push([theme, entries]) + } + } + return themes +} + export function valueForStatusPageV3FromId (genfile: GeneratedFile, physicalId: string): Value { genfile.namedImport(construct, 'checkly/constructs') @@ -143,6 +169,19 @@ export class StatusPageV3Codegen extends Codegen { if (resource.allowIndexing === false) { builder.boolean('allowIndexing', false) } + + const themes = resource.themeColors ? themeColorEntries(resource.themeColors) : [] + if (themes.length > 0) { + builder.object('themeColors', builder => { + for (const [theme, colors] of themes) { + builder.object(theme, builder => { + for (const [property, value] of colors) { + builder.string(property, value) + } + }) + } + }) + } }) }) })) diff --git a/packages/cli/src/constructs/status-page-v3.ts b/packages/cli/src/constructs/status-page-v3.ts index d3729cfe..e313897a 100644 --- a/packages/cli/src/constructs/status-page-v3.ts +++ b/packages/cli/src/constructs/status-page-v3.ts @@ -1,9 +1,86 @@ import { Construct } from './construct.js' +import { InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' import { Diagnostics } from './diagnostics.js' import { validatePhysicalIdIsUuid } from './internal/common-diagnostics.js' +import { + hexColorPattern, + statusPageV3ThemeColorProperties, + statusPageV3Themes, +} from './internal/status-page-v3-theme-colors.js' import { Session } from './session.js' import type { StatusPageTheme } from './status-page.js' +/** + * The colors of one theme (light or dark) of a v3 status page. Each color is + * a hex string such as `#FF0000` or `#F00`. Any color left out keeps + * Checkly's default for that theme. + */ +export interface StatusPageV3ThemeColorGroup { + /** + * Background of the page. + */ + bodyBackgroundColor?: string + /** + * Background of the page header. + */ + headerBackgroundColor?: string + /** + * Text in the page header. + */ + headerFontColor?: string + /** + * Titles and headings. + */ + titleFontColor?: string + /** + * Regular body text. + */ + bodyFontColor?: string + /** + * De-emphasized body text, such as timestamps. + */ + bodyFontColorMuted?: string + /** + * Navigation links. + */ + navigationFontColor?: string + /** + * Links in the page content. + */ + linkFontColor?: string + /** + * Background of component and incident cards. + */ + cardBackgroundColor?: string + /** + * Borders and dividers. + */ + borderColor?: string + /** + * Background of primary buttons, such as "Subscribe". + */ + primaryButtonBackgroundColor?: string + /** + * Text of primary buttons. + */ + primaryButtonFontColor?: string +} + +/** + * Custom colors of a v3 status page, per theme. Either theme can be left out + * to keep its defaults. + */ +export interface StatusPageV3ThemeColors { + /** + * Colors used when the page renders in light mode. + */ + light?: StatusPageV3ThemeColorGroup + /** + * Colors used when the page renders in dark mode. + */ + dark?: StatusPageV3ThemeColorGroup +} + export interface StatusPageV3Props { /** * The name of the status page. @@ -65,6 +142,12 @@ export interface StatusPageV3Props { * Whether search engines may index the public page. Defaults to true. */ allowIndexing?: boolean + /** + * Custom colors for the light and dark theme of the page. Only the colors + * you set are changed; the rest keep Checkly's defaults. Requires custom + * theme colors to be part of your plan. + */ + themeColors?: StatusPageV3ThemeColors } /** @@ -122,6 +205,7 @@ export class StatusPageV3 extends Construct { footerText?: string googleAnalyticsTag?: string allowIndexing?: boolean + themeColors?: StatusPageV3ThemeColors // Same resource type as the v2 page: both live in one table and are told // apart by the `version` discriminator synthesized below. @@ -152,6 +236,7 @@ export class StatusPageV3 extends Construct { this.footerText = props.footerText this.googleAnalyticsTag = props.googleAnalyticsTag this.allowIndexing = props.allowIndexing + this.themeColors = props.themeColors Session.registerConstruct(this) } @@ -160,6 +245,61 @@ export class StatusPageV3 extends Construct { return `StatusPageV3:${this.logicalId}` } + async validate (diagnostics: Diagnostics): Promise { + await super.validate(diagnostics) + this.validateThemeColors(diagnostics) + } + + // TypeScript already rejects unknown colors and non-string values; this + // repeats the check at runtime for JavaScript users and loosely typed + // objects, so a typo is an error here rather than silently dropped by the + // backend. + private validateThemeColors (diagnostics: Diagnostics): void { + if (this.themeColors === undefined) { + return + } + + if (typeof this.themeColors !== 'object' || this.themeColors === null) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'themeColors', + new Error('Value must be an object with optional "light" and "dark" color groups.'), + )) + return + } + + for (const theme of statusPageV3Themes) { + const colors = this.themeColors[theme] + if (colors === undefined) { + continue + } + + if (typeof colors !== 'object' || colors === null) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + `themeColors.${theme}`, + new Error('Value must be an object of color properties.'), + )) + continue + } + + for (const [property, value] of Object.entries(colors)) { + if (value === undefined) { + continue + } + if (!(statusPageV3ThemeColorProperties as string[]).includes(property)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + `themeColors.${theme}.${property}`, + new Error(`Unknown color. Supported colors: ${statusPageV3ThemeColorProperties.join(', ')}.`), + )) + } else if (typeof value !== 'string' || !hexColorPattern.test(value)) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + `themeColors.${theme}.${property}`, + new Error('Value must be a hex color such as "#FF0000" or "#F00".'), + )) + } + } + } + } + /** * @param id - The UUID of the existing v3 status page */ @@ -184,6 +324,7 @@ export class StatusPageV3 extends Construct { footerText: this.footerText, googleAnalyticsTag: this.googleAnalyticsTag, allowIndexing: this.allowIndexing, + themeColors: this.themeColors, version: 3, } }