headreel 1.6.0 → 1.7.1

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.
package/README.md CHANGED
@@ -120,6 +120,24 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
120
120
  <tr>
121
121
  <td>
122
122
 
123
+ ### Spec Sheet
124
+
125
+ `spec-sheet`: your GitHub profile as a printed spec card on grid paper. Your avatar renders as a monochrome halftone portrait that dissolves and rebuilds dot by dot, next to a stats table (followers, stars, repos, contributions) and your name as the headline.
126
+
127
+ ![Spec Sheet banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/spec-sheet.gif)
128
+
129
+ | Option | Default | Description |
130
+ | -------- | ------- | ----------------------------------------------------------------------------------- |
131
+ | `accent` | `gray` | Eyebrow, tagline, website and cursor: `gray`, `teal`, `cobalt`, `violet`, `sienna`. |
132
+
133
+ </td>
134
+ </tr>
135
+ </table>
136
+
137
+ <table>
138
+ <tr>
139
+ <td>
140
+
123
141
  ### Highlights Reel
124
142
 
125
143
  `highlights-reel`: a few profile facts, each on its own card, filmed by one camera. The camera travels between the cards, pushes into your busiest week, and ends on a wide shot of the whole reel.
@@ -0,0 +1,171 @@
1
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
2
+ import { z } from 'zod';
3
+ import { contributionWindow } from './contributions.js';
4
+ // The card's own query: the profile (avatar, followers, account age), the
5
+ // owned public non-fork repositories paged by 100 (the connection's
6
+ // totalCount is the repo count, the nodes' stars are summed), and the
7
+ // 365-day calendar total, so it matches the other styles. Field names
8
+ // checked against the live GraphQL schema (2026-09-19).
9
+ const QUERY = /* GraphQL */ `
10
+ query SpecSheet($login: String!, $from: DateTime!, $to: DateTime!, $after: String) {
11
+ user(login: $login) {
12
+ avatarUrl(size: 256)
13
+ createdAt
14
+ followers {
15
+ totalCount
16
+ }
17
+ repositories(
18
+ first: 100
19
+ ownerAffiliations: OWNER
20
+ isFork: false
21
+ privacy: PUBLIC
22
+ after: $after
23
+ ) {
24
+ totalCount
25
+ pageInfo {
26
+ hasNextPage
27
+ endCursor
28
+ }
29
+ nodes {
30
+ stargazerCount
31
+ }
32
+ }
33
+ contributionsCollection(from: $from, to: $to) {
34
+ contributionCalendar {
35
+ totalContributions
36
+ }
37
+ }
38
+ }
39
+ }
40
+ `;
41
+ /** Repositories per page; GitHub caps `first` at 100. */
42
+ export const REPO_PAGE_SIZE = 100;
43
+ /** Pages fetched before the star sum stops: 1,000 repositories. */
44
+ export const REPO_PAGES_MAX = 10;
45
+ /** Avatar download limit: a stalled image host must not hang the run. */
46
+ const AVATAR_TIMEOUT_MS = 15_000;
47
+ /** The avatar is reduced to this square luma grid before it is stored. */
48
+ export const AVATAR_GRID = 64;
49
+ const lumaBytes = AVATAR_GRID * AVATAR_GRID;
50
+ const avatarSchema = z.object({
51
+ size: z.literal(AVATAR_GRID),
52
+ /** The grid's bytes, base64: luma 0 (black) to 255 (white), row-major. */
53
+ luma: z
54
+ .string()
55
+ .base64()
56
+ .refine((s) => Buffer.from(s, 'base64').length === lumaBytes, {
57
+ message: `luma must decode to ${lumaBytes} bytes`,
58
+ }),
59
+ });
60
+ export const specSheetSchema = z.object({
61
+ /** Fetch date (= window end), YYYY-MM-DD (UTC). */
62
+ asOf: z.iso.date(),
63
+ /** Account creation date, YYYY-MM-DD. */
64
+ createdAt: z.iso.date(),
65
+ followers: z.number().int().min(0),
66
+ /** Stars summed over the fetched owned public non-fork repositories. */
67
+ stars: z.number().int().min(0),
68
+ /** The repositories connection's totalCount under the same filters. */
69
+ repos: z.number().int().min(0),
70
+ /** The calendar's total over the 365-day window, as the other styles show. */
71
+ contributions: z.number().int().min(0),
72
+ /** Null when the avatar could not be fetched; the render draws a sphere. */
73
+ avatar: avatarSchema.nullable(),
74
+ });
75
+ /** Rec. 709 luma of a flattened pixel, 0 to 255. */
76
+ function lumaOf(r, g, b) {
77
+ return Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
78
+ }
79
+ /**
80
+ * Flattens the avatar on white (they can be transparent) and area-averages it
81
+ * into the luma grid: each cell holds the mean luma of its rectangle of
82
+ * source pixels, drawn at natural size so no scaling filter is involved. The
83
+ * render reads only the grid, so decoding differences never reach the output.
84
+ */
85
+ export function normalizeAvatar(img) {
86
+ const w = Math.max(1, img.width);
87
+ const h = Math.max(1, img.height);
88
+ const canvas = createCanvas(w, h);
89
+ const ctx = canvas.getContext('2d');
90
+ ctx.fillStyle = '#ffffff';
91
+ ctx.fillRect(0, 0, w, h);
92
+ ctx.drawImage(img, 0, 0);
93
+ const px = ctx.getImageData(0, 0, w, h).data;
94
+ const luma = Array.from({ length: lumaBytes });
95
+ for (let gy = 0; gy < AVATAR_GRID; gy++) {
96
+ const y0 = Math.floor((gy * h) / AVATAR_GRID);
97
+ const y1 = Math.min(h, Math.max(y0 + 1, Math.floor(((gy + 1) * h) / AVATAR_GRID)));
98
+ for (let gx = 0; gx < AVATAR_GRID; gx++) {
99
+ const x0 = Math.floor((gx * w) / AVATAR_GRID);
100
+ const x1 = Math.min(w, Math.max(x0 + 1, Math.floor(((gx + 1) * w) / AVATAR_GRID)));
101
+ let r = 0;
102
+ let g = 0;
103
+ let b = 0;
104
+ for (let y = y0; y < y1; y++) {
105
+ for (let x = x0; x < x1; x++) {
106
+ const i = (y * w + x) * 4;
107
+ r += px[i];
108
+ g += px[i + 1];
109
+ b += px[i + 2];
110
+ }
111
+ }
112
+ const n = (y1 - y0) * (x1 - x0);
113
+ luma[gy * AVATAR_GRID + gx] = lumaOf(r / n, g / n, b / n);
114
+ }
115
+ }
116
+ return luma;
117
+ }
118
+ export async function fetchSpecSheet(client, login, now) {
119
+ const { from, to } = contributionWindow(now);
120
+ let after = null;
121
+ let user;
122
+ let stars = 0;
123
+ let repos = 0;
124
+ for (let page = 0;; page++) {
125
+ const data = await client(QUERY, {
126
+ login,
127
+ from: `${from}T00:00:00Z`,
128
+ to: `${to}T23:59:59Z`,
129
+ after,
130
+ });
131
+ user = data.user;
132
+ const conn = user.repositories;
133
+ repos = conn.totalCount;
134
+ stars += conn.nodes.reduce((sum, n) => sum + n.stargazerCount, 0);
135
+ if (!conn.pageInfo.hasNextPage || page + 1 >= REPO_PAGES_MAX)
136
+ break;
137
+ after = conn.pageInfo.endCursor;
138
+ }
139
+ // The loop runs at least once, so the user block is set.
140
+ const profile = user;
141
+ const avatar = await fetchAvatarLuma(profile.avatarUrl);
142
+ return {
143
+ asOf: to,
144
+ createdAt: profile.createdAt.slice(0, 10),
145
+ followers: profile.followers.totalCount,
146
+ stars,
147
+ repos,
148
+ contributions: profile.contributionsCollection.contributionCalendar.totalContributions,
149
+ avatar,
150
+ };
151
+ }
152
+ /**
153
+ * Fetches the avatar and reduces it to the luma grid. GitHub serves avatars
154
+ * publicly (no token); any failure (network, non-image, decode) returns null
155
+ * with a warning, and the render falls back to the sphere instead of failing
156
+ * the run.
157
+ */
158
+ async function fetchAvatarLuma(url) {
159
+ try {
160
+ const res = await fetch(url, { signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS) });
161
+ if (!res.ok)
162
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
163
+ const bytes = Buffer.from(await res.arrayBuffer());
164
+ const img = await loadImage(bytes);
165
+ return { size: AVATAR_GRID, luma: Buffer.from(normalizeAvatar(img)).toString('base64') };
166
+ }
167
+ catch (err) {
168
+ console.warn(`headreel: avatar unavailable, drawing the fallback sphere (${err.message})`);
169
+ return null;
170
+ }
171
+ }
@@ -3,6 +3,7 @@ import { highlightsReel } from './highlights-reel/index.js';
3
3
  import { nowPlaying } from './now-playing/index.js';
