Skip to content

Format reference

The .gigchart file, in full

Everything a chart carries, key by key — so an assistant (or a script, or you in a text editor) can write a finished, fully annotated song, not just its words and chords.

On this page

A .gigchart is one JSON file. It holds the song, its annotations and the exact way it should be typeset, and it is the whole asset: hand it to another machine and the chart comes out identical. This page documents every part of it.

Having an LLM write the chart? Author with AI is the page to hand your assistant — the prompt, the ChordPro path and the one-click import link; this page is the schema behind it. If you only want to get a song in, that shorter read is enough: ChordPro text covers words, chords, sections and cues. Come here when you want the rest — exact placement, colors, voices, road maps, page geometry.

The envelope

{
  "@context": "https://www.gigcharts.app/context/gigchart.jsonld", // optional, JSON-LD (see below)
  "@type": "gc:Chart",                     // optional, the document's own type (v10)
  "format": "gigchart",                    // always this literal
  "version": 12,                           // current file version
  "id": "urn:uuid:5b1e6e1a-2f3c-4a7b-9d21-6c9a1e3f7d02", // optional, the chart's own identifier
  "savedAt": "2026-07-28T09:00:00.000Z",   // optional, ISO 8601 (pre-v8 files wrote ms epoch; both load)
  "generator": "gigcharts.app v0.9.0 (a1b2c3d)", // optional, which build wrote the file
  "song": { … }                            // everything below
}

The smallest file that opens and renders:

{
  "format": "gigchart",
  "version": 12,
  "song": {
    "properties": { "title": "House of the Rising Sun", "artist": "Traditional" },
    "body": [
      "Am         C        D     F",
      "There is a house in New Orleans",
      "",
      "Am      C      D       F",
      "My mother was a tailor"
    ]
  }
}

version is the file format's version, not the song's (that's properties.revision). Write 12, the current version. Older files still load — the app migrates them on read — but new producers should emit the current shape. Unknown keys are ignored rather than rejected, so a file written against a later version still opens.

Format history has moved → what changed at every version, v3 to v12. This page stays present-tense: it describes version 12 and nothing else.

A .gigchart file declares the media type application/vnd.gigchart+json and uses the .gigchart extension. It's plain JSON underneath — any JSON tool reads it — the media type just names the shape.

Envelope keys

KeyTypeDefaultWhat it is
format 'gigchart' Always this literal.
version number The file format's version (not the song's — that's properties.revision). Write the current version.
id string (urn:uuid:…) The chart's own identifier (v8), stamped by the app on save. Provenance/dedup only — it resolves nowhere and names nothing outside the file. Optional; omit it in a hand-written file. Opening a file doesn't adopt its id: the app always mints a fresh library id on import, so opening your own export never overwrites the library original.
@context string (URL) Points at the published JSON-LD context (v8). Descriptive linked data, not load-bearing — see Linked data below.
@type 'gc:Chart' The document's own JSON-LD type (v10) — a chart document, defined in the GigCharts vocabulary. Optional; omit it in a hand-written file and the app stamps it on the next save.
savedAt string | number When the file was saved. v8+ writes ISO 8601; pre-v8 files wrote ms epoch. Both load.
generator string Which build wrote the file, e.g. gigcharts.app v0.9.0 (a1b2c3d) (v8). The tiebreaker when two files share a version but came from different builds.
pageCount number The song's laid-out page count as the writer last rendered it (v12 additive, 2026-07-30). Derived provenance, like savedAt — a different device or layout may re-paginate, so readers treat it as a hint (the app seeds its library summary from it on import, then re-measures on the next save). Omit it in a hand-written file.
song object Everything below — the song itself.

What song contains

KeyTypeWhat it is
@type 'schema:MusicComposition' The song's JSON-LD type (v10) — it is a schema.org MusicComposition. Optional; omit it in a hand-written file and the app stamps it on the next save. A JSON-LD context can't inject a type into a node, which is why this is written rather than mapped.
properties object Song identity — title, artist, key, tempo… Everything here is optional.
body string[] The chart itself, one array entry per line. "" separates stanzas.
structure object[] The stanza ledger (v9): [{ id }], one entry per stanza of body, in order. It is what an annotation's sid / gapAfterSid / refSid names, so a target survives someone editing the stanzas above it. Omit it in a hand-written file and anchor by index — the app mints the ledger on the next save.
settings object Presentation, grouped by function. Omit it entirely and the app uses its defaults.
annotations object[] Cues, sections, repeats, road maps, voices, capos, page breaks.
spacers object[] Explicit blocks of vertical space: { after, reserveLines, tailRows?, hidden? }. reserveLines is at least 1 between stanzas and may be 0 at the song's edges; tailRows is space at the foot of the page this boundary closes, live only while a page break lands there. The app itself always writes this key now (possibly []) — its presence is the signal that the file already uses the spacer model. In a hand-written file, omit it: the app builds spacers from your blank lines and gap annotations.
voices object { roster: [{ id, name, color }], defaultVoiceId } — the people whose lines get colored.
textRanges object[] The same inline italic / underline / voice-colour runs, spelled stanza-locally (v9): [{ sid, line, from, to, style }], where line is the line inside that stanza and from/to are columns in it. Prefer this — a flat body offset moves whenever anything above it changes length. Wins over textStyles on read; the app writes one or the other, never both.
textStyles object The same runs in flat body character offsets: { italic, underline, voice }. Written only when some run has no stanza to name (it sits on a blank line), so textRanges can't express the whole set. (Files before v6 used the short keys i / u / v; still read.)
palette object A custom color theme traveling with the song (20 swatches). Its assign maps each colorable element to a swatch index, and that index is also the position in the row you see in the app and the number printed on the swatch — there is one number for a color. In order: 0 red · 1 brown · 2 orange · 3 yellow · 4 lime · 5 green · 6 forest · 7 teal · 8 cyan · 9 blue · 10 navy · 11 violet · 12 magenta · 13 pink · 14 black · 15 dark gray · 16 gray · 17 white · 18 no fill · 19 rainbow. The last two are structural and not editable: 18 paints nothing (a transparent fill, or no line on a border element), 19 is the theme's gradient and is valid only as a background. Set "v": 3 when you write a palette by hand — one with no v is read as the original layout and put through the version migrations, which will move your indices. Every element takes an ordinary swatch index, frames included. Each frame defaults to the dark neighbour of its own fill — a red cue chip is framed in brown, a green section spine in forest, a grey chord box in dark grey — and paints nothing until the widget is given a border width, so leaving assign out is always safe. Files written on 2026-08-12 may carry -1 on an element whose name ends in border: a short-lived sentinel meaning derive this frame from the fill it goes around, now retired. It still reads without error — any out-of-range index resolves to that element's own default — but do not write it.

properties — the song's identity

Free-text fields, all optional, all strings unless noted. These are the same values the ChordPro directives on the import page fill in — only the key spelling differs ({key: Am} here is originalKey).

The mb…Uri keys at the end are the exception: they are resolvable MusicBrainz addresses rather than free text, captured when the author picks a search result and dropped the moment the field beside them is edited by hand. See linked data for what they're for.

KeyTypeWhat it does
title string Song title.
artist string Performing artist.
subtitle string Free subtitle text — its own field, not the artist.
music string Composer credit.
lyrics string Lyricist credit.
arranger string Arranger credit.
tempo string | number Tempo in BPM. Stored internally as bpm; write tempo.
timeSig string Time signature, e.g. 6/8.
style string Beat / style, e.g. Cha cha. Shown as “beat” in the info box.
genre string Genre.
originalKey string The key the chart is written in.
keyActual string The sounding key, when a capo makes it differ.
capo string Capo fret as text ("2"). A non-numeric value means no concert transpose.
capoMode '' | 'capo' | 'concert' Whether the chart is written capo-relative or at concert pitch.
targetInstrument string Instrument the chart is for.
revision string The chart's own revision, printed in the bottom box. (Files before v6 spelled this version, which collided with the envelope's format version; that spelling is still read.)
video string (URL) Video link — printed as a link or a QR code (see settings.video.mode).
album string Album name.
track string | number Track number. Written as a JSON number when cleanly numeric (v8); a non-numeric value (e.g. "3 of 12") stays a string.
year string | number Year. Written as a JSON number when cleanly numeric (v8); a non-numeric value stays a string.
copyright string Copyright line.
license string The license of the chart/arrangement itself — free text: an SPDX id (CC-BY-4.0), a URL, or e.g. Public domain. Distinct from copyright, which is the credit line, not a license grant.
duration string Duration, e.g. 3:45.
holiday string Occasion the song belongs to, e.g. Christmas.
region string Where the song is from — a region, village or tradition, e.g. Kalotaszeg. Free text; there is no controlled vocabulary behind it yet.
source string (URL) Where this chart came from — a collection, recording or page.
language string The language of the lyrics.
mbTitleUri string (URL) MusicBrainz URI for the work — the composition, never a recording of it. Captured when the author picks a search result, and cleared the moment the title is hand-edited, so it can never describe a value it no longer matches.
mbArtistUri string (URL) MusicBrainz URI for the performing artist.
mbMusicUri string (URL) MusicBrainz URI for the composer.
mbLyricsUri string (URL) MusicBrainz URI for the lyricist.
mbArrangerUri string (URL) MusicBrainz URI for the arranger (v12 additive, 2026-07-31).
mbInstrumentUri string (URL) MusicBrainz URI for targetInstrument (v12 additive, 2026-07-31).
mbGenreUri string (URL) MusicBrainz URI for genre (v12 additive, 2026-07-31). The genre vocabulary has no search endpoint, so GigCharts ships the list; the identifier is the same one MusicBrainz publishes.
regionUri string (URL) Identifier for region — a Wikidata item, e.g. wikidata.org/wiki/Q832218 for Kalotaszeg (v12 additive, 2026-07-31). Named for the field rather than the source, unlike the mb… keys: a place could as reasonably carry a GeoNames or Getty id, and the URI itself says which authority answered.
holidayUri string (URL) Identifier for holiday — a Wikidata item (v12 additive, 2026-07-31). Same reasoning as regionUri.
languageUri string (URL) Identifier for language — the Wikidata item for that language, e.g. Q9067 for Hungarian (v12 additive, 2026-07-31). The NAMES come from the browser's own CLDR data, so the field offers every language in whatever language you work in; the identifier comes from a bundled ISO 639 → Wikidata map.
videoTitle string What video pointed at when it was last looked up (v12 additive, 2026-07-31). Cached provenance, not chart content — GigCharts never prints it. Links rot; this is what tells you what to go looking for.
videoAuthor string The channel that published the video.
videoAuthorUrl string (URL) That channel's page.
videoFetched string (ISO date) The day the three fields above were fetched — the "accessed on" of a citation. A cached fact about somebody else's page without a date is a claim about the present that nobody checked.
mbAlbumUri string (URL) MusicBrainz URI for the release.

