diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 9474864..1cf12cd 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -114,6 +114,7 @@ import saasApiEndpoint from './saas/api-endpoint'; import saasRoadmap from './saas/roadmap'; import saasMaintenance from './saas/maintenance'; import saasDeprecation from './saas/deprecation'; +import saasIncident from './saas/incident'; [ saasDashboardPreview, saasPricingHighlight, saasMetricShowcase, saasTestimonialBanner, @@ -125,6 +126,7 @@ import saasDeprecation from './saas/deprecation'; saasRoadmap, saasMaintenance, saasDeprecation, + saasIncident, ].forEach(registerTemplate); // GitHub diff --git a/src/templates/saas/incident.ts b/src/templates/saas/incident.ts new file mode 100644 index 0000000..b3a6214 --- /dev/null +++ b/src/templates/saas/incident.ts @@ -0,0 +1,317 @@ +import type { TemplateDefinition } from '../types'; +import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils'; + +const STEPS = ['Investigating', 'Identified', 'Monitoring', 'Resolved'] as const; + +const STATUS_INDEX: Record = { + investigating: 0, + identified: 1, + monitoring: 2, + resolved: 3, +}; + +const template: TemplateDefinition = { + id: 'saas-incident', + name: 'Incident Update', + category: 'saas', + description: 'Status-page incident card with the four progress steps filled up to the current status', + tags: ['saas', 'incident', 'status', 'outage', 'status-page'], + fields: [ + commonFields.title, + { + key: 'status', + label: 'Status', + type: 'select', + defaultValue: 'identified', + options: [ + { label: 'Investigating', value: 'investigating' }, + { label: 'Identified', value: 'identified' }, + { label: 'Monitoring', value: 'monitoring' }, + { label: 'Resolved', value: 'resolved' }, + ], + group: 'Content', + }, + { key: 'update', label: 'Latest Update', type: 'text', defaultValue: 'A fix is rolling out to all regions', placeholder: 'A fix is rolling out to all regions', group: 'Content' }, + { key: 'started', label: 'Started', type: 'text', defaultValue: 'Started 14:05 UTC', placeholder: 'Started 14:05 UTC', group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: 'Elevated API error rates', + status: 'identified', + update: 'A fix is rolling out to all regions', + started: 'Started 14:05 UTC', + siteName: 'status.acme.dev', + bgColor: '#0a0a0a', + accentColor: '#E07A5F', + textColor: '#FFFFFF', + }, + render: (params) => { + const rawTitle = String(params.title || 'Elevated API error rates').trim() || 'Elevated API error rates'; + const titleSize = autoFontSize(rawTitle, [ + { maxLen: 24, size: 64 }, + { maxLen: 40, size: 52 }, + { maxLen: 60, size: 42 }, + ]); + const title = truncate(rawTitle, 60); + const rawStatus = String(params.status || 'identified').trim().toLowerCase(); + // An unknown or blank status falls back to the default step. `Object.hasOwn` is + // needed here: a name every object inherits, like `constructor`, would otherwise + // resolve to a built-in function and slip past the fallback. + const statusIndex = Object.hasOwn(STATUS_INDEX, rawStatus) + ? STATUS_INDEX[rawStatus] + : STATUS_INDEX.identified; + const update = truncate(String(params.update || '').trim(), 90); + const started = truncate(String(params.started || '').trim(), 40); + const siteName = truncate(String(params.siteName || '').trim(), 40); + const bgColor = String(params.bgColor || '#0a0a0a'); + const accentColor = String(params.accentColor || '#E07A5F'); + const textColor = String(params.textColor || '#FFFFFF'); + // The label, the reached steps and their connectors are drawn in the accent, + // so they are darkened when the page background is light. + const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor; + + const steps = STEPS.map((label, index) => { + const reached = index <= statusIndex; + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + }, + children: [ + // Marker and the connector that runs to the next step + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + width: '100%', + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '30px', + height: '30px', + borderRadius: '50%', + boxSizing: 'border-box', + flexShrink: 0, + backgroundColor: reached ? accentInk : 'transparent', + border: `2px solid ${reached ? accentInk : `${textColor}33`}`, + }, + children: reached + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + width: '10px', + height: '10px', + borderRadius: '50%', + backgroundColor: bgColor, + }, + children: '', + }, + }, + ] + : [], + }, + }, + // The connector is filled once the next step is reached too + ...(index < STEPS.length - 1 + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + flex: 1, + height: '2px', + marginLeft: '12px', + backgroundColor: index + 1 <= statusIndex ? accentInk : `${textColor}1F`, + }, + children: '', + }, + }, + ] + : []), + ], + }, + }, + { + type: 'span', + props: { + style: { + fontSize: '17px', + fontWeight: reached ? 700 : 500, + color: reached ? textColor : `${textColor}66`, + marginTop: '14px', + }, + children: label, + }, + }, + ], + }, + }; + }); + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + width: '1200px', + height: '630px', + boxSizing: 'border-box', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '64px 80px', + }, + children: [ + // "Incident" label + { + type: 'span', + props: { + style: { + fontSize: '16px', + fontWeight: 700, + color: accentInk, + letterSpacing: '0.16em', + textTransform: 'uppercase' as const, + }, + children: 'Incident', + }, + }, + // Incident title + { + type: 'h1', + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.15, + margin: 0, + marginTop: '16px', + }, + children: title, + }, + }, + // Investigating, Identified, Monitoring, Resolved + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'flex-start', + width: '100%', + marginTop: '48px', + }, + children: steps, + }, + }, + // Latest update + ...(update + ? [ + { + type: 'p', + props: { + style: { + fontSize: '22px', + fontWeight: 500, + color: `${textColor}CC`, + lineHeight: 1.4, + margin: 0, + marginTop: '48px', + }, + children: update, + }, + }, + ] + : []), + // Started and site name + ...(started || siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginTop: update ? '18px' : '48px', + }, + children: [ + ...(started + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 600, + color: accentInk, + }, + children: started, + }, + }, + ] + : []), + ...(started && siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + width: '4px', + height: '4px', + borderRadius: '50%', + backgroundColor: `${textColor}44`, + }, + children: '', + }, + }, + ] + : []), + ...(siteName + ? [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 500, + color: `${textColor}66`, + }, + children: siteName, + }, + }, + ] + : []), + ], + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/saas/index.ts b/src/templates/saas/index.ts index ea61858..e13a68e 100644 --- a/src/templates/saas/index.ts +++ b/src/templates/saas/index.ts @@ -14,3 +14,4 @@ export { default as saasApiEndpoint } from './api-endpoint'; export { default as saasRoadmap } from './roadmap'; export { default as saasMaintenance } from './maintenance'; export { default as saasDeprecation } from './deprecation'; +export { default as saasIncident } from './incident'; diff --git a/tests/templates/incident.test.ts b/tests/templates/incident.test.ts new file mode 100644 index 0000000..ac3f0ad --- /dev/null +++ b/tests/templates/incident.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import incident from '@/templates/saas/incident'; +import { getTemplate } from '@/templates/registry'; +import type { SatoriElement } from '@/templates/types'; + +const STEP_LABELS = ['Investigating', 'Identified', 'Monitoring', 'Resolved']; + +function elements(node: unknown): SatoriElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!node || typeof node !== 'object' || !('type' in node)) return []; + + const element = node as SatoriElement; + return [element, ...elements(element.props.children)]; +} + +// Structural placeholders (empty `children`) are skipped so the text reads in order. +function textContent(node: unknown): string[] { + if (typeof node === 'string') return node === '' ? [] : [node]; + if (Array.isArray(node)) return node.flatMap(textContent); + if (node && typeof node === 'object' && 'props' in node) { + return textContent((node as SatoriElement).props.children); + } + return []; +} + +const render = (overrides: Record = {}) => + incident.render({ ...incident.defaults, ...overrides }); + +/** The circle marker for each step, in order. */ +const markers = (node: unknown) => + elements(node).filter((element) => element.type === 'div' && + element.props.style?.width === '30px' && element.props.style?.height === '30px'); + +/** The connector between each pair of steps, in order. */ +const connectors = (node: unknown) => + elements(node).filter((element) => element.type === 'div' && + element.props.style?.height === '2px' && element.props.style?.flex === 1); + +describe('saas-incident', () => { + it('is registered in the saas category and re-exported', async () => { + expect(getTemplate('saas-incident')?.category).toBe('saas'); + const exports = await import('@/templates/saas'); + expect(exports.saasIncident.id).toBe('saas-incident'); + }); + + it('renders the label, title, all four steps, the update, the start and the site name', () => { + expect(textContent(render())).toEqual([ + 'Incident', + 'Elevated API error rates', + ...STEP_LABELS, + 'A fix is rolling out to all regions', + 'Started 14:05 UTC', + 'status.acme.dev', + ]); + }); + + it('fills the steps up to the current status and leaves the rest empty', () => { + const filled = (status: string) => markers(render({ status })).map((marker) => + marker.props.style?.backgroundColor !== 'transparent'); + + expect(filled('investigating')).toEqual([true, false, false, false]); + expect(filled('identified')).toEqual([true, true, false, false]); + expect(filled('monitoring')).toEqual([true, true, true, false]); + expect(filled('resolved')).toEqual([true, true, true, true]); + }); + + it('fills a connector only once the next step is reached', () => { + const connectorColors = (status: string) => + connectors(render({ status })).map((connector) => connector.props.style?.backgroundColor); + + // Identified: one connector filled, the other two still muted + const identified = connectorColors('identified'); + expect(identified).toHaveLength(3); + expect(identified[0]).not.toMatch(/1F$/); + expect(identified[1]).toMatch(/1F$/); + expect(identified[2]).toMatch(/1F$/); + + // Resolved: every connector filled + expect(connectorColors('resolved').filter((color) => !String(color).match(/1F$/))).toHaveLength(3); + }); + + it('falls back to the default step for an unknown or blank status', () => { + const filled = (status: string) => markers(render({ status })).map((marker) => + marker.props.style?.backgroundColor !== 'transparent'); + + expect(filled('nonsense')).toEqual(filled('identified')); + expect(filled(' ')).toEqual(filled('identified')); + expect(filled('RESOLVED')).toEqual([true, true, true, true]); + }); + + it('falls back for names every object inherits instead of filling nothing', () => { + const filled = (status: string) => markers(render({ status })).map((marker) => + marker.props.style?.backgroundColor !== 'transparent'); + + // `STATUS_INDEX['constructor']` finds Object.prototype.constructor without the + // own-property guard, which used to render a card with no steps filled at all. + expect(filled('constructor')).toEqual([true, true, false, false]); + expect(filled('toString')).toEqual([true, true, false, false]); + expect(filled('hasOwnProperty')).toEqual([true, true, false, false]); + expect(filled('__proto__')).toEqual([true, true, false, false]); + }); + + it('shrinks a long title with autoFontSize and then truncates it', () => { + const heading = elements(render({ + title: 'Elevated API error rates across the EU and US regions affecting uploads and webhooks', + })).find((element) => element.type === 'h1'); + + expect(textContent(heading)[0]).toHaveLength(60); + expect(textContent(heading)[0]).toMatch(/…$/); + expect(heading?.props.style?.fontSize).toBe('42px'); + }); + + it('hides a blank update, start and site name cleanly', () => { + expect(textContent(render({ update: ' ', started: '', siteName: ' ' }))).toEqual([ + 'Incident', + 'Elevated API error rates', + ...STEP_LABELS, + ]); + }); + + it('keeps the separator out when only one of the start and site name is set', () => { + const dots = (params: Record) => elements(render(params)).filter((element) => + element.type === 'div' && element.props.style?.width === '4px' && element.props.style?.height === '4px'); + + expect(dots({ started: ' ' })).toHaveLength(0); + expect(dots({ siteName: '' })).toHaveLength(0); + expect(dots({})).toHaveLength(1); + }); + + it('keeps the label, the reached steps and the start readable on a light background', () => { + const dark = render(); + const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }); + + const label = (node: unknown) => elements(node).find((element) => + element.type === 'span' && textContent(element)[0] === 'Incident'); + const started = (node: unknown) => elements(node).find((element) => + element.type === 'span' && textContent(element)[0] === 'Started 14:05 UTC'); + + expect(label(dark)?.props.style?.color).toBe('#E07A5F'); + expect(label(light)?.props.style?.color).toBe('#8b7b27'); + expect(started(dark)?.props.style?.color).toBe('#E07A5F'); + expect(started(light)?.props.style?.color).toBe('#8b7b27'); + + // The reached markers take the darkened accent, the unreached ones stay muted + const lightMarkers = markers(light).map((marker) => marker.props.style?.backgroundColor); + expect(lightMarkers[0]).toBe('#8b7b27'); + expect(lightMarkers[3]).toBe('transparent'); + }); +});