headreel 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { longestStreak as coreLongestStreak } from '../../core/streaks.js';
1
2
  import { paletteOf } from './palette.js';
2
3
  /**
3
4
  * Where the reel lives: four cards on one desk, filmed by one camera.
@@ -54,16 +55,7 @@ const NEUTRAL = { light: [102, 106, 124], dark: [140, 146, 168] };
54
55
  const CARD_ORDER = ['contributions', 'top_repo', 'pull_requests', 'languages'];
55
56
  /** Longest run of consecutive days with at least one contribution. */
56
57
  export function longestStreak(contributions) {
57
- let best = 0;
58
- let run = 0;
59
- for (const week of contributions.weeks) {
60
- for (const day of week) {
61
- run = day.count > 0 ? run + 1 : 0;
62
- if (run > best)
63
- best = run;
64
- }
65
- }
66
- return best;
58
+ return coreLongestStreak(contributions.weeks.flat().map((day) => day.count));
67
59
  }
68
60
  /** "2026-03-28" .. "2026-04-03" -> "Mar 28 to Apr 3". */
69
61
  function dateRange(from, to) {
@@ -1,9 +1,13 @@
1
1
  import { contributionCity } from './contribution-city/index.js';
2
2
  import { highlightsReel } from './highlights-reel/index.js';
3
+ import { nowPlaying } from './now-playing/index.js';
4
+ import { receipt } from './receipt/index.js';
3
5
  import { repoGalaxy } from './repo-galaxy/index.js';
4
6
  /** Built-in styles, keyed by id. */
5
7
  export const styles = {
6
8
  [contributionCity.id]: contributionCity,
7
9
  [highlightsReel.id]: highlightsReel,
10
+ [nowPlaying.id]: nowPlaying,
11
+ [receipt.id]: receipt,
8
12
  [repoGalaxy.id]: repoGalaxy,
9
13
  };
@@ -0,0 +1,105 @@
1
+ import { paletteOf } from './palette.js';
2
+ /** 8 lyric lines at 2 s each, at 25 fps. */
3
+ export const FRAMES = 400;
4
+ /** Cassette hub and the fullest tape pack, px. */
5
+ export const HUB_R = 26;
6
+ export const PACK_MAX = 40;
7
+ const COUNTER_MAX = 999;
8
+ function hexToRgb(hex) {
9
+ const n = Number.parseInt(hex.slice(1), 16);
10
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
11
+ }
12
+ function rgbToHsl([r, g, b]) {
13
+ const [rn, gn, bn] = [r / 255, g / 255, b / 255];
14
+ const max = Math.max(rn, gn, bn);
15
+ const min = Math.min(rn, gn, bn);
16
+ const l = (max + min) / 2;
17
+ if (max === min)
18
+ return [0, 0, l];
19
+ const d = max - min;
20
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
21
+ let h;
22
+ if (max === rn)
23
+ h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
24
+ else if (max === gn)
25
+ h = ((bn - rn) / d + 2) / 6;
26
+ else
27
+ h = ((rn - gn) / d + 4) / 6;
28
+ return [h, s, l];
29
+ }
30
+ function hslToRgb(h, s, l) {
31
+ if (s === 0) {
32
+ const v = Math.round(l * 255);
33
+ return [v, v, v];
34
+ }
35
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
36
+ const p = 2 * l - q;
37
+ const channel = (t) => {
38
+ if (t < 0)
39
+ t += 1;
40
+ if (t > 1)
41
+ t -= 1;
42
+ if (t < 1 / 6)
43
+ return p + (q - p) * 6 * t;
44
+ if (t < 1 / 2)
45
+ return q;
46
+ if (t < 2 / 3)
47
+ return p + (q - p) * (2 / 3 - t) * 6;
48
+ return p;
49
+ };
50
+ return [
51
+ Math.round(channel(h + 1 / 3) * 255),
52
+ Math.round(channel(h) * 255),
53
+ Math.round(channel(h - 1 / 3) * 255),
54
+ ];
55
+ }
56
+ /**
57
+ * The band's caption is small white type, so the band is held to a darkness
58
+ * pale linguist colors reach after adjustment and dark ones keep their hue.
59
+ */
60
+ export function languageBandColor(hex) {
61
+ if (!hex)
62
+ return [90, 96, 110];
63
+ const [h, s, l] = rgbToHsl(hexToRgb(hex));
64
+ return hslToRgb(h, Math.min(s, 0.8), Math.min(Math.max(l, 0.3), 0.45));
65
+ }
66
+ /** Tape pack radius for a share of the tape, by area. */
67
+ export function packRadius(share) {
68
+ const s = Math.min(1, Math.max(0, share));
69
+ return Math.sqrt(HUB_R ** 2 + s * (PACK_MAX ** 2 - HUB_R ** 2));
70
+ }
71
+ function reel(share, phase) {
72
+ const radius = packRadius(share);
73
+ return { radius, revs: Math.max(1, Math.round(80 / radius)), phase };
74
+ }
75
+ /** Meter heights: sqrt scaling, so a quiet day next to a busy one still shows. */
76
+ export function meterBars(days) {
77
+ const max = Math.max(1, ...days);
78
+ return days.map((c) => (c > 0 ? Math.sqrt(c / max) : 0));
79
+ }
80
+ export function buildDeck(data, options, rng) {
81
+ const brush = [];
82
+ for (let i = 0; i < 90; i++) {
83
+ brush.push({
84
+ y: rng(),
85
+ x0: rng() * 0.6,
86
+ x1: 0.4 + rng() * 0.6,
87
+ alpha: 0.03 + rng() * 0.03,
88
+ });
89
+ }
90
+ return {
91
+ palette: paletteOf(options),
92
+ status: data.status,
93
+ repo: data.repo ? { owner: data.repo.owner, name: data.repo.name } : null,
94
+ bandColor: languageBandColor(data.repo?.language?.color),
95
+ languageName: data.repo?.language?.name ?? null,
96
+ counter: Math.min(COUNTER_MAX, data.commits),
97
+ bars: meterBars(data.days),
98
+ lyrics: data.lyrics,
99
+ takeUp: reel(data.share, rng()),
100
+ supply: reel(1 - data.share, rng()),
101
+ brush,
102
+ screws: [rng() * Math.PI, rng() * Math.PI, rng() * Math.PI, rng() * Math.PI],
103
+ labelTilt: (rng() - 0.5) * 0.04,
104
+ };
105
+ }
@@ -0,0 +1,16 @@
1
+ import { fetchNowPlaying, nowPlayingSchema } from '../../core/data/now-playing.js';
2
+ import { buildDeck, FRAMES } from './deck.js';
3
+ import { options } from './options.js';
4
+ import { paletteOf, pinnedOf } from './palette.js';
5
+ import { createDeckSketch } from './sketch.js';
6
+ export const nowPlaying = {
7
+ id: 'now-playing',
8
+ fps: 25,
9
+ frames: FRAMES,
10
+ pinned: (options) => pinnedOf(paletteOf(options)),
11
+ data: { name: 'nowplaying', schema: nowPlayingSchema, fetch: fetchNowPlaying },
12
+ options,
13
+ createSketch({ data, options, identity, rng }) {
14
+ return createDeckSketch(buildDeck(data, options, rng), identity);
15
+ },
16
+ };
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ export const THEMES = ['dark', 'light'];
3
+ export const ACCENTS = ['cyan', 'green', 'orange'];
4
+ export const options = z
5
+ .object({
6
+ /** The deck and the room: graphite on dark, or a silver deck on light. */
7
+ theme: z.enum(THEMES).default('dark'),
8
+ /** Display phosphor, level meter, play LED, prompt and links. Real VFD colors only. */
9
+ accent: z.enum(ACCENTS).default('cyan'),
10
+ /** Comma-separated repositories never picked, e.g. the profile repo itself. */
11
+ exclude: z
12
+ .string()
13
+ .default('')
14
+ .transform((v) => v
15
+ .split(',')
16
+ .map((n) => n.trim())
17
+ .filter(Boolean)),
18
+ })
19
+ .strict();
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Accent presets: the three colors real VFDs came in. The phosphor value
3
+ * passes 4.5:1 on the display glass; the light theme's darker value passes
4
+ * 4.5:1 on its room (both asserted by a test).
5
+ */
6
+ export const ACCENT_VALUES = {
7
+ cyan: [34, 211, 238],
8
+ green: [52, 211, 153],
9
+ orange: [251, 146, 60],
10
+ };
11
+ /** Tailwind 700 of each accent (orange 800: 700 misses 4.5:1), for type on the light room. */
12
+ const LIGHT_ACCENT_VALUES = {
13
+ cyan: [14, 116, 144],
14
+ green: [4, 120, 87],
15
+ orange: [154, 52, 18],
16
+ };
17
+ const CASSETTE = {
18
+ shell: [46, 44, 41],
19
+ tape: [24, 19, 17],
20
+ paper: [239, 230, 210],
21
+ ink: [31, 29, 26],
22
+ glass: [7, 16, 15],
23
+ amber: [255, 181, 71],
24
+ red: [255, 77, 61],
25
+ };
26
+ const BASE = {
27
+ dark: {
28
+ ...CASSETTE,
29
+ bg: [16, 17, 20],
30
+ vignette: 0.3,
31
+ faceplate: [36, 38, 43],
32
+ bevel: [58, 61, 68],
33
+ keyTop: [58, 61, 68],
34
+ keyRim: [255, 255, 255],
35
+ screw: [22, 23, 26],
36
+ plateInk: [143, 147, 156],
37
+ identityInk: [242, 239, 232],
38
+ identityMuted: [143, 147, 156],
39
+ },
40
+ // A silver deck, as 1980s hi-fi came in. The window and display stay dark.
41
+ light: {
42
+ ...CASSETTE,
43
+ bg: [236, 237, 241],
44
+ vignette: 0.06,
45
+ faceplate: [204, 207, 212],
46
+ bevel: [246, 247, 249],
47
+ keyTop: [226, 228, 232],
48
+ keyRim: [255, 255, 255],
49
+ screw: [120, 124, 132],
50
+ plateInk: [70, 74, 82],
51
+ identityInk: [17, 18, 22],
52
+ identityMuted: [92, 96, 106],
53
+ },
54
+ };
55
+ export function paletteOf(options) {
56
+ const accent = ACCENT_VALUES[options.accent];
57
+ return {
58
+ ...BASE[options.theme],
59
+ accent,
60
+ identityAccent: options.theme === 'dark' ? accent : LIGHT_ACCENT_VALUES[options.accent],
61
+ };
62
+ }
63
+ /** Flat colors the encoder keeps exact and undithered. */
64
+ export function pinnedOf(palette) {
65
+ const colors = [palette.bg, palette.faceplate, palette.glass, palette.paper, palette.accent];
66
+ if (palette.identityAccent !== palette.accent)
67
+ colors.push(palette.identityAccent);
68
+ return colors;
69
+ }