Nothing here is printed just because it is set: the header prints what its title and subtitle templates ask for, and the info box prints the fields it is told to show. Setting album or copyright stores them for later without changing the page.

body — the chart itself

An array of lines: ["chord line", "lyric line", "", …]. One entry per line, no trailing newline, and an empty string separates stanzas. Two chord notations work, and they can be mixed:

  • Chords above lyrics — a chord-only line whose columns sit over the syllable they change on, then its lyric line. Columns are what align, so this only works in a monospace font — which is exactly what GigCharts renders in.
  • Inline[Am]There is a [C]house. The renderer splits each such line into a chord row and a lyric row.

Stanzas, rows and gaps

Annotations don't point at characters; they point at stanzas, rows inside a stanza, and the gaps between stanzas. All three are 0-based and all three are counted the same way everywhere in the file:

stanza 0  ┌ row 0   Am         C        D     F        ← chord row
          │ row 1   There is a house in New Orleans  ← lyric row
          │ row 2   Am         C           E
          └ row 3   They call the Rising Sun
                                                     ← gapAfter 0
stanza 1  ┌ row 0   Am      C      D       F
          └ row 1   My mother was a tailor
  • Stanza — a run of non-blank lines. Blank lines separate them; they aren't stanzas themselves.
  • Row — a rendered row inside the stanza. A chord row and a lyric row are two rows, so in the usual chord-over-lyric chart the lyric rows are the odd indices (1, 3, 5…). A wrapped line adds rows. To skip the counting, add lineBasis: 'lyric' to a line/range target and line counts lyric lines only (0-based) — the app resolves it to the row on load.
  • GapgapAfter: n is the boundary after stanza n; -1 is the space above the first stanza.

annotations — everything drawn over the music

A flat array. Each entry is one widget: what it is (kind), where it goes (target), and its own fields.

{
  "id": "a-cue-1",
  "kind": "cue",
  "text": "slower",
  "target": { "kind": "line", "stanza": 0, "line": 1 },
  "placement": "inline",
  "color": "#e23b3b"
}

Fields every annotation shares

