From 639b954b723e58f6f8063effd59ef5b084f3a6eb Mon Sep 17 00:00:00 2001 From: Sanketh Kumar Date: Tue, 27 Oct 2026 16:54:52 +0530 Subject: [PATCH 1/2] feat(templates): add incident update saas template Adds saas-incident: an Incident label in the accent, the title shrunk with autoFontSize before truncating, the Investigating/Identified/Monitoring/Resolved steps joined by a line with the reached steps filled, the latest update, the start time and the site name. An unknown or blank status falls back to identified, Resolved fills all four steps, and blank optional fields drop out cleanly. Accent text is darkened on light backgrounds. Closes #282 --- src/templates/registry.ts | 2 + src/templates/saas/incident.ts | 313 +++++++++++++++++++++++++++++++ src/templates/saas/index.ts | 1 + tests/templates/incident.test.ts | 137 ++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 src/templates/saas/incident.ts create mode 100644 tests/templates/incident.test.ts 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..c12647f --- /dev/null +++ b/src/templates/saas/incident.ts @@ -0,0 +1,313 @@ +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. + const statusIndex = 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..47731d3 --- /dev/null +++ b/tests/templates/incident.test.ts @@ -0,0 +1,137 @@ +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('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'); + }); +}); From 57c0ec5f00f4b499e9ae926e1560231f2d760b01 Mon Sep 17 00:00:00 2001 From: Sanketh Kumar Date: Tue, 27 Oct 2026 17:35:57 +0530 Subject: [PATCH 2/2] fix(saas-incident): fall back for status names inherited from Object.prototype STATUS_INDEX['constructor'] resolved to Object.prototype.constructor, so the ?? fallback never fired and every step comparison was false, rendering a card with no steps filled. Guard the lookup with Object.hasOwn, the same way saas/maintenance.ts does, and cover constructor, toString, hasOwnProperty and __proto__ in the fallback test. --- src/templates/saas/incident.ts | 8 ++++++-- tests/templates/incident.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/templates/saas/incident.ts b/src/templates/saas/incident.ts index c12647f..b3a6214 100644 --- a/src/templates/saas/incident.ts +++ b/src/templates/saas/incident.ts @@ -57,8 +57,12 @@ const template: TemplateDefinition = { ]); const title = truncate(rawTitle, 60); const rawStatus = String(params.status || 'identified').trim().toLowerCase(); - // An unknown or blank status falls back to the default step. - const statusIndex = STATUS_INDEX[rawStatus] ?? STATUS_INDEX.identified; + // 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); diff --git a/tests/templates/incident.test.ts b/tests/templates/incident.test.ts index 47731d3..ac3f0ad 100644 --- a/tests/templates/incident.test.ts +++ b/tests/templates/incident.test.ts @@ -88,6 +88,18 @@ describe('saas-incident', () => { 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',