headreel 1.4.0 → 1.6.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/arifszn/headreel/actions/workflows/ci.yml/badge.svg)](https://github.com/arifszn/headreel/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
7
7
 
8
- An animated banner for your GitHub profile, made from your GitHub activity.
8
+ Animated banners for your GitHub profile, made from your GitHub activity.
9
9
 
10
10
  ![Contribution City banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/contribution-city.gif)
11
11
 
@@ -18,8 +18,7 @@ Pick one way to make your banner:
18
18
 
19
19
  The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions/headreel).
20
20
 
21
- 1. Open your profile repository. This is the public repository with the same name as your username, for example `octocat/octocat`.
22
- 2. Add `.github/workflows/headreel.yml`:
21
+ 1. Add `.github/workflows/headreel.yml` to your repository:
23
22
 
24
23
  ```yaml
25
24
  name: headreel
@@ -45,8 +44,8 @@ The Action is on the [GitHub Marketplace](https://github.com/marketplace/actions
45
44
  website: https://example.com
46
45
  ```
47
46
 
48
- 3. Run the workflow once from the **Actions** tab. It adds `headreel.gif` to the `headreel` branch.
49
- 4. Add the image line to your `README.md`. The workflow run page shows it, ready to copy. It looks like this:
47
+ 2. Run the workflow once from the **Actions** tab. It adds `headreel.gif` to the `headreel` branch.
48
+ 3. Add the image line to your `README.md`. The workflow run page shows it, ready to copy. It looks like this:
50
49
 
51
50
  ```markdown
52
51
  ![My GitHub activity](https://raw.githubusercontent.com/octocat/octocat/headreel/headreel.gif)
@@ -68,7 +67,7 @@ Run this command. Replace `octocat` with your GitHub username.
68
67
  npx headreel --style contribution-city --user octocat --tagline "Open source maintainer" --website https://example.com
69
68
  ```
70
69
 
71
- The command saves `headreel.gif` in the current folder. Add the file to your profile repository, and add `![My GitHub activity](headreel.gif)` to its `README.md`.
70
+ The command saves `headreel.gif` in the current folder. Add the file to your repository, and add `![My GitHub activity](headreel.gif)` to its `README.md`.
72
71
 
73
72
  You need:
74
73
 
@@ -99,6 +98,10 @@ Empty settings do not show on the banner. Your name comes from your GitHub profi
99
98
 
100
99
  Set the style with `style: <name>` in the Action, or `--style <name>` in the command. Set an option with `options: 'accent: green'` in the Action, or `--option accent=green` in the command. For more than one option in the Action, put each on its own line.
101
100
 
101
+ <table>
102
+ <tr>
103
+ <td>
104
+
102
105
  ### Contribution City
103
106
 
104
107
  `contribution-city`: each day of the last 12 months is one building. A taller building means more contributions. Beacons glow on your busiest days.
@@ -109,6 +112,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
109
112
  | -------- | ------- | --------------------------------------------------------------------------------------------- |
110
113
  | `accent` | `cyan` | Color of the roofs, windows, lights and links: `cyan`, `cobalt`, `green`, `violet` or `pink`. |
111
114
 
115
+ </td>
116
+ </tr>
117
+ </table>
118
+
119
+ <table>
120
+ <tr>
121
+ <td>
122
+
112
123
  ### Highlights Reel
113
124
 