KeyTypeDefaultWhat it does
id string auto-minted Unique within the song. Optional (v8): the app mints one on read when it's absent, so a hand-written file never needs to invent one — omit it.
kind AnnKind One of the kinds listed below.
target object What it is anchored to — see the target table.
hidden boolean false Kept in the file and the Layers panel, but not drawn and not exported.
color hex string the theme's color for that kind Base color (a hex string). Files from older app versions still load unchanged.
placement 'free' | an alignment the kind's own default Since v11 these are exclusive. 'free' ("Follow text") is the only mode that reads offset.dx, measured from the target's left edge. Every other value is an alignment — recomputed from the content box each render, so it can't drift or leave the page — and offset.dx is ignored. Which alignments a kind offers is listed per kind below.
offset { dx, dy, atFont? } none Free-drag nudge in px off the computed spot. atFont is the body pt size it was placed at, so the nudge scales with reflow. A zero offset ({dx:0, dy:0}) is omitted (v8) — absence already means no nudge. Since v11 dx applies only when placement is 'free'; dy applies in every mode.
anchor 'row' | 'page' 'row' 'row' follows the music through a reflow; 'page' pins the widget to the page.
pagePos { x, y } none Frozen page-box position in unscaled px from the page's top-left, used only when anchor is 'page'. While the song's snapGrid is on, a pin is drawn on the nearest character-lattice point — at most half a cell from the value you wrote. That is drift correction, not a placement: the lattice's origin follows the first body row and its cell is re-measured at every zoom, so a spot that was on a column the day it was pinned would otherwise wander off one. Set pinFree when your px are exact and must be honoured as written.
pinPage integer the target's page Which sheet the pin is frozen to (v12 additive, 0-based), captured with pagePos. A pin used to freeze a coordinate but not a page, so which sheet a widget drew on was still decided by wherever its target stanza reflowed to — 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, which is neither spot it had ever been placed at. The stanza remains the widget's owner (the Layers tree, and delete-with-stanza); the pin only overrides where it draws. Clamped to the last page on render, so a chart that loses pages keeps every pinned widget visible. Absent = the old behaviour, and the app stamps the sheet the widget is already showing on the first time it renders the song for editing.
pinFree boolean false true = these px were chosen off the lattice on purpose (v12 additive), so the re-lock described under pagePos is skipped and the pin draws exactly where it says whatever the snap mode is. The app stamps it when the gesture that set pagePos had no snap step, and clears it on any drag or nudge that had one — which is how such a pin rejoins the grid. Leave it out unless you mean it: absent means "this spot is a lattice point", which is what every position the app snapped is.
geom { radius?, frame?, frameColor?, pad? } none How the widget is DRAWN, as opposed to what it says and where it goes (v12 additive). radius is corner roundness as a fraction of one text row (0–0.5, where 0.5 is a full pill) — not pixels, because a 4px corner is a pill at 7pt and a hint at 30pt, while a fraction keeps the same shape at every size. frame is border thickness in the same unit (0–0.25 of a row), for the same reason: leaving it out keeps the chart-wide hairline, while a border you deliberately dial up scales with the page as the font grows. A non-zero value never resolves below half a pixel; 0 means no border at all. frameColor is what that border is drawn in — a hex, or transparent / currentColor — and like every other stored colour it is a slot reference that moves with the palette rather than a fixed ink; it is an override for this one widget, and a value that is not one of those shapes is ignored rather than read as black. Absent is the normal case and does not mean "no colour": the frame then takes the palette's colour for that KIND of widget — the …border elements in the palette — which by default is the dark neighbour of that widget's own fill. pad is the inset (0–0.5 of a row): how far the widget's text is held off its own edge. A frame is drawn inward, over the box and never moves the text at any thickness, so this is the key for when you want it moved — to keep a heavy border clear of the words. It takes the room out of the box, which stays exactly the size it was, so a line fits fewer characters. radius and frame apply to every kind that paints a box; a pagebreak is a rule across the page and a voice annotation draws nothing, so neither takes any of them. Two exceptions: frameColor is ignored on a textbox, which colours its frame with its own boxStroke (that field also decides whether the box has a frame at all, so a second colour here would be a rival with no tie-breaker); and pad is honoured on a textbox only — chips and bars are positioned from an estimate of their own label, so padding one would draw it wider than the space reserved for it. Leave it out unless you mean it — absent is not a bag of defaults, it means whatever the chart already looks like, and { radius: 0 } is a real instruction to square a widget that would otherwise be round.
z integer cues 1, everything else 0 Paint depth when widgets overlap (v12 additive): higher paints on top, ties break by list order. The app sets it via right-click Bring to front / Send to back; leave it out unless one widget must draw over another. The chart itself is on this scale too — the song chrome carries its own depths in settings.depth.chrome, and 0 is the plane the title, lyrics and boxes paint on, so a negative z puts a widget BEHIND the chart (a filled text box becomes a colored band behind the title). Affects painting only — never packing, gutters or reserved space.

Colors serialize as color — on annotations, on bar items and on voices. Leave a color out and the element follows the chart's theme, which is usually what you want.

Targets

TargetIndicesAnchors to
{ kind: 'stanza', stanza } 0-based stanza index The whole stanza.
{ kind: 'span', from, to } inclusive stanza range A run of stanzas — one spine or bar covering several.
{ kind: 'line', stanza, line } row index inside the stanza One rendered row. Rows alternate chord, lyric, chord, lyric… so lyric rows are the odd indices. Add lineBasis: 'lyric' to count lyric lines only (0-based) and let the app resolve the row for you.
{ kind: 'range', stanza, line, from, to } row + column range A word range on one lyric row; from/to are character columns in that row. Also accepts lineBasis: 'lyric'.
{ kind: 'gap', gapAfter } boundary index The gap after stanza gapAfter (-1 = before the first stanza). Use this — the app converts it to a spacer on load.
{ kind: 'spacer', spacerId } spacer id A gap annotation already hosted by an explicit spacer. What the app writes when it saves.

Gap-scoped kinds — cue, road map, repeat-section mark, page break — are stored against a spacer once the app has loaded them. You don't have to build those: write { "kind": "gap", "gapAfter": n } and the app mints the spacer, gives it a sensible height, and rewrites the target on load.

The annotation kinds