4
4
  import { receipt } from './receipt/index.js';
5
5
  import { repoGalaxy } from './repo-galaxy/index.js';
6
+ import { specSheet } from './spec-sheet/index.js';
6
7
  import { trailProfile } from './trail-profile/index.js';
7
8
  /** Built-in styles, keyed by id. */
8
9
  export const styles = {
@@ -11,5 +12,6 @@ export const styles = {
11
12
  [nowPlaying.id]: nowPlaying,
12
13
  [receipt.id]: receipt,
13
14
  [repoGalaxy.id]: repoGalaxy,
15
+ [specSheet.id]: specSheet,
14
16
  [trailProfile.id]: trailProfile,
15
17
  };
@@ -0,0 +1,143 @@
1
+ import { AVATAR_GRID } from '../../core/data/spec-sheet.js';
2
+ /**
3
+ * The portrait: a monochrome halftone of the avatar's luma grid, on a screen
4
+ * angled 45 degrees like print. Dots, radii and animation delays are computed
5
+ * once (pure functions of the grid and the seeded jitter); every frame only
6
+ * scales each dot's radius.
7
+ */
8
+ /** Screen pitch, px: the distance between neighbouring dot centres. */
9
+ export const PITCH = 7;
10
+ /** Dot radius at full ink, as a fraction of the pitch. */
11
+ export const DOT_MAX = 0.55;
12
+ /** Radii below this, px, are left out entirely. */
13
+ export const DOT_MIN = 0.6;
14
+ /** The outer share of the disc where dots fade toward the paper. */
15
+ export const RIM = 0.12;
16
+ /**
17
+ * Loop phases where the portrait dissolves and rebuilds. The two are the same
18
+ * length and mirror each other, and the hold between them wraps across the
19
+ * seam, so the loop reads as one even rhythm: hold, dissolve, rebuild.
20
+ */
21
+ export const DISSOLVE = [24 / 169, 84 / 169];
22
+ export const BUILD = [84 / 169, 144 / 169];
23
+ /** Share of a sub-phase a single dot takes to shrink or grow. */
24
+ const WINDOW = 0.2;
25
+ /** Share of a sub-phase given to the seeded jitter, which feathers the front. */
26
+ const JITTER = 0.06;
27
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
28
+ function smoothstep(e0, e1, x) {
29
+ const t = clamp01((x - e0) / (e1 - e0));
30
+ return t * t * (3 - 2 * t);
31
+ }
32
+ /** Bilinear sample of the luma grid at a point of the unit square (0..1). */
33
+ export function sampleLuma(luma, u, v) {
34
+ const g = AVATAR_GRID - 1;
35
+ const x = clamp01(u) * g;
36
+ const y = clamp01(v) * g;
37
+ const x0 = Math.floor(x);
38
+ const y0 = Math.floor(y);
39
+ const x1 = Math.min(g, x0 + 1);
40
+ const y1 = Math.min(g, y0 + 1);
41
+ const fx = x - x0;
42
+ const fy = y - y0;
43
+ const a = luma[y0 * AVATAR_GRID + x0];
44
+ const b = luma[y0 * AVATAR_GRID + x1];
45
+ const c = luma[y1 * AVATAR_GRID + x0];
46
+ const d = luma[y1 * AVATAR_GRID + x1];
47
+ return a * (1 - fx) * (1 - fy) + b * fx * (1 - fy) + c * (1 - fx) * fy + d * fx * fy;
48
+ }
49
+ /** One dot's at-rest radius: dark luma grows it, the rim fades it out. */
50
+ export function dotRadius(luma, rim) {
51
+ const ink = 1 - luma / 255;
52
+ const fade = clamp01((1 - rim) / RIM);
53
+ return PITCH * DOT_MAX * Math.sqrt(ink) * fade;
54
+ }
55
+ /**
56
+ * The screen: a square lattice rotated 45 degrees, one dot per intersection
57
+ * inside the disc, each sampling the grid bilinearly at its centre. Dots
58
+ * whose faded radius falls below `DOT_MIN` (the pale and the outermost rim)
59
+ * are left out.
60
+ */
61
+ export function halftoneDots(luma, cx, cy, radius, next) {
62
+ const dots = [];
63
+ const step = PITCH / Math.SQRT2;
64
+ const k = Math.ceil(radius / step) + 1;
65
+ for (let u = -k; u <= k; u++) {
66
+ for (let v = -k; v <= k; v++) {
67
+ const x = cx + (u - v) * step;
68
+ const y = cy + (u + v) * step;
69
+ const dist = Math.hypot(x - cx, y - cy);
70
+ if (dist > radius)
71
+ continue;
72
+ const rim = dist / radius;
73
+ const sample = sampleLuma(luma, (x - cx) / radius / 2 + 0.5, (y - cy) / radius / 2 + 0.5);
74
+ const r = dotRadius(sample, rim);
75
+ if (r < DOT_MIN)
76
+ continue;
77
+ dots.push({ x, y, r, rim, jitter: next(), front: 0 });
78
+ }
79
+ }
80
+ const order = dots.map((_, i) => i).sort((a, b) => dots[b].rim - dots[a].rim || a - b);
81
+ const total = dots.reduce((sum, d) => sum + d.r * d.r, 0);
82
+ let before = 0;
83
+ for (const i of order) {
84
+ const ink = dots[i].r * dots[i].r;
85
+ dots[i].front = total > 0 ? (before + ink / 2) / total : 0;
86
+ before += ink;
87
+ }
88
+ return dots;
89
+ }
90
+ /**
91
+ * The fallback portrait when no avatar could be fetched: a sphere shaded from
92
+ * a seeded light direction, as the reference card's halftone sphere. Points
93
+ * outside the unit disc are paper (no dot).
94
+ */
95
+ export function sphereLuma(next) {
96
+ const g = AVATAR_GRID;
97
+ const lx = -0.52 - 0.2 * next();
98
+ const ly = -0.58 - 0.2 * next();
99
+ const lz = 0.62;
100
+ const norm = Math.hypot(lx, ly, lz);
101
+ const l = [lx / norm, ly / norm, lz / norm];
102
+ const luma = Array.from({ length: g * g }, () => 255);
103
+ for (let y = 0; y < g; y++) {
104
+ for (let x = 0; x < g; x++) {
105
+ const nx = ((x + 0.5) / g) * 2 - 1;
106
+ const ny = ((y + 0.5) / g) * 2 - 1;
107
+ const d2 = nx * nx + ny * ny;
108
+ if (d2 > 1) {
109
+ luma[y * g + x] = 255;
110
+ continue;
111
+ }
112
+ const nz = Math.sqrt(1 - d2);
113
+ const lit = Math.max(0, nx * l[0] + ny * l[1] + nz * l[2]);
114
+ luma[y * g + x] = Math.round(255 * (0.16 + 0.78 * lit));
115
+ }
116
+ }
117
+ return luma;
118
+ }
119
+ /**
120
+ * One dot's radius scale over one sub-phase `s` (0..1): the dissolve, rim
121
+ * first, the front paced by ink (`Dot.front`), so about the same amount of
122
+ * ink leaves on every frame. Every dot reaches exactly 0 at `s` 1: trigger
123
+ * plus WINDOW never passes 1.
124
+ */
125
+ function dissolveScale(dot, s) {
126
+ const trigger = dot.front * (1 - WINDOW - JITTER) + dot.jitter * JITTER;
127
+ return 1 - smoothstep(trigger, trigger + WINDOW, s);
128
+ }
129
+ /**
130
+ * One dot's radius scale at loop phase `t`. The portrait holds finished
131
+ * (scale exactly 1), dissolves rim to centre, then rebuilds centre to rim as
132
+ * the dissolve played backwards, so both halves have the same pace. Periodic:
133
+ * `t` 0 and every `t` outside the two phases read exactly 1.
134
+ */
135
+ export function dotScale(dot, t) {
136
+ if (t >= DISSOLVE[0] && t < DISSOLVE[1]) {
137
+ return dissolveScale(dot, (t - DISSOLVE[0]) / (DISSOLVE[1] - DISSOLVE[0]));
138
+ }
139
+ if (t >= BUILD[0] && t < BUILD[1]) {
140
+ return dissolveScale(dot, 1 - (t - BUILD[0]) / (BUILD[1] - BUILD[0]));
141
+ }
142
+ return 1;
143
+ }
@@ -0,0 +1,16 @@
1
+ import { fetchSpecSheet, specSheetSchema } from '../../core/data/spec-sheet.js';
2
+ import { buildSheet, FRAMES } from './sheet.js';
3
+ import { options } from './options.js';
4
+ import { paletteOf, pinnedOf } from './palette.js';
5
+ import { createSpecSheetSketch } from './sketch.js';
6
+ export const specSheet = {
7
+ id: 'spec-sheet',
8
+ fps: 25,
9
+ frames: FRAMES,
10
+ pinned: (o) => pinnedOf(paletteOf(o)),
11
+ data: { name: 'specsheet', schema: specSheetSchema, fetch: fetchSpecSheet },
12
+ options,
13
+ createSketch({ data, identity, options, rng }) {
14
+ return createSpecSheetSketch(buildSheet(data, identity, paletteOf(options), rng));
15
+ },
16
+ };
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export const ACCENTS = ['gray', 'teal', 'cobalt', 'violet', 'sienna'];
3
+ export const options = z
4
+ .object({
5
+ /** Color of the eyebrow, tagline, website and cursor. */
6
+ accent: z.enum(ACCENTS).default('gray'),
7
+ })
8
+ .strict();
@@ -0,0 +1,40 @@
1
+ /** `over` at `alpha` over `base`, both opaque. */
2
+ export function blend(over, base, alpha) {
3
+ return [
4
+ Math.round(over[0] * alpha + base[0] * (1 - alpha)),
5
+ Math.round(over[1] * alpha + base[1] * (1 - alpha)),
6
+ Math.round(over[2] * alpha + base[2] * (1 - alpha)),
7
+ ];
8
+ }
9
+ /**
10
+ * Gray is the muted label color itself: the card prints in ink and one
11
+ * grey, like the portrait. Teal is new to this style; the other three share Trail Profile's
12
+ * values.
13
+ */
14
+ const ACCENT_VALUES = {
15
+ gray: [106, 106, 112], // #6A6A70, the muted labels
16
+ teal: [30, 122, 94], // #1E7A5E
17
+ cobalt: [47, 85, 212], // #2F55D4
18
+ violet: [106, 63, 200], // #6A3FC8
19
+ sienna: [138, 75, 34], // #8A4B22
20
+ };
21
+ const BASE = {
22
+ paper: [245, 245, 242], // #F5F5F2
23
+ ink: [22, 22, 26], // #16161A
24
+ muted: [106, 106, 112], // #6A6A70
25
+ };
26
+ export function paletteOf(options) {
27
+ return {
28
+ ...BASE,
29
+ accent: ACCENT_VALUES[options.accent],
30
+ // Pre-blended solid, drawn opaque so the encoder can pin it: alpha
31
+ // hairlines anti-alias into a ramp of near-identical colors that dithers.
32
+ grid: blend(BASE.ink, BASE.paper, 0.06),
33
+ };
34
+ }
35
+ /** Flat colors the encoder keeps exact and undithered. */
36
+ export function pinnedOf(palette) {
37
+ const colors = [palette.paper, palette.ink, palette.muted, palette.accent, palette.grid];
38
+ // Gray accent is the muted color: pin it once.
39
+ return colors.filter((c, i) => colors.findIndex((d) => d.join() === c.join()) === i);
40
+ }
@@ -0,0 +1,106 @@
1
+ import { canvasMeasure, fitLine } from '../../core/text.js';
2
+ import { BUILD, DISSOLVE, halftoneDots, sphereLuma } from './halftone.js';
3
+ /**
4
+ * The card as printed: where every block of type sits, the stats rows, and
5
+ * the halftone screen. Everything here is a pure function of the data; the
6
+ * sketch only draws.
7
+ */
8
+ export const FRAMES = 169;
9
+ export const LAYOUT = {
10
+ width: 1280,
11
+ height: 400,
12
+ /** Card border inset, and the text margin inside it. */
13
+ inset: 24,
14
+ pad: 48,
15
+ /** Grid paper pitch. */
16
+ grid: 16,
17
+ /** Headline and tagline column: x 48 to 560, the stats panel's left edge minus 32. */
18
+ nameColumn: 560 - 48,
19
+ topBarY: 48,
20
+ eyebrowY: 84,
21
+ /** Baselines. */
22
+ nameY: 152,
23
+ taglineY: 192,
24
+ /** The bordered stats table. */
25
+ panel: { left: 592, right: 900, top: 110, bottom: 300, rowH: 38, pad: 20 },
26
+ /** The halftone portrait disc. */
27
+ portrait: { cx: 1086, cy: 200, radius: 140 },
28
+ footerY: 352,
29
+ };
30
+ /** Frames a stats row's value takes to retype during the build. */
31
+ export const TYPE_FRAMES = 12;
32
+ const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
33
+ /** "2026-09-19" -> "19 SEP 2026". */
34
+ export function asOfText(asOf) {
35
+ const [, m, d] = asOf.split('-');
36
+ return `${Number(d)} ${MONTHS[Number(m) - 1]} ${asOf.slice(0, 4)}`;
37
+ }
38
+ /**
39
+ * A value's visible characters at loop phase `t`. Complete at rest; during
40
+ * the dissolve the values are deleted one row at a time, bottom row first,
41
+ * and during the build they are typed back top row first, the deletion
42
+ * played backwards. Between the two they are empty, like the portrait.
43
+ */
44
+ export function typedChars(value, row, rows, t) {
45
+ const len = TYPE_FRAMES / FRAMES;
46
+ let k;
47
+ if (t >= DISSOLVE[0] && t < BUILD[0]) {
48
+ const start = DISSOLVE[0] + (rows - 1 - row) * len;
49
+ k = t < start ? 1 : 1 - (t - start) / len;
50
+ }
51
+ else if (t >= BUILD[0] && t < BUILD[1]) {
52
+ const start = BUILD[0] + row * len;
53
+ k = (t - start) / len;
54
+ }
55
+ else {
56
+ return { text: value, cursor: false };
57
+ }
58
+ if (k >= 1 - 1e-9)
59
+ return { text: value, cursor: false };
60
+ if (k <= 1e-9)
61
+ return { text: '', cursor: false };
62
+ const chars = Math.min(value.length, Math.floor(k * (value.length + 1)));
63
+ return { text: value.slice(0, chars), cursor: true };
64
+ }
65
+ /** "https://www.example.com/" -> "www.example.com" */
66
+ function displayUrl(url) {
67
+ return url.replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '');
68
+ }
69
+ export function buildSheet(data, identity, palette, next) {
70
+ const { portrait } = LAYOUT;
71
+ const luma = data.avatar ? Buffer.from(data.avatar.luma, 'base64') : sphereLuma(next);
72
+ const dots = halftoneDots(luma, portrait.cx, portrait.cy, portrait.radius, next);
73
+ // The footer: the handle, then the website link, on one line. The link is
74
+ // the arrow plus the domain with a hair of space between them, and the
75
+ // domain fits what remains of the column.
76
+ const mono = canvasMeasure('JetBrains Mono', 400, 2);
77
+ const handle = identity.handle?.trim() ?? '';
78
+ const arrowX = LAYOUT.pad + mono(handle, 12) + (handle ? 24 : 0);
79
+ const arrowW = mono('↗', 12);
80
+ const websiteX = arrowX + arrowW + 7;
81
+ const website = identity.website
82
+ ? fitLine(displayUrl(identity.website), LAYOUT.nameColumn - (websiteX - LAYOUT.pad), 12, 12, mono)
83
+ : null;
84
+ const stat = (glyph, label, n) => ({
85
+ glyph,
86
+ label,
87
+ value: n.toLocaleString('en-US'),
88
+ });
89
+ return {
90
+ sinceText: `SINCE ${data.createdAt.slice(0, 4)}`,
91
+ eyebrow: `// GITHUB PROFILE · ${asOfText(data.asOf)}`,
92
+ name: fitLine(identity.name.toUpperCase(), LAYOUT.nameColumn, 56, 30, canvasMeasure('Space Grotesk', 700, 2)),
93
+ tagline: identity.tagline
94
+ ? fitLine(identity.tagline, LAYOUT.nameColumn, 28, 28, canvasMeasure('Space Grotesk', 700))
95
+ : null,
96
+ stats: [
97
+ stat('▲', 'FOLLOWERS', data.followers),
98
+ stat('■', 'STARS', data.stars),
99
+ stat('●', 'REPOS', data.repos),
100
+ stat('◆', 'CONTRIBUTIONS', data.contributions),
101
+ ],
102
+ footer: { handle, arrowX, website, websiteX },
103
+ dots,
104
+ palette,
105
+ };
106
+ }
@@ -0,0 +1,185 @@
1
+ import { dotScale } from './halftone.js';
2
+ import { LAYOUT, typedChars } from './sheet.js';
3
+ /**
4
+ * The spec card. Everything is drawn in screen space through the raw context,
5
+ * the style set right before each draw (see Highlights Reel's note on p5's
6
+ * cached fill state), so a still frame reproduces byte for byte: the loop
7
+ * holds the finished card at both ends and only the portrait's dots and the
8
+ * table's typing move in between.
9
+ */
10
+ const MONO = '"JetBrains Mono"';
11
+ const SANS = '"Space Grotesk"';
12
+ const TAU = Math.PI * 2;
13
+ const { width: W, height: H, inset: INSET, pad: PAD, grid: GRID, panel: PANEL } = LAYOUT;
14
+ /** Rule weights, px: the card and table borders, the header rule, row rules. */
15
+ const LINE = { border: 2, header: 2, row: 1 };
16
+ /** Table type: header and labels tracked, values plain mono. */
17
+ const TYPE = { header: 11, label: 12, value: 16, glyph: 12 };
18
+ function rgba(color, alpha = 1) {
19
+ return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
20
+ }
21
+ function setFill(ctx, color, alpha = 1) {
22
+ ctx.fillStyle = rgba(color, alpha);
23
+ }
24
+ function setFont(ctx, o) {
25
+ ctx.font = `${o.bold ? 700 : 400} ${o.size}px ${o.mono ? MONO : SANS}`;
26
+ ctx.textAlign = o.align ?? 'left';
27
+ ctx.textBaseline = 'alphabetic';
28
+ ctx.letterSpacing = `${o.tracking ?? 0}px`;
29
+ }
30
+ function drawText(ctx, str, x, y, o) {
31
+ setFont(ctx, o);
32
+ setFill(ctx, o.color);
33
+ ctx.fillText(str, x, y);
34
+ if (o.tracking)
35
+ ctx.letterSpacing = '0px';
36
+ }
37
+ /** The value column's mono advance at 16 px, for the typing cursor's width. */
38
+ function monoAdvance(ctx) {
39
+ setFont(ctx, { size: TYPE.value, mono: true });
40
+ return ctx.measureText('0').width;
41
+ }
42
+ export function createSpecSheetSketch(model) {
43
+ const P = model.palette;
44
+ function setup(p) {
45
+ void p;
46
+ }
47
+ /** Grid paper: whole-pixel hairlines at the exact blended color. */
48
+ function drawGrid(ctx) {
49
+ setFill(ctx, P.grid);
50
+ for (let x = GRID; x < W; x += GRID)
51
+ ctx.fillRect(x, 0, 1, H);
52
+ for (let y = GRID; y < H; y += GRID)
53
+ ctx.fillRect(0, y, W, 1);
54
+ }
55
+ /** The card border and the bordered stats table, printed black. */
56
+ function drawRules(ctx) {
57
+ ctx.strokeStyle = rgba(P.ink);
58
+ ctx.lineWidth = LINE.border;
59
+ ctx.strokeRect(INSET + 1, INSET + 1, W - 2 * INSET - 2, H - 2 * INSET - 2);
60
+ ctx.strokeRect(PANEL.left + 1, PANEL.top + 1, PANEL.right - PANEL.left - 2, PANEL.bottom - PANEL.top - 2);
61
+ setFill(ctx, P.ink);
62
+ const headerY = PANEL.top + PANEL.rowH;
63
+ ctx.fillRect(PANEL.left, headerY - 1, PANEL.right - PANEL.left, LINE.header);
64
+ for (let i = 1; i < model.stats.length; i++) {
65
+ ctx.fillRect(PANEL.left, headerY + i * PANEL.rowH, PANEL.right - PANEL.left, LINE.row);
66
+ }
67
+ }
68
+ function drawHeadline(ctx) {
69
+ drawText(ctx, model.sinceText, W - PAD, LAYOUT.topBarY, {
70
+ size: 12,
71
+ mono: true,
72
+ tracking: 2,
73
+ align: 'right',
74
+ color: P.muted,
75
+ });
76
+ drawText(ctx, model.eyebrow, PAD, LAYOUT.eyebrowY, {
77
+ size: 12,
78
+ mono: true,
79
+ tracking: 2,
80
+ color: P.accent,
81
+ });
82
+ drawText(ctx, model.name.text, PAD, LAYOUT.nameY, {
83
+ size: model.name.size,
84
+ bold: true,
85
+ tracking: 2,
86
+ color: P.ink,
87
+ });
88
+ if (model.tagline) {
89
+ drawText(ctx, model.tagline.text, PAD, LAYOUT.taglineY, {
90
+ size: model.tagline.size,
91
+ bold: true,
92
+ color: P.accent,
93
+ });
94
+ }
95
+ }
96
+ /**
97
+ * The stats table. The form is printed (glyphs, labels, rules); the values
98
+ * are deleted during the dissolve and retyped during the build from their
99
+ * final left anchor, so the completed row is right-aligned and the typing
100
+ * grows rightward in place.
101
+ */
102
+ function drawStats(ctx, t, advance) {
103
+ const headerY = PANEL.top + PANEL.rowH;
104
+ drawText(ctx, 'STATS', PANEL.left + PANEL.pad, headerY - 13, {
105
+ size: TYPE.header,
106
+ mono: true,
107
+ tracking: 2,
108
+ color: P.muted,
109
+ });
110
+ model.stats.forEach((row, i) => {
111
+ const baseline = headerY + i * PANEL.rowH + 25;
112
+ drawText(ctx, row.glyph, PANEL.left + PANEL.pad, baseline, {
113
+ size: TYPE.glyph,
114
+ mono: true,
115
+ color: P.ink,
116
+ });
117
+ drawText(ctx, row.label, PANEL.left + PANEL.pad + 22, baseline, {
118
+ size: TYPE.label,
119
+ mono: true,
120
+ tracking: 1,
121
+ color: P.muted,
122
+ });
123
+ const { text, cursor } = typedChars(row.value, i, model.stats.length, t);
124
+ setFont(ctx, { size: TYPE.value, mono: true });
125
+ const anchor = PANEL.right - PANEL.pad - ctx.measureText(row.value).width;
126
+ const width = ctx.measureText(text).width;
127
+ drawText(ctx, text, anchor, baseline, { size: TYPE.value, mono: true, color: P.ink });
128
+ if (cursor) {
129
+ setFill(ctx, P.accent);
130
+ ctx.fillRect(anchor + width + 1, baseline - 13, advance, TYPE.value);
131
+ }
132
+ });
133
+ }
134
+ /** The portrait: the halftone screen, each dot at its phase scale. */
135
+ function drawPortrait(ctx, t) {
136
+ setFill(ctx, P.ink);
137
+ for (const dot of model.dots) {
138
+ const scale = dotScale(dot, t);
139
+ if (scale <= 0)
140
+ continue;
141
+ const r = dot.r * scale;
142
+ if (r < 0.1)
143
+ continue;
144
+ ctx.beginPath();
145
+ ctx.arc(dot.x, dot.y, r, 0, TAU);
146
+ ctx.fill();
147
+ }
148
+ }
149
+ function drawFooter(ctx) {
150
+ if (model.footer.handle) {
151
+ drawText(ctx, model.footer.handle, PAD, LAYOUT.footerY, {
152
+ size: 12,
153
+ mono: true,
154
+ tracking: 2,
155
+ color: P.muted,
156
+ });
157
+ }
158
+ if (model.footer.website) {
159
+ drawText(ctx, '↗', model.footer.arrowX, LAYOUT.footerY, {
160
+ size: 12,
161
+ mono: true,
162
+ tracking: 2,
163
+ color: P.accent,
164
+ });
165
+ drawText(ctx, model.footer.website.text, model.footer.websiteX, LAYOUT.footerY, {
166
+ size: 12,
167
+ mono: true,
168
+ tracking: 2,
169
+ color: P.accent,
170
+ });
171
+ }
172
+ }
173
+ function draw(p, _frame, t) {
174
+ const ctx = p.drawingContext;
175
+ setFill(ctx, P.paper);
176
+ ctx.fillRect(0, 0, W, H);
177
+ drawGrid(ctx);
178
+ drawRules(ctx);
179
+ drawHeadline(ctx);
180
+ drawStats(ctx, t, monoAdvance(ctx));
181
+ drawPortrait(ctx, t);
182
+ drawFooter(ctx);
183
+ }
184
+ return { setup, draw };
185
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "headreel",
3
- "version": "1.6.0",
4
- "description": "Animated GitHub profile banner made from your GitHub activity, with a GitHub Action or CLI.",
3
+ "version": "1.7.1",
4
+ "description": "Animated GitHub profile banners made from your GitHub activity, with a GitHub Action or CLI.",
5
5
  "keywords": [
6
6
  "github",
7
7
  "github-profile",