MEGAINFRA-WEBAPP/foreman/mod/hooks/mascot.ts
DEls e97bf93c7d chore: add foreman scheduler mod; keep it out of the app type-check
foreman/ is a Claude Code mod (job runner + hooks) that imports the
claude-code runtime types, so tsc over the app failed on it. Excluding
it from tsconfig keeps the Next build green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:50 +02:00

142 lines
7.7 KiB
TypeScript

export type Mood = 'idle' | 'running' | 'clear' | 'attention' | 'failed'
export type Theme = {
name: string
bg: string // pane background
panel: string // card background
chip: string // chip / column background
line: string // borders
text: string // main text
sub: string // secondary text
muted: string // hints
accent: string // headings, primary
good: string // clear / done
warn: string // needs attention
bad: string // failed
run: string // running
/** Dark themes only: the light chip buttons and fields sit on, since the app draws their text dark. */
control?: string
}
// Bright by default; Dracula and Monokai for the evenings.
export const THEMES = {
hivis: { name: 'Hi-Vis', bg: '#FFFCF2', panel: '#FFFFFF', chip: '#FFF3C4', line: '#EED68A', text: '#23201A', sub: '#4D473A', muted: '#8A8270', accent: '#D99100', good: '#2E9E5B', warn: '#E8590C', bad: '#D6336C', run: '#1C7ED6' },
sky: { name: 'Sky', bg: '#F3F8FF', panel: '#FFFFFF', chip: '#E3EEFF', line: '#BCD3F5', text: '#142338', sub: '#3A4E6B', muted: '#7A8BA3', accent: '#2F80ED', good: '#12A37A', warn: '#F76707', bad: '#E03131', run: '#7048E8' },
mint: { name: 'Mint', bg: '#F1FBF6', panel: '#FFFFFF', chip: '#DDF5E8', line: '#A8DFC1', text: '#12261C', sub: '#345244', muted: '#6F8F80', accent: '#0CA678', good: '#2B8A3E', warn: '#F08C00', bad: '#E8590C', run: '#1C7ED6' },
sunrise: { name: 'Sunrise', bg: '#FFF6F2', panel: '#FFFFFF', chip: '#FFE6DC', line: '#F7C2AE', text: '#2B1A14', sub: '#5A3A2E', muted: '#9A7A6E', accent: '#FF6B4A', good: '#2F9E44', warn: '#F59F00', bad: '#D6336C', run: '#4263EB' },
grape: { name: 'Grape', bg: '#F8F5FF', panel: '#FFFFFF', chip: '#EDE6FF', line: '#CFC0F5', text: '#1F1636', sub: '#463A66', muted: '#8A80A6', accent: '#7950F2', good: '#12A37A', warn: '#FD7E14', bad: '#FA5252', run: '#339AF0' },
dracula: { name: 'Dracula', bg: '#282A36', panel: '#2F3242', chip: '#383A4C', line: '#6272A4', text: '#F8F8F2', sub: '#E9E9F0', muted: '#A4ACD6', accent: '#BD93F9', good: '#50FA7B', warn: '#FFB86C', bad: '#FF5555', run: '#8BE9FD', control: '#ECE4FF' },
monokai: { name: 'Monokai', bg: '#272822', panel: '#2F302A', chip: '#3E3D32', line: '#75715E', text: '#F8F8F2', sub: '#E6E6DC', muted: '#B5AF96', accent: '#E6DB74', good: '#A6E22E', warn: '#FD971F', bad: '#F92672', run: '#66D9EF', control: '#F5F1D3' },
} satisfies Record<string, Theme>
export type ThemeId = keyof typeof THEMES
export const THEME_ORDER = Object.keys(THEMES) as ThemeId[]
export const isThemeId = (v: unknown): v is ThemeId => typeof v === 'string' && v in THEMES
export const MOOD_LABEL: Record<Mood, string> = {
idle: 'On site, waiting for the next job',
running: 'Out checking paperwork',
clear: 'All clear on the last run',
attention: 'Found things that need a look',
failed: 'A job hit a problem',
}
// Terminal fallback: the block-glyph critter in a hard hat.
export const ASCII: Record<Mood, string> = {
idle: ' ▄██▄\n▐▛█M█▜▌ z',
running: ' ▄██▄\n▐▛█M█▜▌ ~',
clear: ' ▄██▄\n▐▛█M█▜▌ ✓',
attention: ' ▄██▄\n▐▛█M█▜▌ !',
failed: ' ▄██▄\n▐▛█M█▜▌ ✗',
}
const CRAB = { body: '#3F7FB8', light: '#7DB1DE', dark: '#2A5A86', eye: '#F7F3EA', ink: '#1E1A16' }
const HAT = { main: '#FFC21A', shade: '#E0A100', shine: '#FFE27A' }
const VEST = { main: '#FF7A1A', strip: '#E8EEF2' }
const px = (x: number, y: number, w: number, h: number, fill: string, extra = '') =>
`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
const bob = (dy: number, dur: string, begin = '0s') =>
`<animateTransform attributeName="transform" type="translate" values="0 0;0 ${dy};0 0" dur="${dur}" begin="${begin}" repeatCount="indefinite" calcMode="discrete"/>`
const M_ROWS = ['X...X', 'XX.XX', 'X.X.X', 'X...X']
const monogram = (x0: number, y0: number, fill: string) =>
M_ROWS.flatMap((row, r) => [...row].map((c, col) => (c === 'X' ? px(x0 + col * 0.5, y0 + r * 0.5, 0.5, 0.5, fill) : ''))).join('')
/** Foreman: the blue pixel critter in a yellow hard hat and hi-vis vest. */
export function foremanSvg(mood: Mood, t: Theme): string {
const running = mood === 'running'
const idle = mood === 'idle'
const happy = mood === 'clear'
const alert = mood === 'attention'
const sad = mood === 'failed'
const body = CRAB.body
const eye = CRAB.eye
const eyes = idle
? px(4, 3.5, 1, 0.5, CRAB.ink) + px(11, 3.5, 1, 0.5, CRAB.ink)
: happy
? px(4, 2.5, 1, 0.5, eye) + px(3.5, 3, 0.5, 0.5, eye) + px(5, 3, 0.5, 0.5, eye) +
px(11, 2.5, 1, 0.5, eye) + px(10.5, 3, 0.5, 0.5, eye) + px(12, 3, 0.5, 0.5, eye)
: sad
? px(4, 2.5, 1, 1.5, eye) + px(11, 2.5, 1, 1.5, eye) + px(3.5, 2, 1.5, 0.5, CRAB.dark) + px(11, 2, 1.5, 0.5, CRAB.dark)
: [4, 11]
.map(x =>
running
? `<rect x="${x}" y="2" width="1" height="2" fill="${eye}">
<animate attributeName="height" values="2;2;0.3;2" keyTimes="0;0.9;0.95;1" dur="3s" repeatCount="indefinite"/>
<animate attributeName="y" values="2;2;3.7;2" keyTimes="0;0.9;0.95;1" dur="3s" repeatCount="indefinite"/></rect>`
: px(x, alert ? 1.5 : 2, 1, alert ? 2.5 : 2, eye),
)
.join('')
const legsA = px(3, 8, 1, 2, body) + px(10, 8, 1, 2, body)
const legsB = px(5, 8, 1, 2, body) + px(12, 8, 1, 2, body)
// Hard hat: dome, ridge, shine and a wide brim over the top of the body.
const hat =
px(4, -3, 8, 0.5, HAT.main) + px(3, -2.5, 10, 2, HAT.main) + px(7.5, -3.5, 1, 3, HAT.shade) +
px(4, -2.5, 1.5, 0.5, HAT.shine) + px(1, -0.5, 14, 0.75, HAT.main) + px(1, 0.25, 14, 0.25, HAT.shade)
// Hi-vis vest straps with reflective strips.
const vest =
px(2, 4.5, 2.5, 3.5, VEST.main) + px(11.5, 4.5, 2.5, 3.5, VEST.main) +
px(2, 6, 2.5, 0.5, VEST.strip) + px(11.5, 6, 2.5, 0.5, VEST.strip)
const extras = idle
? `<g>${px(16, -2, 1, 0.25, t.muted)}${px(16.75, -1.75, 0.25, 0.5, t.muted)}${px(16, -1.25, 1, 0.25, t.muted)}
<animate attributeName="opacity" values="0;1;0" dur="2.6s" repeatCount="indefinite"/></g>`
: running
? [0, 0.8, 1.6]
.map(
(d, i) => `<rect x="${16 + (i % 2) * 0.6}" y="2" width="0.5" height="0.5" fill="${CRAB.light}">
<animate attributeName="y" values="2;-2.5" dur="2.4s" begin="${d}s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;0" dur="2.4s" begin="${d}s" repeatCount="indefinite"/></rect>`,
)
.join('')
: happy
? `<g>${px(16.5, -3, 0.5, 1.5, t.good)}${px(16, -2.5, 1.5, 0.5, t.good)}
<animate attributeName="opacity" values="1;0.2;1" dur="1.4s" repeatCount="indefinite"/></g>
<g>${px(-0.5, -2.5, 0.5, 1, HAT.main)}${px(-0.75, -2.25, 1, 0.5, HAT.main)}
<animate attributeName="opacity" values="0.2;1;0.2" dur="1.4s" repeatCount="indefinite"/></g>`
: alert
? `<g>${px(16.25, -3.5, 1, 2.5, t.warn)}${px(16.25, -0.5, 1, 1, t.warn)}
<animate attributeName="opacity" values="1;0.35;1" dur="1s" repeatCount="indefinite"/></g>`
: px(5.25, 4.5, 0.5, 0.75, CRAB.light) + px(12.25, 4.5, 0.5, 0.75, CRAB.light)
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -5 21 16" width="132" height="100" shape-rendering="crispEdges">
${px(1, 10.4, 14, 0.4, t.muted, ' opacity="0.25"')}
<g>${running ? bob(-0.25, '0.8s') : ''}
<g>${running ? bob(-0.5, '0.4s') : ''}${legsA}</g>
<g>${running ? bob(-0.5, '0.4s', '0.2s') : ''}${legsB}</g>
<g>${running ? bob(-0.5, '0.6s') : ''}${px(0, 4, 2, 2, body)}${px(14, 4, 2, 2, body)}</g>
${px(2, 0, 12, 8, body)}
${vest}
${eyes}
${monogram(6.75, 5, HAT.main)}
${hat}
</g>
${extras}
</svg>`
}