kindWhat it drawsTargetsIts own fields
section A labeled spine beside a stanza or a run of stanzas (Verse, Chorus, Bridge…). stanza, span text (the label), placement 'left' | 'inline' | 'right', textDir 'left' | 'right' | 'stack', orient 'auto' | 'horizontal' | 'vertical'
cue A small labeled chip — a performance note. The most-used annotation. line, stanza, gap / spacer text (\n makes a multi-line chip), placement — on a stanza 'above' | 'inline' | 'beside' | 'right', on a line 'inline' | 'right', in a gap 'left' | 'inline' (center) | 'right'
repeat A repeat bar with a ×N count beside a stanza. stanza, span text = the count ("2" renders 2×), barCols = the bar's width in whole grid columns — i.e. body characters — 25, default 3. A column count, never a physical size, so the bar keeps its width when the font moves. placement 'left' | 'inline' | 'right'
markers A group of narrow vertical bars in a gutter — who plays what, side by side. In a gap it renders like a road map instead: full-width stacked bars, one body row each. stanza, span, gap / spacer items[] (the bars), textDir (adds 'normal' = a horizontal chip), spacing 0 | 1 | 2 — whole body characters between the side-by-side bars, groupCols ('normal' only) = the whole rack's width in grid columns, 440; omit it for auto, which is as wide as the labels need. Either way the page caps it at half the text width, and a label longer than the width it gets simply wraps. barRound 'outer' | 'all' (default 'outer': the corner radius lands on the outside of the whole rack, not on every pill), barFrame 'outer' | 'all' — the same question asked of the border: one ring round the whole block, or one per bar. Default 'outer' where the bars touch and 'all' where spacing/rowGap holds them apart, and a gap overrides a stored 'outer' (separate bars have no inside edge to skip). Only visible on a list that sets geom.frame, placement
roadmap An arrangement list: stacked bars in a gap (Intro · Verse ×4 · Outro). gap / spacer items[] (a bar's label may contain \n: each line is another body row of that bar), align 'left' | 'center' | 'right' (text inside the bars), roadmapWidth 'full' | 'longest', rowGap 0 | 1 | 2 — blank body rows between the stacked bars, the vertical twin of a marker's spacing (default 0), barRound (as above), barFrame (as above — with the default rowGap: 0 a framed road map draws ONE ring around the stack, not one per bar), placement (only when 'longest')
recall A “Repeat: …” recall mark in a gap, pointing at an earlier stanza — or that stanza's full text inlined. gap / spacer refStanza (which stanza to recall), text (overrides the “Repeat:” prefix), showRecallText, hideChip, placement. Like a cue chip, the mark rides its boundary’s blank space and reserves no rows of its own — a reserveLines on a recall is ignored.
capo A per-stanza capo pill — retunes the concert chords from that stanza on. stanza text = the fret ("5"), placement 'above' | 'inline'. 'above' reserves a body row; 'inline' costs no page room.
voice Colors lyrics in a voice's color — a whole stanza, one line, or a word range. stanza, line, range voiceId — an id from song.voices.roster. No text, no color (the voice supplies it).
pagebreak Forces a page break at that gap. gap / spacer spaceAfter 0–3 — blank body rows opened at the top of the page the break creates (room for a cue chip or a capo pill; a spacer can't reach that spot — its rows render at the bottom of the previous page, before the break). turnPreview 'on' | 'off' — this boundary's page-turn preview, overriding the song's settings.page.pageTurn.preview. Omit it to inherit: that is a third state, not a synonym for 'off', so a break that agrees with the song today keeps agreeing when the song changes. Only an explicit break can carry it — a page boundary the layout invented moves with the font and has no address to hang a rule on.
info A placeable info box — a list of song fields, dropped beside any stanza or into a gap. stanza, gap / spacer rows[] of { field, prefix?, hidden? }field is a title-template field id (bpm, timeSig, style, album…); a row prints "<prefix> <value>" and is skipped when the song has no value. placement like a cue.
chordbox A box of typed chords, drawn like the concert box — an intro, a turnaround, a shape to keep in view. stanza, gap / spacer text (\n = one row per line), placement — on a stanza 'beside' | 'right', in a gap 'left' | 'inline' (center) | 'right', transposeWith (omit/true = follow the song's transpose), boxColor, chordColor.
linkedchordbox The same box, but its content is a live mirror of another stanza's chords — edit the source, the box follows. stanza, gap / spacer refStanza (+refSid), chordAlign 'beat' | 'pack', chordGap 1–5, placement / transposeWith / colors as chordbox.
qr A placeable QR code, carrying its own link — as many per song as you like. (The song's own QR is chrome, derived from the video property; this is the one you point somewhere else.) stanza, gap / spacer url — what the code encodes, unrelated to the song's video property. linkMode 'qr' | 'link' (default 'qr') — print the square, or print the URL as a text link instead; the same choice the song's own widget makes in settings.video.mode. qrRows — the square's side in body grid rows (1.5–9, default 3): a row count, not a physical size, so the code keeps its proportion to the music at every font size. qrFill — how much of that square the code itself fills, in percent (50–100, default 100); the box is unchanged, and the margin it opens is where the plate and frame colors show. In text mode instead: prefix (printed before the link; omit for the shared ), shortUrl (print the link's short form rather than the URL as typed), and the font size via boxSizeMode / boxSizeOffset / boxSizePct / boxFontPt, as textbox. placement as chordbox. In a gap the square books ceil(qrRows) rows of real room; beside a stanza it reserves nothing and floats. Its look is its own (2026-08-16, v12 additive): qrLogo — the app mark, absent = on — plus color (the modules, or the link's glyphs) and boxFill (the plate, or the link's background), both absent = the color theme's element for the form being drawn. Nothing here reads the song-level widget's settings any more; the theme is what the two tiers share, and it is edited from the color manager.
textbox A free-floating, resizable rich-text frame. Floats over the music — it reserves no rows and never reflows the chart. stanza, gap / spacer (the anchor only picks which page it belongs to) text, textRuns[] of { from, to, b?, i?, u?, c? } (per-range bold/italic/underline/color), boxFrame 'grow' | 'fixed' | 'fit' with boxCols/boxRows or boxW/boxH (fractions of the page width), boxFlush 'w' | 'h' | 'wh' (that axis is stretched flush to the page's content edge — it renders at the stored boxW/boxH verbatim instead of snapping to the character lattice, which is how a full-width band reaches the margin), boxFill + boxFillOpacity, boxStroke, align / vAlign, textDir (+'normal'), font via boxSizeMode / boxFontPt. Position it Free, or pin with anchor: 'page' + pagePos — identical pagePos values on several boxes give a same-spot strip on every page (how the tutorial song's explanation boxes work).

At most one section, repeat and capo per stanza — extras are dropped on load. Cues, marker groups and voices may stack freely.

The bar items of a road map or marker group

KeyTypeDefaultWhat it does
id string auto-minted Unique within the annotation. Optional (v8) — the app no longer writes it and mints one on read when it's absent; omit it.
label string The text in the bar.
repeat number none Renders a ×N after the label.
color hex string the annotation's color Overrides the base color for this bar alone.
hidden boolean false Keeps the bar in the file without drawing it.

One of each

// A section spine over stanzas 1–2, in the left gutter
{ "id": "s1", "kind": "section", "text": "Verses 2–3",
  "target": { "kind": "span", "from": 1, "to": 2 },
  "placement": "left", "textDir": "left" }

// A two-line cue chip centered in the gap after stanza 1
{ "id": "c1", "kind": "cue", "text": "instrumental\nverse",
  "target": { "kind": "gap", "gapAfter": 1 },
  "placement": "inline", "color": "#1aa64b" }

// Play stanza 3 twice
{ "id": "r1", "kind": "repeat", "text": "2",
  "target": { "kind": "stanza", "stanza": 3 }, "placement": "inline" }

// Capo 5 from stanza 2 on
{ "id": "k1", "kind": "capo", "text": "5",
  "target": { "kind": "stanza", "stanza": 2 }, "placement": "above" }

// Two marker bars in the right gutter
{ "id": "m1", "kind": "markers",
  "target": { "kind": "stanza", "stanza": 1 },
  "placement": "right", "textDir": "left", "spacing": 1, "color": "#ef8a2b",
  "items": [ { "id": "b1", "label": "guitar", "repeat": 2 },
             { "id": "b2", "label": "shaker", "color": "#20b7d4" } ] }

// The arrangement, as a full-width block after the last stanza
{ "id": "rm1", "kind": "roadmap",
  "target": { "kind": "gap", "gapAfter": 3 },
  "align": "center", "roadmapWidth": "full", "color": "#7ac043",
  "items": [ { "id": "i1", "label": "Intro" },
             { "id": "i2", "label": "Verse", "repeat": 4 },
             { "id": "i3", "label": "Outro", "color": "#6c4bd1" } ] }

// "Repeat: Verse 1" in the gap after stanza 2, two rows tall
{ "id": "ch1", "kind": "recall",
  "target": { "kind": "gap", "gapAfter": 2 },
  "refStanza": 0, "placement": "inline", "reserveLines": 2 }

// Force a new page after stanza 1, keeping two blank rows at the top of the page it opens
{ "id": "pb1", "kind": "pagebreak", "target": { "kind": "gap", "gapAfter": 1 }, "spaceAfter": 2 }

spacers — vertical space as an object

A spacer is a block of empty rows at one boundary. It owns its height and hosts at most one gap-scoped annotation; several can stack at the same boundary. That's how a chart can have a three-row gap before the bridge, or a cue chip with room around it.

"spacers": [
  { "id": "sp-1", "after": -1, "reserveLines": 0 },  // above the first stanza — 0 = flush to the margin
  { "id": "sp-2", "after": 0,  "reserveLines": 2 },  // a double gap after stanza 0
  { "id": "sp-3", "after": 1,  "reserveLines": 3, "tailRows": 2 }   // …and 2 rows if a page break lands here
],
"annotations": [
  { "id": "c1", "kind": "cue", "text": "instrumental\nverse",
    "target": { "kind": "spacer", "spacerId": "sp-3" } }
]
  • id — optional (v8): the app mints one on read when it's absent, and only writes one at all when some annotation targets the spacer by it. Omit it.
  • after — the boundary, numbered like gapAfter (-1 = above the first stanza).
  • reserveLines — the block's height in body rows. At least 1 between stanzas (that row is the separator that keeps them apart); may be 0 at the song's edges, which is how a chart with no title starts flush at the top margin.
  • tailRows — 0–12, default 0: space at the foot of the page this boundary closes, and only while a page break actually lands here. A split boundary renders none of its reserveLines — that room is padding above the lower stanza, and the break is already the bigger visual break — so this is the only spacing knob the bottom of a page has. It is charged to the closing page, so a large value can push the upper stanza onto the next page. Its mirror is spaceAfter on a pagebreak, which opens space at the top of the page a break opens.
  • hidden — kept in the file, reserving nothing.

The app itself always writes the spacers key now (possibly []) — but it doesn't write every spacer: a boundary's lone default spacer (reserveLines 1, not hidden, hosting nothing, carrying no tailRows) is silently omitted, because the reader reconstructs exactly that shape at every boundary that lacks one. A spacer that shares its boundary with another, or that a cue/road-map/recall/page-break annotation targets, is always written. None of this affects a hand-written file: omitting spacers entirely still means "build it for me" (the pre-v5 migration signal), and writing an empty [] means "post-v5, nothing unusual here".

voices — who sings what

A song owns its voices: name and color travel in the file, so the coloring survives on any machine. roster entries are { id, name, color }; annotations refer to them by voiceId. Use ids of the form v-001, v-002… (all is reserved for the built-in “All” voice).

"annotations": [
  // the whole second row of stanza 1 in Bea's color
  { "id": "v1", "kind": "voice", "voiceId": "v-001",
    "target": { "kind": "line", "stanza": 1, "line": 1 } },
  // just the first nine characters of row 3
  { "id": "v2", "kind": "voice", "voiceId": "v-002",
    "target": { "kind": "range", "stanza": 1, "line": 3, "from": 0, "to": 9 } }
],
"voices": {
  "roster": [
    { "id": "v-001", "name": "Bea",  "color": "#c45912" },
    { "id": "v-002", "name": "Atti", "color": "#2c5fd6" }
  ],
  "defaultVoiceId": null
}

defaultVoiceId is the song's default voice, or null. A range target's from/to are character columns in that row's text — count them in the line as you wrote it in body.

textStyles — italic and underline

Inline styling can't live in body — markup there would shift the monospace columns and pull the chords off their syllables. So it rides alongside, as character ranges over the body treated as one flat string (lines joined with \n, exactly as a text editor would count).

"textStyles": {
  "italic":    [[28, 59]],       // "There is a house in New Orleans"
  "underline": [[48, 59]],       // "New Orleans"
  "voice":     [[60, 84, "#c45912"]] // a raw color run (voices are better done as annotations)
}

i = italic, u = underline, v = colored runs. Ranges are half-open [start, end). They may cover chord rows too — usually you don't want that, so count carefully, or set styling in the app and save from there.

settings — how it is typeset

Grouped by function, and every group (and every key inside it) is optional. Omit a group and nothing about it changes.

"settings": {
  "typography": { "fontKey": "dejavu", "fontSizePt": 12, "layout": "vertical" },
  "header":     { "scope": "first", "title": { "color": "#2c5fd6" } },
  "concert":    { "show": true, "boxMode": "page" }
}

Values are validated on load: an out-of-range enum falls back to a safe value instead of breaking the chart, and numeric ranges are clamped. So a wrong value doesn't destroy the file — it just quietly isn't what you asked for.

settings.page

Page geometry and the page-turn preview band. A chart carries its own page, so it opens the same size on any machine. The eight band keys live in a pageTurn container (v12).

KeyValuesDefaultWhat it does
snapAlign boolean true The OTHER snapping: align a dragged widget to what is already on the page — the other widgets' edges and centres (a guide line is drawn for whatever it latches onto), and the page's own alignments, which a drop adopts: released within one grid column of the left margin, the right margin or the band centre, a widget takes that alignment, throws its nudge away, and is recomputed from the content box every render from then on. That adopt window is a whole column wide by design, so with this on, the position one character off an alignment cannot be reached — which is what the switch is for. Purely an authoring aid: it decides where a drag lands and never where a saved widget prints, so a reader that ignores it renders the file identically. Absent = on, the behaviour of every chart written before it existed.
presetId string 'ipadpro13' Factory page: letter, halfletter, a4, a5, macbook15, macbook13, ipadpro13, ipadpro11, hd, 4k, 5k2k, iphone17, iphone17max, pixel10, pixel10xl.
preset object The resolved page, embedded so it travels: { id, name, unit: 'in'|'mm'|'px', width, height, ppi?, marginMode: 'unit'|'percent', margins: { top, right, bottom, left }, marginsLinked, orientation }. Wins over presetId; drop it and the id is used. (pagePreset before v12.)
orientation 'portrait' | 'landscape' 'portrait' Overrides the preset's own orientation.
pageTurn.preview number 0 Rows of the next page repeated at the bottom of the previous one. 0 = off.
pageTurn.pos 'bottom' | 'flow' 'bottom' Band pinned to the page bottom, or placed in the flow.
pageTurn.border / pageTurn.fill hex | '' '#7c3aed' / '' Border and fill of the repeated band. '' = none.
pageTurn.nextBorder / pageTurn.nextFill hex | '' mirrors the band The frame drawn around the original lines on the next page.
pageTurn.dy number 0 Whole-row vertical nudge of the band.
pageTurn.frameTop boolean false Paint the next-page frame over the other elements instead of behind them.

settings.typography

KeyValuesDefaultWhat it does
fontKey string 'dejavu' dejavu, jetbrains, sourcecodepro, and the Pro faces courierprime, intelone, recmono, ubuntumono. Only DejaVu and JetBrains carry ♯/♭ glyphs.
fontSizePt number 5–30 11 Body size. The single biggest lever on page count.
lineSpacing number 1.1–1.6 1.32 Line height as a multiple of the em. Clamped on load.
chordWeight / lyricWeight 400 | 700 700 / 700 Per-role body weight.
layout 'vertical' | 'horizontal' | 'booklet' 'horizontal' How multiple pages are laid out on the stage.
overflow 'break' | 'fit' | 'kern' | 'hide' 'break' What a too-wide line does: wrap, shrink to fit, tighten letter spacing, or clip.
letStanzasBreak boolean false Allow a stanza to split across a page break.

settings.labels

The printed wording of every prefixed field. House style — but it prints, so it travels with the song. Set any of them to '' to print the bare value. Inside this group the Prefix suffix is dropped (v12): tempo is the tempo's label.

KeyValuesDefaultWhat it does
tempo string 'Tempo:' Before the BPM in the info box.
timeSig string 'Time:' Before the time signature.
beat string '' Before the beat / style.
originalKey string 'Original key' Before the original key.
genre string '' Before the genre.
instrument string 'Instr:' Before the target instrument.
version string 'v.' Before the version in the bottom box.
pageNumber string 'p ' Before the page number.
capo string 'Capo' On the capo pill.
video string '▶' Before the video link.

settings.header

Three containers (v12): title, subtitle and meta. Title and subtitle take the same members and are template-driven — each template is an ordered list of { field } and { sep } tokens; an empty array means the default composition (title = artist – title). scope sits at the group's top level, because it governs the whole block.

KeyValuesDefaultWhat it does
scope 'first' | 'every' | 'rest' 'first' Which pages the title + subtitle block prints on. It is a rule, never a page number: a page number stops being true the moment somebody nudges the font a quarter point. first = the header page · every = all of them · rest = every page except the first, which is the continuation-header case (a running title that would be redundant on page 1, where the real one already is). There is no last here. This block reserves layout room, and a rule that depends on the page count can't settle when the page count depends on the rule — assume 3 pages, free the rows on the other two, collapse to 2, assume 2, grow back to 3. The four chrome elements that reserve nothing — capo, video, voiceLegend, stamp — take last as a fourth value. An unrecognised rule falls back to first, so a typo costs you the repeat, never the title.
title.show / subtitle.show boolean true Print the line at all.
title.template / subtitle.template TplToken[] [] e.g. [{"field":"title"},{"sep":" — "},{"field":"artist"}]. Fields: title, artist, subtitle, music, lyrics, arranger, album, track, year, genre, style, bpm, timeSig, originalKey, keyActual, capo, version, copyright, duration, targetInstrument, video. An empty field drops its separator with it.
title.sizeMode / subtitle.sizeMode 'body' | 'offset' | 'percent' | 'fixed' 'body' / 'percent' How the line is sized relative to the body text.
title.sizeOffset / subtitle.sizeOffset number −10…+10 2 / 0 Points added to the body size in 'offset' mode.
title.sizePct / subtitle.sizePct number 30–300 100 / 75 Percentage of the body size in 'percent' mode.
title.sizePt / subtitle.sizePt number 5–72 13 / 10 Absolute size in 'fixed' mode.
title.color / subtitle.color hex | '' '' '' inherits the body color.
title.align / subtitle.align 'left' | 'center' | 'right' 'left' Horizontal alignment on the page.
title.offset / subtitle.offset { dx, dy } { dx: 0, dy: 0 } Drag nudge off the anchor.
title.pagePos / subtitle.pagePos { x, y } | null null Pin to an absolute page position.
meta.pos 'left' | 'right' 'right' Which top corner the info box sits in.
meta.offset / meta.pagePos { dx, dy } / { x, y } | null { 0, 0 } / null Info-box nudge and page pin.

settings.songInfoBox

The meta box in the top corner. Each field shows only when the matching property has a value. The per-field flags live in a fields container (v12), and the video link moved out to its own video group.

KeyValuesDefaultWhat it does
show boolean true The box as a whole.
scope 'first' | 'every' | 'rest' 'first' Which pages the box prints on — the same rule and the same three values as header.scope, and no last for the same reason: the box reserves room. It used to be governed by the header's rule outright; a file written before the split carries no scope here and inherits header.scope, so a chart set to repeat its header keeps repeating its box.
rows InfoRow[] The box's content: an ordered list of { id?, field, prefix?, hidden? }, so the box can print anything a title can. Omit it for the standard six. Wins over order + fields, which are the pre-rows model the app still writes for older builds.
color hex | '' '' The box's own fill. '' or absent inherits the colour theme's infobox swatch; a value overrides it for this song, and the text on it is chosen for contrast rather than stored. Like every other stored colour it is a slot reference — it follows the theme rather than pinning the chart to the hue it was authored in. The placeable info annotation carries the same colour on its own record; the two are one widget in two homes.
order string[] ['tempo',​'timeSig',​'beat',​'originalKey',​'genre',​'instrument'] Display order in the legacy model. Unknown keys are dropped and missing ones appended.
fields.bpm / fields.timeSig / fields.beat / fields.originalKey / fields.genre / fields.instrument boolean true Per-field visibility in the legacy model.
fields.version boolean true The version in the bottom box (it lives in this group for historical reasons).

settings.video

The video property printed on the page, as a link or a QR code. Its own group since v12 — it is page chrome of its own, a peer of the capo band, and was filed under songInfoBox before that. The placeable qr annotation is the same widget pointed somewhere else; the two share their controls.

KeyValuesDefaultWhat it does
mode 'off' | 'link' | 'qr' 'off' Print the video property as a text link or a QR code, or not at all.
pos 'bottom-left' | 'bottom-center' | 'bottom-right' | 'top-right' 'bottom-left' Where the link / QR sits.
rows number 1.5–9 3 The QR square's side, in body grid rows. A row count rather than a size, so the code holds its proportion to the music at every font size — a millimetre figure is true at the instant you drag it and a lie one font step later. Clamped on load. (Replaced scale, which multiplied a fixed 16 mm.)
scale number Retired. The old multiplier on a fixed 16 mm QR. Still read, and converted to rows against the song's own font size and line spacing, so an older file opens at the size it printed at; nothing writes it any more. Don't write it in a new file — write rows.
logo boolean true Punch the app mark into the middle of the QR. This widget's own, since 2026-08-16 (v12 additive): a placed qr annotation carries its own qrLogo, so the two tiers no longer decide for each other. A file written before that has none on its codes, and they take the app default — except where the song said false, which is stamped onto them as it loads, so a deliberately unmarked chart stays unmarked.
color string (hex) "" The widget's own ink — the code's modules, or the text link's glyphs (v12 additive, 2026-08-16). Empty or absent inherits the color theme's element for whichever form is drawn: qr as a code, videolink as a link. It is a slot reference, so it moves with the palette. Before this key the widget's editor recolored the ELEMENT, which repainted every other code on the page — and every chart wearing that palette.
plateColor string (hex) "" The widget's own backing — the QR's plate (the light field a scanner needs around dark modules) or the text link's background. Empty or absent inherits qrplate / videolinkbg. The plate is never a gradient: it is what the contrast a scan depends on is measured against.
shortUrl boolean false Print the link's short form instead of the URL as typed (text mode only — a QR always encodes the full URL). Off by default: what somebody entered is what prints until they ask for the compact one.
sizeMode 'body' | 'offset' | 'percent' | 'fixed' 'percent' How the text link is sized — the same four-mode chooser the header lines use, but resolved against the annotation font rather than the body. Nothing to do with the QR, whose size is rows.
sizeOffset number −10…+10 0 Points added to the annotation font in 'offset' mode.
sizePct number 30–300 64 Percentage of the annotation font in 'percent' mode — the default 64 is the ratio the link's CSS used to hardcode, so a song that never touches this prints exactly as it always did.
sizePt number 7 Absolute size in 'fixed' mode.
scope 'first' | 'every' | 'rest' | 'last' 'first' Which pages the link / QR prints on — see header.scope. It reserves no layout room, so it takes last as well: the scannable code on the final page, where a player stops.
offset / pagePos { dx, dy } / { x, y } | null { 0, 0 } / null Nudge and page pin.

settings.voiceLegend

The voice legend box — one row per voice the song uses (swatch + name, in the voice's color). WHICH voices appear is derived from the voice annotations, the default voice and voices.roster; only the box's own look travels here. Its fill/border colors are theme elements (voicelegendbg / voicelegendborder), not per-song keys.

KeyValuesDefaultWhat it does
show boolean false Print the legend. Off until a song actually colors lines by voice — until then it would only spend page room.
pos 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' 'top-right' Which page corner it anchors to. The four corners, no centre: a legend is a key you read before the music, and halfway along an edge it sits under the middle of the lyrics with nothing to line up against. (Older files may carry a bottom-center inherited from the video link's set — it loads as the default.)
scope 'first' | 'every' | 'rest' | 'last' 'first' Which pages the legend prints on — see header.scope. It reserves no layout room, so it takes last as well. The reason the rule exists at all: a five-page song may want its key on the three pages that use it.
offset / pagePos { dx, dy } / { x, y } | null { 0, 0 } / null Nudge and page pin.

settings.stamp

The mark that says which sheet this is — page number and chart revision. Called bottomBox until July 2026: a stamp is defined by being applied, not by where it sits, and this one has been draggable to any corner for a long time. A file written under the old name still loads, and the spelling cures itself on the next save.

KeyValuesDefaultWhat it does
show boolean true The badge as a whole (revision + page number).
showPageNumber boolean true The page number inside it. Keeps its verb: beside show, a bare pageNumber would read as the number rather than the decision to print it.
pos 'left' | 'center' | 'right' 'right' Which bottom corner.
scope 'first' | 'every' | 'rest' | 'last' 'every' Which pages the stamp prints on — see header.scope. The one element whose default is every, because printing on every page is what it always did. It is also the one element that both reserves room and offers last: rather than take a working option away from charts that use it, last pays its rows on every page and simply draws on the final one — no worse than before, and the paginator never has to answer a question it can't.
offset / pagePos { dx, dy } / { x, y } | null { 0, 0 } / null Nudge and page pin.

settings.capo

The song-wide capo pill (the capo property is its value). It reserves no space: the pill hangs right above the first line, in whatever blank space is there. Per-stanza capos are annotations instead. (rows and stack were retired in July 2026; older files carrying them still load, the keys are ignored.)

KeyValuesDefaultWhat it does
show boolean true Print the pill.
pos 'left' | 'inline' | 'right' 'left' Alignment on its row — 'inline' reads as center.
scope 'first' | 'every' | 'rest' | 'last' 'first' Which pages the pill prints on — see header.scope. It reserves no layout room, so it takes last as well. What it is for (the owner's own case): a capo reminder on page 2, because you forget. On a page with no header there is no blank space above the first body line for the pill to hang in, so a repeated pill anchors to a page corner instead.
color hex | '' '' '' inherits the theme's capo swatch.
offset / pagePos { dx, dy } / { x, y } | null { 0, 0 } / null Nudge and page pin.

settings.depth

Paint depth of the song chrome (v12 additive) — the same integer scale an annotation's z uses, so Bring to front / Send to back means one thing whether you right-click a widget or the title. Absent/0 is the chart's own plane; negative paints behind the annotations that were sent to the back; positive paints in front of every annotation. Only write it to settle a deliberate overlap.

KeyValuesDefaultWhat it does
chrome { title?, subtitle?, info?, capo?, video?, voices?, stamp? } {} One small int per chrome element. An absent key is 0. Every STEP is its own plane, not just the sign: send the title back, then a band, and they get −1 and −2, with the band painting under the title. stamp was called footer until 2026-07-31; the old key is still read.

settings.geom

Geometry of the song chrome (v12 additive) — the same two tokens an annotation carries in its own geom bag, one entry per chrome widget. Absent means whatever the chart already looks like: no key emits no style, so a chart written before this renders unchanged. Values are clamped on read.

KeyValuesDefaultWhat it does
chrome { info?, capo?, video?, voices?, stamp?, concert?, turn? } {} Each entry is { radius?, frame?, frameColor? }. The two lengths are fractions of one text row — radius 0–0.5 (0.5 is a full pill), frame 0–0.25; frameColor is a hex (or transparent / currentColor) and moves with the palette. A widget with no border of its own (info, capo, stamp) takes radius only; the four that draw a frame (video, voices, concert, turn) take all three.

settings.notation

KeyValuesDefaultWhat it does
srcNote 'english' | 'german' | null null How the chords in body are written. null = auto-detect. German reads H as B natural.
srcMinor 'english' | 'hungarian' | null null Am vs the folk lowercase a in the source.
outNote / outMinor same values null How chords are printed. null = same as the source.
prettyAcc boolean false Print ♯/♭ instead of #/b. Only DejaVu and JetBrains have the glyphs.
transpose number 0 Semitones, ±7 in the UI.

settings.concert

The side column of concert-pitch chords, for a capo'd chart played with someone who isn't capo'd.

KeyValuesDefaultWhat it does
show boolean false Show the concert column.
boxMode 'page' | 'stanza' 'page' One gray column down the page, or a tinted box per stanza.
gap number 2 Gap in characters between the chart and the column.
align 'beat' | 'pack' 'beat' Line the concert chords up with the beat, or pack them tight.
titleShow / title boolean / string true / 'CONCERT' The column's heading.
display 'box' | 'inline' 'box' The side column, or each sounding chord printed on the chord row beside its written shape.
delim string ' ' Printed between the written and the sounding chord (inline only). Avoid / — it reads as a bass note.
inlineStyle 'color' | 'pill' 'color' Inline look: coloured text, or a small filled chip behind each sounding chord.
pillPad number 0.18 The chip's horizontal overhang past its glyphs, in character widths (0–0.5). Takes no layout, so it never moves a chord off its syllable.
pillPadV number 0.83 The chip's height in grid cells (0.8–1.5), not a reach: 1.0 is exactly one character cell tall — where the chips of two stacked chord rows meet — 0.8 is about the font's own height, and 1.5 overhangs half a cell, split above and below.
pillBorder string '' Border ring around the chip. '' = none.
chordFill / chordBorder string / string '' / '' The same chip treatment for the WRITTEN (capo-shape) chord beside it.
chordText string '' The written chord's own text colour, overriding the theme's chord colour. '' = the theme's. (Replaced chordFont, which existed 2026-07-29 only and now loads as a no-op.)
sepFill string '' The separator cell's own fill (pill look). '' = the cell bridges the two chips' fills, written on the left half, pill on the right.

settings.annotations

KeyValuesDefaultWhat it does
fontSep boolean false Size annotation text separately from the body.
fontSizePt number 9 That separate size.
snapGrid 'off' | 'whole' | 'half' 'whole' The character lattice a dragged annotation steps on — a whole character per step, half a character, or off (continuous). It is more than an editing aid, which is why it is saved with the song: the same lattice quantizes where every widget resolves and how wide the gutter boxes are, so a chart carries its alignment rather than re-deriving it on whichever machine opens it. A page pin is re-locked to it too, unless the pin says otherwise — see pagePos and pinFree.
presetId string The id of the song's active color scheme. A custom scheme also travels in full as song.palette, so the id resolves on another machine.

palette — a color theme in the file

Optional, and only present when the song uses a custom theme (the four factory themes — Rainbow, Pastel, Muted, Grayscale — are not written into files). A palette is twelve editable swatches plus an assignment of each chart element to a swatch index.

"palette": {
  "id": "my-theme",
  "name": "My theme",
  "swatches": [ { "bg": "#e23b3b" }, { "bg": "#ef8a2b" }, … 12 in total … ],
  "assign": { "cue": 0, "section": 4, "roadmap": 3, "markers": 1 }
}

Assignable elements: title, subtitle, body, chord, capo, repeat, section, cue, markers, roadmap, pagebreak, chorus, concertbox, concertboxtitle, concertchords, infobox, bottombox, videolink, qr, pageturnborder, pageturnbg, pageturnnextborder, pageturnnextbg. Index 12 is the theme's rainbow swatch, valid only for elements with a solid fill.

Per-element colors on the chart are simpler to set directly (color on the annotation, titleColor in the header group) — reach for a palette only when you want a whole coordinated theme.

How a chart travels

The same envelope moves three ways. All three carry the whole song — words, annotations, settings, page — so the choice between them is about the channel, not about fidelity.

TransportWhat it isReach for it when
.gigchart file The envelope above, as plain JSON. Media type application/vnd.gigchart+json. Anything can carry a file: version control, a backup, an attachment, a hand-off to a program. The only form nothing can retype.
Clipboard block The same JSON compressed into an armored gcz4: text block with a CRC-32 receipt — below. There is nothing to host and no URL to build: a chat message, an email body, a conversation with an assistant.
Import link The same JSON compressed into a URL fragment: app.gigcharts.app/import#gc=… You can hand over a link and want one click to land in the studio, song and all.

#gc= carries the envelope through lz-string's compressToEncodedURIComponent — the JSON, compressed, in a URI-component-safe alphabet. Build it exactly the way the app does:

import LZString from "lz-string";

const envelope = { format: "gigchart", version: 12, song: /* the song */ };
const token = LZString.compressToEncodedURIComponent(JSON.stringify(envelope));
const url = "https://app.gigcharts.app/import#gc=" + token;

Two things follow from the #. It is a fragment, so browsers never send it to any server — the song is decoded in the recipient's own tab, and nothing is uploaded on the way. And the token's alphabet includes +, so a reader must take it from the raw hash rather than through URLSearchParams, which would turn every + into a space. Compression is what keeps the link pasteable: a multi-page annotated song is several KB of JSON.

The decoder is forgiving in the ways that matter: a raw (or percent-encoded) JSON token opens too, as does a gcz4: clipboard token dropped into the same slot. There is also a fetch form — /import?src=<url>, for a hosted .gigchart or ChordPro file under 256 KB. Both are documented as an authoring workflow, with the prompt and the assistant's side of it, on Author with AI.

The armored clipboard block

For chat, email, or an AI conversation — anywhere with nothing to host and no URL to build — the same envelope travels as a self-describing text block (My Songs → right-click a song → Share & export ▸ Copy binary transport). Pasting one into the app imports it. It is a transport, not a storage format: use the plain .gigchart file for version control.

-----BEGIN GIGCHART-----
Song: Traditional - Swing Low, Sweet Chariot
Checksum: crc32:a30e3fc2
gcz4:G6BCAZQPMBHAWYEGAFYQBOCAOMBFTIGGACZZISADIIAG4AUZIAZYMEAHMKA
IYAJQSVOBIATAECJKWMABTGADMAFUAIB3FCAMYAQAVAEBZELSQEAOSFAIAWRA3PJ
UFFAAA4UBBDSQIGOJKQZBFQQAG3SFBAABEEACXFLSAAECDAPQIXC7AASQYGR2GQP
-----END GIGCHART-----
  • The token is gcz4: + lz-string's compressed bytes as unpadded RFC 4648 base32, hard-wrapped at 64 columns. The alphabet is A–Z 2–7 — alphanumeric, one case, nothing else — so there is no . for a spell-checker to capitalize after or a linkifier to read as a hostname, no _ for chat markdown to take as italics, no - for a renderer to break a line at, and none of the 0/O, 1/l, 8/B confusions that a channel which retypes the block would introduce. Re-wrapping is harmless — whitespace inside a token is stripped before decoding.
  • The Song: line is decoration for humans; edit or drop it freely. Long titles fold at the same width, email-header style.
  • The Checksum: line is the block's integrity receipt: crc32: + eight hex digits of the CRC-32 (IEEE) of the token exactly as decoded — prefix + body, whitespace stripped — so it survives re-wrapping and covers only the payload. If it is present and wrong, the app first tries to repair the block against it — undoing a case fold or the confusable substitutions, then testing every single-character substitution, drop and insertion, and accepting only a candidate whose CRC matches. One damaged character is therefore recovered (and reported); two or more are refused as damaged in transit. Without the receipt, a few characters lost in a chat client would “decode” into silently garbled JSON (lz-string has no integrity check of its own). Blocks without the line still load.
  • A bare single-line gcz4:… token, legacy gcz3:/gcz2:/gcz1: tokens and raw .gigchart JSON all paste too; the gczN: prefix is matched case-insensitively.

A complete song

Download the showcase file — one song using every element on this page: section spines, cues on a line and in a gap, a repeat bar, a per-stanza capo, a marker group, a road map, a chorus recall, line and word-range voices, inline styling, and every settings group. Open it in the app with Open file, take it apart, and copy what you need.

It's also the file this page was checked against: it was loaded into GigCharts and every element confirmed on the rendered page before any of this was written.

Rules of thumb

  • Omit before guessing. An absent key means “use the default”. A wrong one means a chart that isn't what you meant.
  • Start from the words. Get properties and body right, then add annotations. They anchor to stanzas and rows, so the body has to be settled first.
  • Count stanzas from 0, and remember lyric rows are the odd row indices in a chords-above chart.
  • Use gapAfter, not spacers, unless you specifically want an unusual gap height.
  • Never invent an id — omit it. Annotation, bar-item, info-row and spacer ids are all optional; the app mints one on read whenever it's missing.
  • Colors are color and hex. Leave them out to follow the theme.
  • Page count is an outcome, not a setting. Font size, line spacing and page preset drive it; the final break depends on the render, so the author checks it in the app.
  • Round-trip to be sure. Open the file in the app, adjust, then Save → .gigchart — what comes out is the canonical form of what you wrote.

Ready to hand one over? Build a one-click import link and the song opens straight in the studio.