114
125
  `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.
@@ -120,6 +131,14 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
120
131
  | `theme` | `light` | `light` or `dark`. Use `dark` if your profile is mostly viewed in GitHub dark mode. |
121
132
  | `accent` | `cobalt` | Color of the bars, numbers and links: `cobalt`, `green`, `violet`, `orange` or `pink`. |
122
133
 
134
+ </td>
135
+ </tr>
136
+ </table>
137
+
138
+ <table>
139
+ <tr>
140
+ <td>
141
+
123
142
  ### Now Playing
124
143
 
125
144
  `now-playing`: a cassette deck playing the repository you committed to most in the last 30 days. The label names its language, your commit messages on it scroll across the deck's display as lyrics, and the level meter shows the month day by day. When nothing was committed in the last 30 days, the deck shows the repository you pushed to last as `LAST PLAYED`.
@@ -132,6 +151,52 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
132
151
  | `accent` | `cyan` | Display color, meter and links: `cyan`, `green` or `orange`. |
133
152
  | `exclude` | empty | Comma-separated repositories never picked, for example your profile repository: `exclude: octocat`. |
134
153
 
154
+ </td>
155
+ </tr>
156
+ </table>
157
+
158
+ <table>
159
+ <tr>
160
+ <td>
161
+
162
+ ### Receipt
163
+
164
+ `receipt`: a thermal printer feeds out your last 12 months as a till receipt: repositories as items, contribution counts as the totals, streaks and your best day printed below, and a barcode encoding your activity week by week. The paper prints line by line, tears off, and the next receipt starts.
165
+
166
+ ![Receipt banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/receipt.gif)
167
+
168
+ | Option | Default | Description |
169
+ | -------- | -------- | ------------------------------------------------------------------------------- |
170
+ | `theme` | `dark` | `dark` or `light`. Use `light` if your profile is mostly viewed in light mode. |
171
+ | `accent` | `cobalt` | Prompt, cursor, tagline and LED: `cobalt`, `green`, `violet`, `orange`, `pink`. |
172
+ | `items` | `5` | Repositories printed as line items, `0` to `5`. |
173
+
174
+ </td>
175
+ </tr>
176
+ </table>
177
+
178
+ <table>
179
+ <tr>
180
+ <td>
181
+
182
+ ### Trail Profile
183
+
184
+ `trail-profile`: your last 12 months as a hiking trail's elevation profile on printed survey paper. Each week's contributions are the trail's elevation, drawn as a profile line with contour strata and engraved relief below it. A hiker marker walks the trail to your busiest week, the summit, and holds on its flag.
185
+
186
+ ![Trail Profile banner](https://raw.githubusercontent.com/arifszn/headreel/main/docs/samples/trail-profile.gif)
187
+
188
+ | Option | Default | Description |
189
+ | -------- | -------- | ---------------------------------------------------------------------------------------------- |
190
+ | `accent` | `sienna` | Profile line, strata and links: `sienna`, `cobalt`, `green` or `violet`. The paper stays buff. |
191
+
192
+ </td>
193
+ </tr>
194
+ </table>
195
+
196
+ <table>
197
+ <tr>
198
+ <td>
199
+
135
200
  ### Repo Galaxy
136
201
 
137
202
  `repo-galaxy`: your most starred repositories orbit a sun. A bigger planet has more stars, and its color is the repository's main language. Repositories you pushed to recently orbit closer to the sun.
@@ -144,6 +209,10 @@ Set the style with `style: <name>` in the Action, or `--style <name>` in the com
144
209
  | `include_forks` | `false` | Show forked repositories too. |
145
210
  | `labels` | `top3` | `top3` names the three most starred repositories. `none` hides names. |
146
211
 
212
+ </td>
213
+ </tr>
214
+ </table>
215
+
147
216
  ## Troubleshooting
148
217
 
149
218
  | Problem | Fix |
@@ -0,0 +1,68 @@
1
+ /**
2
+ * One camera over one world: a key list `{ frame, x, y, z }` interpolated with
3
+ * a single travel curve, so almost all motion happens in the middle third of a
4
+ * move (Highlights Reel's camera, shared with Trail Profile).
5
+ *
6
+ * Zoom travels in log space, so leaving a deep push-in pulls back as fast as it
7
+ * pans. A long move dips its zoom at the midpoint and pushes in to land; short
8
+ * hops stay flat. Held shots are still, and `frameFloat` wraps so `t = 1` snaps
9
+ * bitwise to `t = 0`, closing the loop to the pixel.
10
+ */
11
+ /** The default travel curve: almost all motion happens in the middle third. */
12
+ export const easeQuartic = (t) => t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2;
13
+ /** A gentler cruise, used for long flights: cubic in-out. */
14
+ export const easeCubic = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
15
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
16
+ /** The camera between keys: one curve, with a zoom dip on long moves. */
17
+ export function shotAt(keys, frame, opts) {
18
+ const ease = opts.ease ?? easeQuartic;
19
+ if (frame <= keys[0].frame) {
20
+ return { x: keys[0].x, y: keys[0].y, z: keys[0].z };
21
+ }
22
+ const last = keys[keys.length - 1];
23
+ if (frame >= last.frame) {
24
+ return { x: last.x, y: last.y, z: last.z };
25
+ }
26
+ let index = 0;
27
+ while (index < keys.length - 2 && keys[index + 1].frame <= frame) {
28
+ index++;
29
+ }
30
+ const from = keys[index];
31
+ const to = keys[index + 1];
32
+ const t = clamp01((frame - from.frame) / (to.frame - from.frame));
33
+ const travelled = ease(t);
34
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
35
+ const dip = distance > opts.travel
36
+ ? 1 - 0.22 * Math.sin(Math.PI * t) * Math.min(1, distance / opts.span)
37
+ : 1;
38
+ return {
39
+ x: from.x + (to.x - from.x) * travelled,
40
+ y: from.y + (to.y - from.y) * travelled,
41
+ z: from.z * (to.z / from.z) ** travelled * dip,
42
+ };
43
+ }
44
+ /**
45
+ * The camera at a moment of the loop. Held shots are still: any drift, even in
46
+ * whole-pixel steps, reads as the scene stepping or the type vibrating.
47
+ */
48
+ export function cameraAt(keys, frameFloat, frames, opts) {
49
+ // t = 1 snaps to 0, so the last frame is bit-identical to the first.
50
+ const t = frameFloat >= frames ? 0 : (((frameFloat / frames) % 1) + 1) % 1;
51
+ return shotAt(keys, t * frames, opts);
52
+ }
53
+ /** How fast the frame is moving, in screen pixels, zoom included. */
54
+ export function speedAt(keys, frameFloat, frames, opts) {
55
+ const now = cameraAt(keys, frameFloat, frames, opts);
56
+ const before = cameraAt(keys, frameFloat - 1, frames, opts);
57
+ return ((Math.hypot(now.x - before.x, now.y - before.y) + Math.abs(now.z - before.z) * 700) * now.z);
58
+ }
59
+ /**
60
+ * Applies the world transform, snapped to whole screen pixels. At a fixed zoom
61
+ * the drift then moves the scene in 1 px steps instead of resampling every
62
+ * glyph each frame at a new sub-pixel offset, which reads as vibrating type.
63
+ * `(cx, cy)` is the viewport's centre in screen space.
64
+ */
65
+ export function applyCamera(ctx, cam, cx, cy) {
66
+ ctx.translate(Math.round(cx - cam.x * cam.z), Math.round(cy - cam.y * cam.z));
67
+ ctx.scale(cam.z, cam.z);
68
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from 'zod';
2
+ import { contributionWindow } from './contributions.js';
3
+ // One query for the whole receipt: the 365-day calendar (TOTAL, the streaks,
4
+ // the best day and the barcode), the per-type contribution counts, and the
5
+ // repositories the user committed to (the line items). Field names checked
6
+ // against the live GraphQL schema (2026-09-19).
7
+ const QUERY = /* GraphQL */ `
8
+ query Receipt($login: String!, $from: DateTime!, $to: DateTime!) {
9
+ user(login: $login) {
10
+ contributionsCollection(from: $from, to: $to) {
11
+ contributionCalendar {
12
+ totalContributions
13
+ weeks {
14
+ contributionDays {
15
+ date
16
+ contributionCount
17
+ }
18
+ }
19
+ }
20
+ totalCommitContributions
21
+ totalPullRequestContributions
22
+ totalPullRequestReviewContributions
23
+ totalIssueContributions
24
+ totalRepositoryContributions
25
+ restrictedContributionsCount
26
+ commitContributionsByRepository(maxRepositories: 25) {
27
+ repository {
28
+ name
29
+ owner {
30
+ login
31
+ }
32
+ isPrivate
33
+ primaryLanguage {
34
+ name
35
+ color
36
+ }
37
+ }
38
+ contributions {
39
+ totalCount
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ `;
46
+ /** Line items printed, at most. */
47
+ export const MAX_ITEMS = 5;
48
+ /** Days in the contribution window: 364 back plus the run date. */
49
+ export const WINDOW_DAYS = 365;
50
+ const languageSchema = z
51
+ .object({
52
+ name: z.string().min(1),
53
+ /** Linguist color; null when the repo has no language or color. */
54
+ color: z
55
+ .string()
56
+ .regex(/^#[0-9a-fA-F]{6}$/)
57
+ .nullable(),
58
+ })
59
+ .nullable();
60
+ export const receiptSchema = z.object({
61
+ /** Fetch date (= window end), YYYY-MM-DD (UTC). */
62
+ asOf: z.iso.date(),
63
+ from: z.iso.date(),
64
+ to: z.iso.date(),
65
+ /** The calendar's total, the number the profile shows. */
66
+ total: z.number().int().min(0),
67
+ commits: z.number().int().min(0),
68
+ pullRequests: z.number().int().min(0),
69
+ reviews: z.number().int().min(0),
70
+ issues: z.number().int().min(0),
71
+ newRepos: z.number().int().min(0),
72
+ /** Private contributions the token could see, without any repository named. */
73
+ restricted: z.number().int().min(0),
74
+ /** Public repositories committed to, most commits first. */
75
+ items: z
76
+ .array(z.object({
77
+ /** A repository the user does not own prints as `owner/name`. */
78
+ name: z.string().min(1),
79
+ language: languageSchema,
80
+ commits: z.number().int().min(1),
81
+ }))
82
+ .max(MAX_ITEMS),
83
+ /** Commits summed over the private repositories, never named. */
84
+ privateCommits: z.number().int().min(0),
85
+ /** Daily counts over the window, oldest first. */
86
+ days: z.array(z.number().int().min(0)).length(WINDOW_DAYS),
87
+ });
88
+ /**
89
+ * The line items: public repositories by commits in the window, ties by name.
90
+ * Private repositories are dropped here, before ranking; their commits are
91
+ * summed into `privateCommits` and never named.
92
+ */
93
+ export function selectItems(entries, login, cap = MAX_ITEMS) {
94
+ const items = [];
95
+ let privateCommits = 0;
96
+ for (const entry of entries) {
97
+ const { repository } = entry;
98
+ if (repository.isPrivate) {
99
+ privateCommits += entry.contributions.totalCount;
100
+ continue;
101
+ }
102
+ items.push({
103
+ name: repository.owner.login === login
104
+ ? repository.name
105
+ : `${repository.owner.login}/${repository.name}`,
106
+ language: repository.primaryLanguage
107
+ ? { name: repository.primaryLanguage.name, color: repository.primaryLanguage.color }
108
+ : null,
109
+ commits: entry.contributions.totalCount,
110
+ });
111
+ }
112
+ items.sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name));
113
+ return { items: items.slice(0, cap), privateCommits };
114
+ }
115
+ export async function fetchReceipt(client, login, now) {
116
+ const { from, to } = contributionWindow(now);
117
+ const { user } = await client(QUERY, {
118
+ login,
119
+ from: `${from}T00:00:00Z`,
120
+ to: `${to}T23:59:59Z`,
121
+ });
122
+ const cc = user.contributionsCollection;
123
+ // Calendar weeks are padded to full weeks; keep only the window's days.
124
+ const days = cc.contributionCalendar.weeks
125
+ .flatMap((week) => week.contributionDays)
126
+ .filter((day) => day.date >= from && day.date <= to)
127
+ .map((day) => day.contributionCount);
128
+ const { items, privateCommits } = selectItems(cc.commitContributionsByRepository, login);
129
+ return {
130
+ asOf: to,
131
+ from,
132
+ to,
133
+ total: cc.contributionCalendar.totalContributions,
134
+ commits: cc.totalCommitContributions,
135
+ pullRequests: cc.totalPullRequestContributions,
136
+ reviews: cc.totalPullRequestReviewContributions,
137
+ issues: cc.totalIssueContributions,
138
+ newRepos: cc.totalRepositoryContributions,
139
+ restricted: cc.restrictedContributionsCount,
140
+ items,
141
+ privateCommits,
142
+ days,
143
+ };
144
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Streaks and peaks over a flat array of daily contribution counts, oldest
3
+ * first. Shared by Highlights Reel and Receipt.
4
+ */
5
+ /** Longest run of consecutive days with at least one contribution. */
6
+ export function longestStreak(days) {
7
+ let best = 0;
8
+ let run = 0;
9
+ for (const count of days) {
10
+ run = count > 0 ? run + 1 : 0;
11
+ if (run > best)
12
+ best = run;
13
+ }
14
+ return best;
15
+ }
16
+ /**
17
+ * Consecutive active days ending on the last day, or on the day before it
18
+ * when the last day is still empty: a scheduled run often happens before the
19
+ * day's work.
20
+ */
21
+ export function currentStreak(days) {
22
+ let end = days.length - 1;
23
+ if (days[end] <= 0)
24
+ end--;
25
+ let run = 0;
26
+ for (let i = end; i >= 0 && days[i] > 0; i--)
27
+ run++;
28
+ return run;
29
+ }
30
+ /** The day with the highest count, or null when every day is empty. */
31
+ export function bestDay(days) {
32
+ let best = null;
33
+ days.forEach((count, index) => {
34
+ if (count > 0 && (best === null || count >= best.count))
35
+ best = { index, count };
36
+ });
37
+ return best;
38
+ }
@@ -2,8 +2,9 @@ const AMBER = [232, 163, 61];
2
2
  /**
3
3
  * Accent presets, [light, dark]. Each passes 4.5:1 against its theme's desk,
4
4
  * the lowest background its small type sits on (see SPEC, Highlights Reel).
5
+ * Receipt shares them: its counter is this desk.
5
6
  */
6
- const ACCENT_VALUES = {
7
+ export const ACCENT_VALUES = {
7
8
  cobalt: [
8
9
  [53, 88, 232],
9
10
  [123, 147, 255],
@@ -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,3 +1,4 @@
1
+ import { applyCamera, cameraAt, speedAt } from '../../core/camera.js';
1
2
  import { fitIdentity } from '../../core/text.js';
2
3
  import { FRAMES, LAYOUT, plateAt, stationOf, } from './reel.js';
3
4
  /**
@@ -26,8 +27,8 @@ const TAU = Math.PI * 2;
26
27
  export const IDENTITY_COLUMN = 440 - 48;
27
28
  const clamp01 = (v) => Math.min(1, Math.max(0, v));
28
29
  const easeOutCubic = (t) => 1 - (1 - t) ** 3;
29
- /** The one travel curve: almost all motion happens in the middle third. */
30
- const travelEase = (t) => (t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2);
30
+ /** Camera tuning: moves longer than `travel` dip their zoom, scaled to `span`. */
31
+ const CAMERA = { travel: LAYOUT.travel, span: 2400 };
31
32
  function smoothstep(e0, e1, x) {
32
33
  const t = clamp01((x - e0) / (e1 - e0));
33
34
  return t * t * (3 - 2 * t);
@@ -88,51 +89,10 @@ export function createReelSketch(reel, identity) {
88
89
  website: identity.website && `↗ ${displayUrl(identity.website)}`,
89
90
  }, IDENTITY_COLUMN);
90
91
  // --- camera ---------------------------------------------------------------
91
- /** The camera between keys: one curve, with a zoom dip on long moves. */
92
- function rawShot(frame) {
93
- const keys = reel.plan.keys;
94
- if (frame <= keys[0].frame) {
95
- return { x: keys[0].x, y: keys[0].y, z: keys[0].z };
96
- }
97
- const last = keys[keys.length - 1];
98
- if (frame >= last.frame) {
99
- return { x: last.x, y: last.y, z: last.z };
100
- }
101
- let index = 0;
102
- while (index < keys.length - 2 && keys[index + 1].frame <= frame) {
103
- index++;
104
- }
105
- const from = keys[index];
106
- const to = keys[index + 1];
107
- const t = clamp01((frame - from.frame) / (to.frame - from.frame));
108
- const travelled = travelEase(t);
109
- const distance = Math.hypot(to.x - from.x, to.y - from.y);
110
- // Zoom travels in log space, so leaving a deep push-in pulls back as fast
111
- // as it pans. A long move also dips at its midpoint and pushes in to land.
112
- const dip = distance > LAYOUT.travel
113
- ? 1 - 0.22 * Math.sin(Math.PI * t) * Math.min(1, distance / 2400)
114
- : 1;
115
- return {
116
- x: from.x + (to.x - from.x) * travelled,
117
- y: from.y + (to.y - from.y) * travelled,
118
- z: from.z * (to.z / from.z) ** travelled * dip,
119
- };
120
- }
121
- /**
122
- * The camera at a moment of the loop. Held shots are still: any drift, even
123
- * in whole-pixel steps, reads as the cards stepping or the type vibrating.
124
- */
125
- function camera(frameFloat) {
126
- // t = 1 snaps to 0, so the last frame is bit-identical to the first.
127
- const t = frameFloat >= FRAMES ? 0 : (((frameFloat / FRAMES) % 1) + 1) % 1;
128
- return rawShot(t * FRAMES);
129
- }
92
+ /** The camera at a moment of the loop (see `core/camera.ts`). */
93
+ const camera = (frameFloat) => cameraAt(reel.plan.keys, frameFloat, FRAMES, CAMERA);
130
94
  /** How fast the frame is moving, in screen pixels, zoom included. */
131
- function speedOf(frameFloat) {
132
- const now = camera(frameFloat);
133
- const before = camera(frameFloat - 1);
134
- return ((Math.hypot(now.x - before.x, now.y - before.y) + Math.abs(now.z - before.z) * 700) * now.z);
135
- }
95
+ const speedOf = (frameFloat) => speedAt(reel.plan.keys, frameFloat, FRAMES, CAMERA);
136
96
  /** A card builds while the camera arrives; an empty window means already finished. */
137
97
  function buildAt(window, frameFloat) {
138
98
  if (window.end <= window.start)
@@ -535,11 +495,10 @@ export function createReelSketch(reel, identity) {
535
495
  setFill(ctx, PALETTE.desk);
536
496
  ctx.fillRect(0, 0, W, H);
537
497
  ctx.save();
538
- // Snap the world to whole screen pixels. At a fixed zoom the drift then
539
- // moves the cards in 1 px steps instead of resampling every glyph each
540
- // frame at a new sub-pixel offset, which reads as vibrating type.
541
- ctx.translate(Math.round(W / 2 - cam.x * cam.z), Math.round(H / 2 - cam.y * cam.z));
542
- ctx.scale(cam.z, cam.z);
498
+ // Snap the world to whole screen pixels (see `applyCamera`): at a fixed
499
+ // zoom the drift then moves the cards in 1 px steps instead of resampling
500
+ // every glyph each frame at a new sub-pixel offset.
501
+ applyCamera(ctx, cam, W / 2, H / 2);
543
502
  drawDots(ctx, cam, speed);
544
503
  for (let i = 0; i < reel.cards.length; i++)
545
504
  drawMarks(ctx, i);
@@ -1,11 +1,15 @@
1
1
  import { contributionCity } from './contribution-city/index.js';
2
2
  import { highlightsReel } from './highlights-reel/index.js';
3
3
  import { nowPlaying } from './now-playing/index.js';
4
+ import { receipt } from './receipt/index.js';
4
5
  import { repoGalaxy } from './repo-galaxy/index.js';
6
+ import { trailProfile } from './trail-profile/index.js';
5
7
  /** Built-in styles, keyed by id. */
6
8
  export const styles = {
7
9
  [contributionCity.id]: contributionCity,
8
10
  [highlightsReel.id]: highlightsReel,
9
11
  [nowPlaying.id]: nowPlaying,
12
+ [receipt.id]: receipt,
10
13
  [repoGalaxy.id]: repoGalaxy,
14
+ [trailProfile.id]: trailProfile,
11
15
  };
@@ -0,0 +1,16 @@
1
+ import { fetchReceipt, receiptSchema } from '../../core/data/receipt.js';
2
+ import { buildReceipt, FRAMES } from './receipt.js';
3
+ import { options } from './options.js';
4
+ import { paletteOf, pinnedOf } from './palette.js';
5
+ import { createReceiptSketch } from './sketch.js';
6
+ export const receipt = {
7
+ id: 'receipt',
8
+ fps: 25,
9
+ frames: FRAMES,
10
+ pinned: (options) => pinnedOf(paletteOf(options)),
11
+ data: { name: 'receipt', schema: receiptSchema, fetch: fetchReceipt },
12
+ options,
13
+ createSketch({ data, login, options, identity, rng }) {
14
+ return createReceiptSketch(buildReceipt(data, login, identity, options, paletteOf(options), rng), identity);
15
+ },
16
+ };
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { MAX_ITEMS } from '../../core/data/receipt.js';
3
+ export const THEMES = ['dark', 'light'];
4
+ export const ACCENTS = ['cobalt', 'green', 'violet', 'orange', 'pink'];
5
+ export const options = z
6
+ .object({
7
+ /** The counter and the printer: dark slate, or the light desk. */
8
+ theme: z.enum(THEMES).default('dark'),
9
+ /** Prompt, cursor, tagline, website and the printer LED, as Highlights Reel. */
10
+ accent: z.enum(ACCENTS).default('cobalt'),
11
+ /** Line items printed, most commits first. 0 prints the totals only. */
12
+ items: z.coerce.number().int().min(0).max(MAX_ITEMS).default(MAX_ITEMS),
13
+ })
14
+ .strict();
@@ -0,0 +1,48 @@
1
+ import { ACCENT_VALUES } from '../highlights-reel/palette.js';
2
+ const PAPER = {
3
+ paper: [250, 249, 245],
4
+ ink: [30, 30, 34],
5
+ fadedInk: [106, 106, 112],
6
+ };
7
+ const BASE = {
8
+ dark: {
9
+ ...PAPER,
10
+ counter: [13, 15, 23],
11
+ body: [28, 31, 41],
12
+ slot: [5, 6, 10],
13
+ bevel: [58, 61, 74],
14
+ identityInk: [238, 240, 246],
15
+ identityMuted: [140, 146, 168],
16
+ paperBorder: null,
17
+ shadow: 0,
18
+ },
19
+ light: {
20
+ ...PAPER,
21
+ counter: [236, 237, 241],
22
+ body: [201, 204, 211],
23
+ slot: [42, 45, 53],
24
+ bevel: [246, 247, 249],
25
+ identityInk: [11, 12, 20],
26
+ identityMuted: [102, 106, 124],
27
+ paperBorder: [11, 12, 20],
28
+ shadow: 0.09,
29
+ },
30
+ };
31
+ export function paletteOf(options) {
32
+ const [light, dark] = ACCENT_VALUES[options.accent];
33
+ return {
34
+ ...BASE[options.theme],
35
+ accent: options.theme === 'light' ? light : dark,
36
+ };
37
+ }
38
+ /** Flat colors the encoder keeps exact and undithered. */
39
+ export function pinnedOf(palette) {
40
+ return [
41
+ palette.counter,
42
+ palette.paper,
43
+ palette.ink,
44
+ palette.fadedInk,
45
+ palette.body,
46
+ palette.accent,
47
+ ];
48
+ }