Format reference
Format history
One entry per version bump of the .gigchart file, newest first. Every version is fully backward-compatible: the app still opens a file written at any earlier version, migrating it on load.
On this page
This is the log, not the spec. If you are writing a file today, write version 12 and read the format reference — it documents the current shape, key by key, with worked examples. Come here to understand a file you were handed, or a spelling you met in an older export.
Older files never need converting: the app migrates on load, and every legacy spelling below is still read. What a version bump signals is the other direction — an older build reading a newer file may not find everything in it.
v12 — 2026-07-28 — Settings lose their redundant prefixes
- Every
settingsgroup already said what it was about, and then repeated itself in nearly every member —capo: { showCapo, capoOffset, capoPos, … }, ten*Prefixkeys inlabels, eightpageTurn*keys inpage. Inside a group calledcapo,offsetis the capo's offset, so each member is now spelled short. - Two families that had outgrown their group get containers:
page.pageTurn(the eight band keys) andsongInfoBox.fields(the per-field visibility flags). headersplits intotitle,subtitleandmetacontainers. Thetitle/subtitleprefix only ever existed to tell the two apart inside one flat group; now each isshow/align/color/offset, and the pair reads as the parallel thing it is.- The five
video*keys move out ofsongInfoBoxinto their ownvideogroup — the video link is page chrome of its own, a peer of the capo band, and was never one of the box's rows. songInfoBox.infoRowsis nowsongInfoBox.rows, andpage.pagePresetispage.preset.- Older files load unchanged, with no version check. Inside a group, a key the group knows is translated and anything else falls through as the legacy flat name — so
capo: { showCapo }(v4–v11) andcapo: { show }(v12) both load through one path, and a group you half-edit by hand loads correctly either way. The decision is per key, never per file. - Writes are a clean cut: the app writes only the new names. An older build reading a v12 file would not find its settings, which is what this bump signals.
- This is a serialization change only — nothing about how a chart renders moved.
- Additive since the bump. The entries below landed under v12 without a further bump: each is a new optional key, or a rename whose old spelling is still read, so an older build reading such a file gets less rather than wrong — and a bump is for the second case.
- 2026-07-29 — annotation
z, and the chord chip. An annotation may carryz, an integer paint depth (higher paints on top; absent = the kind's default, cues 1 and everything else 0).settings.concertgains the chip look:pillPad,pillPadV,pillBorder,chordFill,chordBorder,chordText,sepFill. AchordFontexisted for one day, 2026-07-29 to 07-30; a file carrying it still loads, with the key ignored. - 2026-07-30 —
video.scalebecomesvideo.rows, and it is a different quantity, not a new name for the same one.scalewas a multiplier on a fixed 16 mm square: it said how big the QR is in millimetres, which is true at the moment of the drag and wrong one font step later.rowsis the square's side counted in body grid rows, so the code holds its proportion to the music at every size.scaleis still read and converted against the file's own font size and line spacing — an older file opens at the size it printed at — but nothing writes it any more. If you are hand-writing a file, writerows. - 2026-07-30 — envelope
pageCount. The laid-out page count as the writer last rendered the song. Derived provenance besidesavedAt, not song content; readers treat it as a hint. - 2026-07-31 —
settings.bottomBoxis renamedsettings.stamp. Same members. A stamp is defined by being applied, not by where it sits, and this one had been draggable to any corner for a long time, so "bottom box" had stopped describing it. The old group name is read as an alias, so a file written under it loads unchanged and cures its own spelling on the next save. - 2026-07-31 —
scope, on six pieces of chrome.header.scope,songInfoBox.scope,capo.scope,video.scope,voiceLegend.scopeandstamp.scopesay which pages that element prints on:first·every·rest, pluslastfor the four that reserve no layout room. Before this there were two hardcoded answers in the whole app — everything in the header appeared on page 1, and the stamp appeared everywhere. Absent = the previous behaviour (first, oreveryfor the stamp), and a file written beforesongInfoBox.scopeexisted inheritsheader.scope, which is what governed the box then. See the header group for whylastis withheld from the two that reserve room. - 2026-07-31 — the video widget's own controls.
video.logo(the app mark punched into the QR),video.shortUrl(print the link's short form), andvideo.sizeMode/sizeOffset/sizePct/sizePt— the text link's font size, the same four-mode chooser the header lines use, resolved against the annotation font. A file written before them loads onto defaults that reproduce the size it printed at. - 2026-07-31 — the placed
qrannotation gains the same controls.linkMode'qr' | 'link'(absent ='qr', so every code placed before this stays a code), plusprefix,shortUrland the sharedboxSizeMode/boxSizeOffset/boxSizePct/boxFontPtfour. The logo and the colors stay song-level and theme-level respectively — not per code. - 2026-07-31 —
propertiesgrows identifiers and video provenance.mbArrangerUri,mbInstrumentUri,mbGenreUri;regionUri,holidayUri,languageUri(Wikidata items, named for the field rather than the authority); andvideoTitle/videoAuthor/videoAuthorUrl/videoFetched— what the video link resolved to when it was last looked up, and when. - 2026-08-11 —
settings.geom.chrome, andgeom.framechanges unit. The song chrome gains the same two drawing tokens an annotation carries, one entry per widget (info·capo·video·voices·stamp·concert·turn), so roundness and border width mean one thing whether you are editing a placed box or the footer badge. At the same timegeom.framestopped being an absolute px width and became a fraction of one text row likegeom.radius, 0–0.25: the default hairline is unchanged because leaving the key out still means the chart-wide 1px, but a border you dial up now scales with the page as the font grows.framewas authorable in px for about twelve hours on 2026-08-10; a file carrying one of those values is converted on read at the default row, so it draws the same weight it did. - 2026-08-12 —
geom.frameColor, and every box can be framed. A width with no colour was half a control: dialling a border up gave you a thicker version of an ink you could not choose.frameColorsits besideframein the same bag — a hex, ortransparent/currentColor— and is a slot reference like every other stored colour, so a chart re-themed later keeps its frames in step. In the same change,framestopped being honoured by only the four kinds that happened to draw a border: every kind with a box now draws a default-zero frame, so aframeon a cue chip, a section spine or a capo pill means what it says. Both are additive — a file written before this carries neither key, and absent draws exactly what it always drew. - 2026-08-12 — a page pin gains a sheet and an opinion about the grid. Two additive keys beside
pagePos.pinPageis the sheet the pin is frozen to: a pin used to freeze a coordinate but not a page, so a chip pinned to the bottom of page 2 followed its stanza to page 1 the moment the font shrank and arrived at the same x/y — neither position it had ever been placed at.pinFreesettles the other half. WhilesnapGridis on, a pin is now re-locked to the nearest character-lattice point, because the lattice moves underneath a frozen coordinate — its origin follows the first body row, its cell is re-measured at every zoom — and a spot that was on a column drifts off one. That correction assumes the spot was chosen on the grid, which is true of every position the app snapped and false of one dragged with snapping off;pinFreeis how such a position says so and is drawn verbatim. Both are absent in every file written before this, and absent means exactly what those files always did. - 2026-08-12 — the palette grows to 20 and is renumbered (
v: 3). Five colors the set had no answer for — a brown, a dark green, a navy, a pink and a second gray — and, with them, a renumbering so that a swatch's index, its position in the row and the number printed on it are the same number. Slots were briefly appended past the rainbow to avoid moving anything, which left the file format's numbers disagreeing with the ones on screen; for anyone writing a chart by hand or generating one, a spec that says "swatch index" and means something other than the sixth circle in the row is a trap that never stops costing, so the indices moved instead. Existing charts are migrated on read and do not change appearance: every stored assignment is rewritten to the slot now holding the color it used to name, and the rainbow gradient samples the same ten hues it always did. The palette carries"v": 3; write that yourself if you hand-author apaletteblock, or it will be read as the older layout and migrated. Whole-library backups move toversion: 3for the same reason — they carry raw slot numbers inpaletteAssign. - 2026-08-12 — a border is an ordinary palette color. The
-1sentinel introduced earlier the same day — derive this frame from the fill it rings — is retired. It needed a swatch in the color picker that was not one of the palette's colors, which is a thing to explain rather than a thing to look at. Frames now take an ordinary slot like any other element: the dark neighbour of the fill each one rings — red is framed in brown, green in forest, grey in dark grey. Nothing changes on screen: a frame's default width is zero.-1is still READ — any out-of-range index resolves to that element's own default, so files carrying it keep working — but it should not be written, and no version was bumped for it. - 2026-08-12 —
geom.pad, because the frame stopped pushing anything. A text box's border used to be a real CSS border, which came out of the box's inside and shoved the text in with it — visibly, and by a whole pixel even at width zero. It is now drawn inward, over the box, so it takes no room at any thickness and the words sit exactly where they sit with no border at all. That is the right default and it removes the only way there was to get the text off a heavy frame, sopadis that way: an inset, 0–0.5, in the same fraction-of-a-text-row unit as the two lengths beside it, and it is honoured on atextboxonly. It takes its room out of the box rather than growing it, so the frame stays exactly the size it was and a line fits fewer characters — the honest price, and the one you are choosing. Additive: absent means the zero inset every chart written before this already had. - 2026-08-12 —
geom.frameColoris ignored on atextbox. Not a format change, a correction to what this page claimed: the key was documented as applying to every kind that paints a box, and on a text box it never did anything. A text box colours its frame withboxStroke, which is also what says the box has a frame — soframeColorwas a second control at the same tier writing a value nothing read.boxStrokekeeps the job. Every other framed kind still readsgeom.frameColoras its one per-widget override, and theframeWIDTH is unaffected on all of them, text box included:boxStrokesays nothing about how thick the line is. - 2026-08-14 —
settings.annotations.snapAlign. The alignment half of snapping, and until now the invisible half. BesidesnapGrid, which says what LATTICE a drag steps on, this says whether a drag also snaps to things: the other widgets' edges and centres, and the page's own alignments — left margin, right margin, band centre — which a drop adopts outright, dropping the widget's nudge and recomputing its spot from the content box from then on. The adopt window is one whole grid column, which makes the position a single character off an alignment unreachable while it is on; that is deliberate, but it was happening with nothing drawn on screen, so it read as the drag refusing to go where it was put. The app now draws the guide line, and this key is the off switch. Absent =true, which is what every chart written before this was authored under. It is an authoring aid — it changes where a gesture lands, never where a saved widget prints — so a reader may ignore it and still render the file exactly. - 2026-08-14 — the two bar lists gain
barRound, and the road map gainsrowGap.roadmapandmarkersare the same list drawn on two axes, and they had drifted into two different widgets.barRound'outer' | 'all'says where the corner radius lands — on the outside corners of the whole block, or on all four of every bar — and is read by both. Absent ='outer', which is what a road map already drew; a marker group rounded every pill before this, so an older file reopens with the block look. That is a deliberate change of appearance, and a small one: the radius itself is untouched, ingeom.rowGap0 | 1 | 2is blank body rows between a road map's stacked bars — the vertical twin of a marker'sspacing, which counts characters between side-by-side ones. Two keys and not one because the units differ by axis, and because amarkerslist placed in a gap renders with the road map's stacked markup: reading itsspacing(default 1) as rows would double the height of every gap marker ever written. In the same change, a road-map bar'slabelmay contain newlines, each opening another body row of that bar, which the hosting spacer reserves. Both keys are absent in every file written before this, and absent means what those files already did. - 2026-08-15 — a horizontal marker group gains
groupCols, and its automatic width now asks the page. Amarkersgroup set totextDir: 'normal'reads across rather than down, so its labels' LENGTH becomes its width — and that width is page room, reserved beside every stanza the group sits on. It was therefore bounded, at a flat 20 columns shared equally between the bars. A constant is the wrong bound here: 20 columns is a reasonable share of a Letter page and a third of a tablet spread, so a two-word group could wrap mid-word with half the sheet standing empty beside it. The bound is now the page's own figure — a group may claim up to half the body's columns, and it asks for what its labels need rather than for whatever is going spare, so the text always keeps the other half and the width still holds still across a font change. Within that, the columns are shared by need and not evenly: a short label is served in full and the long ones split what is left, instead of every bar being cut to the same width for the sake of the widest.groupColsis the manual override — the whole rack's width in grid columns,4–40, the group-scale twin of a repeat bar'sbarCols. Narrower than the labels want and they wrap; wider and the extra is padding, which is how two groups on different stanzas are made to line up. Absent is a real value and means auto, so no existing file needs it — but auto is what changed, and a file whose labels wanted more than 20 columns, on a page with the room, now draws wider than it did. - 2026-08-12 — border color becomes a palette element, per kind. The palette's
assigngains a…borderentry for each of the nine boxed annotations (cueborder·sectionborder·repeatborder·capoborder·markersborder·roadmapborder·chorusborder·infoborder·chordboxborder), joining the frames the video widget, the voice legend and the page-turn band already had. Their default is not a hand-picked swatch but-1, a sentinel meaning derive this frame from the fill it goes around — a darker shade of that fill (lighter, once the fill is near black), which stays right when the fill is recolored, when the palette is swapped, and when one widget carries a color of its own. A per-widgetgeom.frameColorstill overrides it. Additive and invisible until asked for: a frame's default width is zero, so a chart written before this draws exactly as much border as it did — none. Nothing shifted slot, so the palette's own version counter is unchanged. - 2026-08-16 —
songInfoBox.color. The song info box's own fill, besidecapo.colorone group over:""or absent inherits the colour theme'sinfoboxswatch, a hex overrides it for this chart, and the text printed on it is chosen for contrast rather than stored. Like every other stored colour it is a slot reference that moves with the palette. The box already had this in its other home — the placeableinfoannotation carries acoloron its own record — and the two are one widget, so the song-level box was the half that could not be recoloured without recolouring every chart on the theme. Additive: a file written before this carries no key, and absent draws the theme's swatch exactly as it always did. - 2026-08-16 — the video widget and the placed QR stop sharing a look.
settings.videogainscolorandplateColor, and the placeableqrannotation gainsqrLogobeside thecolorandboxFillit already had room for. They are the same widget in two homes, and until now the one thing they could not each own was how they LOOK: the editor’s colour rows wrote the theme ELEMENTS and its logo switch wrote the song-wide flag, so a pick made on a placed code repainted the song’s widget, every other placed code, and — since recolouring an element forks a factory palette — the look of every chart wearing it. Each widget now keeps its own, with the element as the DEFAULT: absent inheritsqr/qrplateas a code andvideolink/videolinkbgas a link, which is what every file written before this draws. The one reading that would otherwise change is the mark: absent now means ON, so a song that saidlogo: falsehas that answer stamped onto its placed codes as it loads. Recolouring every code at once is still one action — it is the element’s own door, in the colour manager. - 2026-08-17 — the two bar lists gain
barFrame.barRound's question asked of the border rather than the corners, and read by both lists like its twin. A frame is drawn as an outline inside each painted element, and a road map's bars touch — so every internal edge of a framed stack carried two rings, one from each neighbour, and printed at twice the width of the frame around the outside.barFrame: 'outer'moves the ring onto the whole block, still inward, and leaves the bars unframed, so nothing is painted where two of them meet;'all'is the per-bar look a framed list drew before this key. Absent ='outer'where the bars touch and'all'whererowGap/spacingholds them apart — and a gap overrides a stored'outer', because bars that are visibly separate objects have no inside edge to skip. Additive, and it shows only on a list that setsgeom.frameat all (default0): such a file reopens with one ring instead of the doubled ladder, which is the point of the change. - 2026-08-17 — a spacer gains
tailRows, and the song's edges may be zero. A boundary split by a page break renders none of itsreserveLines: that room is padding above the lower stanza, and it is deliberately dropped when the stanza opens a page, because the break is already the bigger visual break. Useful as a default, but it left the foot of a page as the one place in a song with no spacing knob — the widgets standing in a split boundary were reserved for, the blank space around them was not.tailRows(0–12, default 0) is that knob: space at the foot of the page a boundary closes, live only while a break actually lands there, forced or automatic. It is the mirror ofspaceAfteron apagebreak(the top of the page a break opens) and lives on the spacer because the spacer is the only object an automatic break's boundary has. It is charged to the closing page, so a large value can push the upper stanza onto the next page. In the same change,reserveLinesmay be 0 at the song's edges —after: -1and after the last stanza are margin, not separators — which is how a chart with no title starts flush at the top. Additive both ways: absenttailRowsis exactly the old collapse, and nothing written before this moves. A spacer carryingtailRowsis always written out, since the reader's boundary heal would not reconstruct it.
v11 — 2026-07-28 — Alignment and offset become exclusive
placementgains'free'— shown as Follow text in the app. It is the only mode in which a widget's horizontaloffset.dxis read, and its origin is the target's own left edge.- Every other
placementis now an alignment: the app recomputes that spot from the content box and the widget's own width on each render, and ignoresoffset.dx. An aligned widget therefore cannot drift when the font size changes and cannot leave the page. offset.dyis unchanged and honoured in every mode — vertically a widget is always follow-text, i.e. rows down from its target's top.- Up to v10 the two were superimposed: the app took a base from
placementand added the scaled nudge on top, so a file could sayplacement:'left'withdx:+516.516— "left-aligned, and 1250px to the right of left". The nudge scaled with the font while the page margin did not, so widgets walked off the page. - Older files are healed on load: the app works out where each widget actually drew, then either adopts the alignment cleanly (dropping the leftover nudge) or converts it to
'free'withdxrebased onto the target's left edge. A v2–v10 file opens unchanged; an older build reading a v11 file would mis-place a'free'widget, which is what this bump signals.
v10 — 2026-07-28 — Linked data, second pass
- The
gc:vocabulary is published: https://www.gigcharts.app/ns/gigchart now resolves to the term definitions (Turtle alternate at /ns/gigchart.ttl). Until v10 that IRI fell through to the landing page — the terms looked resolvable and were opaque. - The envelope's
idis now the JSON-LD node identifier: the context mapsid→@id. It was previously read as an ordinary string property, so a chart expanded to a blank node — nothing could refer to it. - The envelope gains
@type(gc:Chart) andsonggains@type(schema:MusicComposition). Both optional in a hand-written file — the app stamps them on save. - An annotation's
kindis now its JSON-LD type: the context aliaseskind→@type, so a cue expands asgc:cueand its target asgc:line. No file change — the same JSON, better typed. The classes are defined in the vocabulary. durationmaps togc:duration, notschema:duration: the file holds a display string (3:45) andschema:duration's range is an ISO-8601 duration (PT3M45S). Asserting one into the other was a range violation.savedAtis typedxsd:dateTimerather thanschema:DateTime— the RDF-native datatype.- Nothing in this version changes how a file loads. A v10 file opens in an older build (the added keys are ignored), and a v9 file opens unchanged.
v9 — 2026-07-28 — Semantic stanza anchors
- New
song.structure: one sticky id per stanza, in body order — the anchors an annotation'ssid/gapAfterSid/refSidpoint at, so a target survives editing the body above it. - Annotation targets carry their stanza id beside the index; the index remains the fallback for a file without a ledger.
textRangesreplaces flat-offsettextStyleswhere every styled run has a stanza to name — stanza-local{ sid, line, from, to }. A run with no stanza home (it sits on a blank line) still writes the flat form, and one file never carries both.- A target may say
lineBasis: 'lyric'to count lyric lines only, and let the app resolve the rendered row.
v8 — 2026-07-28 — Interchange & FAIR pass
- The envelope gains
@context(the published JSON-LD context, below), an ISO-8601savedAt, and ageneratorstring — the build (and git sha) that wrote the file. tempo/year/trackwrite as JSON numbers when the value is cleanly numeric; a non-numeric value (e.g."3 of 12") still writes as a string.- Every number in the file is rounded to 3 decimal places — drag/zoom offsets no longer carry float64 noise.
- The song's own voice roster travels in full, not just the voices an annotation actually references.
- The active color palette always travels, including the four factory palettes — previously only a custom palette was written.
- New property:
license— the chart/arrangement's own license, distinct fromcopyright. - The file's declared media type is now
application/vnd.gigchart+json. - The envelope gains
id— the chart's own identifier (urn:uuid:…), for provenance/dedup. It resolves nowhere and names nothing outside the file; opening a file never adopts itsid— the app always mints a fresh library id on import. - Ids are now optional everywhere in a hand-written file: annotation ids, bar-item ids, info-row ids and spacer ids are minted on read when absent. The app itself no longer writes bar-item ids, info-row ids, or unreferenced spacer ids at all. Never invent an id — omit it.
spacersis now always present in what the app writes (possibly[]): a boundary's lone default spacer (reserveLines1, not hidden, referenced by no annotation) is omitted by the writer and reconstructed identically by the reader. Absence of the key entirely is still the pre-v5 migration signal.- An annotation
offsetof{dx:0, dy:0}is omitted — absence already means no nudge.
v7 — 2026-07-24 — Info box group renamed
settings.infoBox1renamed tosettings.songInfoBox— same keys. A pre-v7 file'sinfoBox1group is still read as an alias.
v6 — 2026-07-23 — Format sanity-check cleanup
properties.revisionreplacesproperties.versionfor the song's own revision — it no longer collides with the envelope's formatversion.textStylesuses spelled-out keys (italic/underline/voice), not the old single letters (i/u/v).- The recall annotation kind serializes as
recall(waschorus). - Every legacy spelling above is still read, so v2–v5 files load unchanged.
v5 — 2026-07-19 — Explicit spacers
- Gap-scoped annotations (cue, road map, page break, repeat-section marks) are hosted by explicit spacer blocks that own the boundary and its height.
- A pre-v5 file has no
spacerskey — the app migrates its{ kind: 'gap' }annotations into spacers on load.
v4 — 2026-07-08 — Grouped settings, American spelling
settingsis grouped by function (typography/header/songInfoBox/bottomBox/capo/notation/concert/annotations) instead of one flat ~50-key bag.- Colors serialize as
color(American spelling), not the Britishcolour. Reads still acceptcolour, so pre-v4 files keep loading.
v3 — 2026-07-04 — Markers unified
- The
performerkind (side-by-side single-label panels) becamemarkers— one grouped element carrying an ordered list of colored bars. Old v2performerannotations are not migrated; files still read back to v2.