mossaic-art renders text as pixels on the calendar, emits the commits that
would light them up, and then tracks how far along you are. It never pushes:
--write makes local commits in a directory you name, and prints the push
command for you to run.
- Drawing it
- Drawing on a background, not on nothing
- Tracking it, day by day
- Catching up on the days already past
- Tracking it from a schedule
- How GitHub shades a day, and why it decides the cost
mossaic-art VYNCINT --year 2027 # preview only
mossaic-art VYNCINT --year 2027 \
--snapshot art/vyncint-2027.json # then: mossaic --file …
mossaic-art VYNCINT --year 2027 \
--repo ../vyncint-art --write # local commitsGlyphs are a 5×5 font — letters, digits, punctuation and shapes — one blank
column between them, placed on rows Mon–Fri so Sunday and Saturday stay clear.
mossaic-art --font prints the whole set and the
README lists it; adding to it is one table
entry in src/art.rs, with the shape rules checked when the crate compiles —
see CONTRIBUTING.md §10.
VYNCINT is 41 of the year's 53 columns and is centred by default;
--start-week and --top move it, --commits sets how many commits each lit
day gets.
The whole font, drawn by the rasteriser that draws the chart — mossaic-art --font --png art/font.png writes it, and mossaic-art --font prints the same
set in a terminal. The shapes are the last nineteen.
A shape is written between colons, so it can be typed on any keyboard:
mossaic-art "I :heart: RUST" --year 2027
mossaic-art ":star::star::star:" --year 2027The symbol itself works where you can type one — mossaic-art "I \u{2665} RUST" — and so
does a pasted emoji, variation selector and all: ⭐ draws :star:, 😢 draws
:cry:. All three spellings become the same character before anything else
sees them, which is what lets a shape survive being written to a plan,
uppercased, and read back by --track every morning.
Because a colon always opens a shape name, : has no glyph of its own. A name
nobody has drawn, or a colon that closes nothing, is refused with the list of
shapes rather than guessed at:
mossaic-art: no shape called "wombat" — the font has the shapes :star: :heart: …
mossaic-art: unclosed ':' — a shape is written :name:, and the font has …
Eight characters is the limit, whatever the year. Nine fit on paper — five
columns a letter plus one between is 6N − 1, and nine of those is 53, exactly a
year — but the first and last calendar columns are partial weeks. A year begins
and ends mid-column, so text that fills it loses the part of its leading letter that
falls in December of the year before. mossaic-art says so rather than drawing a broken
letter:
note: 3 lit pixels fell outside 2027 and were dropped — the first and last calendar
columns are partial weeks, so 52 of 53 columns hold a whole letter
Preview it in the real renderer before committing anything — --snapshot writes a
file in GitHub's own response shape and mossaic --file draws it, treating every day
as elapsed so a future year still shows.
Contribution art has an awkward cost nobody mentions: to keep the letters
visible you have to keep the rest of the year dark. Drawing VYNCINT in
2027 the classic way means 75 bright days and 290 days on which you must not
contribute at all. For a tool about contributing, that is a strange thing to
ask.
--background LEVEL draws the background as a colour instead of as nothing.
The letters stay at level 4; the rest of the year sits at the level you pick.
The art becomes the difference between two greens, and the year stays busy.
mossaic-art VYNCINT --year 2027 --background 1VYNCINT · 2027 · 41 of 53 columns · 75 days · 590 commits
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
Mon ░░░░░░░░░░██░░░░░░██░░██░░░░░░██░░██░░░░░░██░░░░██████░░░░██████████░░
░░░░░░░░░░██░░░░░░██░░░░██░░██░░░░████░░░░██░░██░░░░░░██░░░░░░██░░░░░░
Wed ░░░░░░░░░░██░░░░░░██░░░░░░██░░░░░░██░░██░░██░░██░░░░░░░░░░░░░░██░░░░░░
background level 1 under letters at level 4 · 290 background days, 1 each
· ΔE 35 at worst, clear
75 letter days at 4 commits and 290 background days at 1 — 590 commits for a year in which every single day is green.
GitHub's five shades are not evenly spaced, and how far apart two of them look depends on which theme the reader has. mossaic measures it in CIELAB — ΔE, where under 2 is invisible, 10 is "you would have to be told", and 35 reads as two different colours at a glance — across all nine palettes GitHub ships (light, dark and dimmed, each with its winter and halloween variants).
These are the colours github.com serves a browser, which is where art is read, so they are the right ones for this decision. They are not the 256-colour ramp the chart falls back to in a terminal without truecolour: that ramp is chosen for legibility rather than accuracy, and its own separations differ. The numbers below describe the picture your readers see, not the one in your terminal.
--background |
worst ΔE against level 4 | where it is worst | |
|---|---|---|---|
0 (default) |
70.3 | clear | dimmed + winter |
1 |
35.5 | clear | dark + halloween |
2 |
35.4 | clear | dimmed |
3 |
17.5 | faint | dimmed |
The rule falls straight out of the table: leave at least two levels between
the background and the letters. Adjacent shades fall as low as ΔE 9.1 — on the
light halloween palette, levels 1 and 2 are all but the same colour — while any
gap of two or more never drops below 35.4. --background 1 and --background 2 are
both safe; --background 3 is drawn, with a warning:
-> 3 and 4 are neighbouring shades. On some themes they are all but the same
colour;
leave two levels between them — --background 2 is the safe one against
level 4.
Two combinations are refused outright rather than drawn, because neither produces art at all:
mossaic-art VYNCINT --background 4 # the background is the letters
# mossaic-art: the background (level 4) must be darker than the letters
# (level 4), or there is nothing to see
mossaic-art VYNCINT --background 1 --commits 1 # too few commits to hold two shades
# mossaic-art: --commits 1 puts the letters at level 1 and the background at
# level 1, so the letters would not show.
# In this year a letter day needs at least 2 commits to sit above a level-1
# background.That second one is worth understanding, because it is the one that surprises people. A shade is a fraction of the year's busiest day, so a year whose peak is 1 has exactly two shades in it — empty and full — and cannot hold a background at all. At a peak of 4 the counts 1, 2, 3, 4 land on levels 1, 2, 3, 4 exactly, which is as small as a five-shade year gets.
A background day is a band, not a target: it has a floor (contribute enough to reach the shade) and a ceiling (contribute more and it stops being background). Both matter, and they fail differently:
- Below the floor is debt. Contribute more, today or by back-dating.
- Above the ceiling is damage. Nothing takes contributions away, so a background day that ran to level 4 is a bright dot in the picture for good — the same kind of loss a lit day inside a letter has always been.
--track reports the two shades separately, because they are different kinds
of work — three hundred easy days would otherwise drown out seven hard ones:
the shades letters at level 4, background at level 1 · ΔE 35 at worst, clear
a background day has to reach 1 and stay under 2
letters ████████████████████████████ 75 of 75 bright
background ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 of 290 at level 1
owing 290 background days short, 290 contributions
The preview marks each state: ██ a letter that is bright enough, ▒▒ one
still short, ░░ background at the right shade, ·· background still to do,
XX a hole inside the letters, ++ a day outside them that has run too
bright.
One thing a background changes for the better: on a bare graph, any contribution inside the text block is a hole. With a background, a quiet day in there is exactly what the field wants — so a year that could not be drawn cleanly at all often can be, once the background gives those days somewhere to belong.
Everything above draws letters: a 5×5 font, two shades, Mon–Fri, weekends left clear. A canvas is the same machinery with the restrictions taken off — seven rows, up to 53 columns, and any of GitHub's five shades on any day.
mossaic-art --list-templates # what there is, with thumbnails
mossaic-art --template dragon --year 2027 # draw oneFour ship with it, and every one of them is a file in
art/templates/:
| name | what it is |
|---|---|
dragon |
a serpentine dragon coiling across the whole year |
wave |
one clean sine rolling across the year |
pulse |
a heartbeat trace: a quiet line, a spike every few weeks |
invader |
a space invader, centred |
They are worth reading as much as drawing: each is seven lines of digits, and copying one into your own file is the quickest way to start.
Dragon · 2027 · 53 of 53 columns · 146 days · 442 commits
level days commits each
4 75 4
2 71 2
0 225 must stay dark
shades 0 2 4 · closest pair 2 and 4 · ΔE 35, clear
That is what it looks like on the graph itself:
--commits N prices the brightest day, and every darker shade is priced
against it by the same formula GitHub shades with.
The last line of that report is the one worth reading twice, and it is the whole craft of this medium.
GitHub's five greens are not evenly spaced. Measured across all nine palettes it ships — three appearances times three seasons — the worst case for each pair is:
| 1 | 2 | 3 | 4 | |
|---|---|---|---|---|
| 0 | 20 · faint | 50 · clear | 62 · clear | 70 · clear |
| 1 | 9 · faint | 36 · clear | 35 · clear | |
| 2 | 11 · faint | 35 · clear | ||
| 3 | 17 · faint |
Every adjacent pair is faint. So a picture that uses all five shades cannot avoid putting two nearly-identical greens beside each other, however carefully it is drawn — it will look thorough in the terminal and read as a smudge on the graph.
{0, 2, 4} is the only set of three with no faint pair in it, and there is
no clear set of four. That is not a style preference, it is arithmetic, and the
test suite proves it by enumeration rather than asserting it here. It is why
the dragon is drawn in three shades and not five.
mossaic reports the closest pair the picture uses rather than the widest, because the widest always flatters: a drawing spanning 0 to 4 reads as ΔE 70 and can still hold two shades nobody can separate.
--draw opens an editor on the year:
mossaic-art --draw --year 2027 -o mine.art
mossaic-art --template dragon --draw -o mine.art # start from an existing oneArrows or hjkl move, 0–4 paint, space cycles a cell, the mouse paints
directly, u undoes, s saves. The panel underneath keeps a running count of
the days at each shade and what the picture would cost — the same
arithmetic --write uses, so the figure is what you would be asked to make
rather than an approximation of it.
Days in the partial weeks at either end of the year are drawn as ·. They can
be painted and they cost nothing, because they are not days the year has: a
Sunday in the first calendar column of 2027 belongs to 2026.
mossaic-art --matrix mine.art --year 2027
mossaic-art --image logo.png --year 2027 --ditherA .art file is seven rows of 0–4, or of the blocks ░▒▓█, with #
comments. --image reads a PNG, fits it to the calendar keeping its aspect
ratio, and quantises it: a dark pixel becomes a busy day, the way ink reads
on paper. --invert turns that over, and --dither spreads the rounding error
into neighbouring days so a gradient reads as one rather than as four bands.
The same as tracking text, because underneath it is the same plan:
mossaic-art --template dragon --year 2027 --save
mossaic-art --track# with --merge art/vyncint-2027.json --today 2027-08-19, to reproduce this exactly
level days done owing each
4 75 28 188 4
2 71 0 114 2
0 219 186 - must stay dark
still owing 104 days · 302 contributions
today must stay dark
tomorrow must stay dark
The prices are 4, 3, 2, 1 because that year's busiest day is quiet. Track the same picture against a year whose peak is 110 and a level-4 day costs 110, a level-3 day 74 — the shade is a fraction of the busiest day, so the cost of art is set by how active the year already is. That is the whole subject of How GitHub shades a day below, and it applies to a picture exactly as it does to text.
--save stores the picture inline in the plan file rather than storing the
template's name. A name is a pointer to something that can change underneath
you, and a plan is a record of what was decided — the same reason the start
column is saved resolved rather than as "centre it".
A level-0 day inside a picture is not a day the plan ignores. It is a day the plan wants dark, and contributing on it punches a hole in the drawing exactly as contributing inside a letter does. That is why the table above reports 219 of them: they are part of the picture.
holed is the one verdict you cannot answer by contributing more: days inside
the picture are already brighter than the shade they are drawn at, and nothing
takes a contribution away. So it is the verdict that most owes you a next move,
and --track sweeps every column of the year to find one:
Cannot be drawn cleanly — 5 days are brighter than the picture wants,
and nothing takes a contribution away.
--start-week 37 draws it cleanly.
It is offered in every format — suggested_start_week and suggested_holes in
json, a line of its own in markdown, and the suggested-start-week output on
the Action.
Two things decide which column it picks.
Fewest holes wins. If nothing draws the picture cleanly you are told the
least bad column instead — --start-week 12 would leave 3 holes instead of 25
— and if every column is equally bad you are told that, because an emptier year
is then the only way out.
Among columns that tie, one that has not begun yet. A clean column in March
is arithmetic, not advice: the only way to draw there is --backfill into days
five months gone. This matters more than it sounds. An eleven-column picture in
a fifty-three column year can easily have nine placements costing zero holes,
and ranked by column alone the answer is always the one in January.
The preference only breaks ties. A past column that draws the picture cleanly still beats a future one that does not, because back-dating is a thing this tool does and unlighting a day is not.
A column that would push part of the picture off the end of the year is never
suggested: a truncated picture is not a cleaner drawing of the same picture, it
is a smaller one. The first and last calendar columns are partial weeks, so a
picture carrying ink right to its edges overhangs them wherever it is put, and
is offered nothing at all. Blank margins are not counted — losing an empty cell
costs the picture nothing — so dragon, fifty-three columns wide with quiet
edges, still places.
Drawing the art is one command. Getting there while also living a normal year is
a hundred small decisions, and --track is the one that answers them:
# Against your own year. Add `--merge art/vyncint-2026.json --today 2026-08-19`
# to reproduce the output below exactly — that is the calendar this repository
# ships, read on the day this was written.
mossaic-art VYNCINT --year 2026 --trackVYNCINT · 2026 · tracking art/vyncint-2026.json
the plan 41 of 53 columns from week 6, on rows 1-5
the year 9,527 contributions · busiest Aug 11 (146)
a letter day has to reach 110 to match it
letters ██████░░░░░░░░░░░░░░░░░░░░░░ 18 of 75 bright
owing 57 days short, 5,994 contributions between them
holes 61 days are lit inside the letters and cannot be unlit
around 23 days outside the text with contributions
<the year, drawn: bright where a letter is done, dim where it is owed,
red where a day inside the letters is lit and cannot be unlit>
VYNCINT cannot be drawn cleanly in 2026.
61 days inside the letters already have contributions, and
nothing takes those away — the text would read with holes in it.
--start-week 1 would leave 23 instead of 61.
today Wed Aug 19 · inside the letters and already lit (113) — a permanent hole
tomorrow Thu Aug 20 · inside the letters — keep it dark, or it becomes a permanent hole
the next seven days
Wed Aug 19 hole
Thu Aug 20 keep dark
Fri Aug 21 letter 110 to go
Sat Aug 22 —
Sun Aug 23 —
Mon Aug 24 letter 110 to go
Tue Aug 25 keep dark
the rest of the year
23 letter days still to come, 2,530 contributions
34 letter days already past, 3,464 contributions — only back-dated
commits reach those:
mossaic-art VYNCINT --year 2026 --start-week 6 --top 1 --backfill --repo ../art --write
Four kinds of answer, and only two of them are work:
- A letter day that is short. Contribute more, today or by back-dating. Fixable whenever.
- A day inside the letters that must stay dark. The gaps inside and between
the letters are part of the picture, and a contribution landing on one punches
a hole that nothing takes back. This is the only kind of day where the
instruction is to do nothing, and the only one worth being told about a day
early — the report says
keep it dark, the seven-day schedule sayskeep dark, and the Action'stomorrow-kindoutput sayskeep-dark. - A day inside the letters that is already lit. The same day, after the fact.
Nothing takes contributions away, so it is a hole in the text for good. This is
the honest answer to "why can't I write VYNCINT in 2026": not that it is
expensive, but that the year has already been written on.
--trackcounts the holes, and sweeps--start-weekto find the placement that runs into fewest — for a picture as well as for text, preferring a column that has not begun when several cost the same. See When a picture cannot be drawn where it sits. - A day outside the text with contributions. Noise around the letters rather than damage to them; reported, not warned about.
--today DATE is what the report measures against, and it defaults to the
clock rather than replacing it. Two things it is for:
mossaic-art --track --today 2027-06-01 # what will that day owe?
mossaic-art --track --today 2026-08-19 # the same answer, next year and foreverThe first is planning; the second is why anything here can be tested or documented at all. A report that reads the clock is a report whose output changes overnight, which is no use in a README and no use in an assertion.
It reads --merge PATH if you have a saved calendar, and otherwise asks gh —
--track USER for someone else's year. Run it whenever you like: it holds no
state, so the answer is a fact about today's data rather than about the last
time you ran it.
Nagging cannot reach a day that has gone by, and --backfill is what does. It
reads the plan, asks what the year actually holds, and commits each day's
shortfall — nothing on a day that is already bright, and nothing at all on a
day whose job is to stay dark:
mossaic-art --backfill --repo ../art # what it would write
mossaic-art --backfill --repo ../art --write # write it, locally# with --merge art/vyncint-2026.json --today 2026-08-19, to reproduce this exactly
VYNCINT · 2026 · backfilling against art/vyncint-2026.json
letters 57 days short, 5,994 commits
a day gets what it is short of 110, never a flat count
reaching days before 2026-08-19, which are the ones only back-dating reaches
23 days from 2026-08-19 on are short too, and left alone — contribute on those as they come
warning: VYNCINT cannot be drawn cleanly in 2026 — 61 days inside the
letters are already lit, and nothing takes those away. Backfilling will
brighten the letters, and the text will still read with holes in it.
`mossaic-art --track` sweeps --start-week for a placement with fewer.
3,464 commits across 34 days, earliest 2026-02-09, latest 2026-08-14
(add --write to create them; this was a dry run)
With no background drawn, the 34 days and 3,464 commits are exactly what
--track reports as "already past": the two answers come from the same plan and
the same date, so they agree by construction rather than by coincidence. With a
background they will not match, and should not — --track counts "already past"
in letter days, while a backfill also lays down every past day of the field.
Only days already past. A day still to come needs no back-dating — you
contribute on it when it arrives — and whether GitHub counts a future-dated
commit at all is unverified (see below). --today is what "past" is measured
against, so the 34 days here are exactly the ones --track reports as "already
past".
A shortfall rather than a flat --commits, and the reason is the same
arithmetic the rest of this page turns on: a shade is a fraction of the year's
peak, so putting the same count on every lit day adds to the busiest of them
and raises the peak — which raises what every other letter day needs. The bar
moves as you walk towards it. A shortfall cannot, because what a day needs is
never more than the peak it is measured against: the brightest shade is three
quarters of it. Topping a day up to need therefore leaves the peak where the
scale already stood, so what every other day owes is the same afterwards as
before — one pass, and no second round of arithmetic to chase. (On an empty
year the peak does move, from nothing to whatever the art puts there, which is
the easy direction; Shades::min_peak is what keeps even that expressible.)
There is a test that proves the fixed point.
It finishes the past, not the year: the days still to come are deliberately left for you to contribute on as they arrive.
Like --write, it never pushes; it prints the command for you to run.
Why the price moves. Everything above is measured against the year's peak, because that is what GitHub shades against. One big day anywhere — a merge queue that landed 112 commits — raises what every letter day costs, which is why the report leads with the busiest day and what it did to the bar.
The tracker is also a GitHub Action, so the report can arrive rather than be asked for:
- id: art
uses: vyncint/mossaic/action@v0.8.1
with:
text: VYNCINT
year: "2027"
start-week: "6"
timezone: Asia/Ho_Chi_MinhIt hands back verdict, headline, markdown and json, plus the scalars —
bright, owing-commits, holes, today-short, tomorrow-need — and writes
the report to the job summary. Sending it on is a step you add after it, because
Slack, Discord, email and issue comments all have maintained actions already and
none of them belong inside this one; action/README.md has a workflow for each,
and action/track.example.yml is the file to copy into a repository of your own.
fail-on: behind turns "today is a letter day and it is short" into a failed
job, so the reminder arrives through the notifications you already have.
Verified against a real calendar, 365 of 365 days matching:
level = 0 when count == 0
level = min(4, ceil(count * 4 / peak)) otherwise
peak is the busiest day of that year. The buckets are equal slices of [0, peak],
not rank quartiles — so a day only reaches the brightest level once it passes
three quarters of the year's peak. Two consequences:
- An empty year is cheap. With nothing else in it the art sets the peak itself, so any uniform count lights every letter at the top level. A few commits per day is enough.
- An active year is expensive, and the price is set by your busiest day. Drawing
over a year whose peak is 112 needs ~85 commits per lit day just to match it — and
more still, because art landing on an already-busy day sums with it and pushes the
peak up again.
--mergesolves for the real figure rather than estimating it, and reports how many existing days would be as bright as the letters.
Placement matters as much as count: which days the letters happen to cover changes the
peak, and so the cost. Sweeping --start-week over one real year moved the bill from
25,275 commits to 12,900 for the same text — and --track does that sweep for you.
gh api graphql -f query='...' > real.json # your actual year
mossaic-art VYNCINT --year 2026 --start-week 1 \
--commits 113 --merge real.json --snapshot art/vyncint-2026.jsonCommits are written with git fast-import, so tens of thousands take under a second
rather than the minutes a git commit per commit would cost.
For commits to count towards the graph they must be on the default branch of a
repo you own (not a fork), authored with an email registered to your GitHub
account — git config user.email must be one GitHub knows.
Whether a future-dated commit counts is unverified. Git will happily date one
ahead of today, but GitHub may not count it; no account has a future year in
contributionYears, so there is nothing to check against. Push a single future-dated
commit and look before generating thousands. Dates inside the current year are the
safer bet either way: they become ordinary past dates as the year runs on.
The placement is part of the plan, and passing it every time is a mistake
waiting to happen: tracking with a different --start-week compares against a
different plan and says so confidently. Save it once instead.
mossaic-art VYNCINT --year 2027 --start-week 6 --save
mossaic-art --track # reads mossaic-plan.json, no flags neededThe file stores the placement resolved, so a text that was centred keeps the
column it was centred on. Typed flags still win over the saved ones, so
--year 2028 is a one-off rather than a surprise. --plan PATH puts the file
somewhere else.
A plan is version-locked to the tool that wrote it. Every key is checked,
not just every value: a plan carrying a key this build does not recognise is
refused by name rather than applied at its default. That closes the case
where backgruond: 2 — one transposition in a hand edit — silently turned
about 290 background days into keep-dark days at exit 0, on the file that is
the input to --backfill --write, where contributions cannot be unlit. The
cost is the other direction: a plan written by a newer mossaic is refused
too. Save it again with the version you are running.
A picture plan needs a mossaic that understands art. A plan saved from
--template, --matrix or --image stores the picture inline in the art
key. An older build ignores that key and falls back to drawing the text
field, which for a picture is its name — so a 146-day dragon becomes a
79-day word, at exit 0, and --backfill then asks for a different date range
and a different total.

