From 24030e7d9b60aebff86478e8518e828dd5b51346 Mon Sep 17 00:00:00 2001 From: abhishekbhakat Date: Fri, 10 Jul 2026 12:44:14 +0530 Subject: [PATCH] Initial Commit --- Makefile | 59 ++ README.md | 118 ++++ app.js | 906 +++++++++++++++++++++++++++++++ guitar-tab.schema.json | 345 ++++++++++++ haseen.tab.json | 191 +++++++ index.html | 169 ++++++ styles.css | 1162 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2950 insertions(+) create mode 100644 Makefile create mode 100644 README.md create mode 100644 app.js create mode 100644 guitar-tab.schema.json create mode 100644 haseen.tab.json create mode 100644 index.html create mode 100644 styles.css diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d89590 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +PORT ?= 8080 +HOST ?= 127.0.0.1 +TUNNEL_NAME ?= guitar-tabs +TUNNEL_HOST ?= tabs.bhakat.dev +TUNNEL_ORIGIN ?= http://127.0.0.1:$(PORT) +TUNNEL_EDGE_IP_VERSION ?= auto +.DEFAULT_GOAL := serve + +.PHONY: help serve start tunnel-setup tunnel + +help: + @printf '%s\n' \ + 'Guitar tabs sample' \ + '' \ + 'Targets:' \ + ' make Start the local server' \ + ' make serve Start the local server' \ + ' make start Alias for make serve' \ + ' make tunnel-setup Create the Cloudflare Tunnel and DNS route (run once)' \ + ' make tunnel Serve locally and run the Cloudflare Tunnel in the foreground' \ + ' make help Show this help' \ + '' \ + 'Options:' \ + ' PORT=8080 Server port' \ + ' HOST=127.0.0.1 Bind address' \ + ' TUNNEL_NAME=guitar-tabs Cloudflare Tunnel name' \ + ' TUNNEL_HOST=tabs.bhakat.dev Public hostname' \ + ' TUNNEL_ORIGIN=http://127.0.0.1:8080 Local URL served through the tunnel' \ + ' TUNNEL_EDGE_IP_VERSION=auto Cloudflare edge IP version (auto, 4, or 6)' \ + '' \ + 'Examples:' \ + ' make serve PORT=9000 HOST=0.0.0.0' \ + ' make tunnel-setup' \ + ' make tunnel TUNNEL_EDGE_IP_VERSION=4' + +serve: + uv run python -m http.server $(PORT) --bind $(HOST) + +start: serve + +tunnel-setup: + cloudflared tunnel create "$(TUNNEL_NAME)" + cloudflared tunnel route dns "$(TUNNEL_NAME)" "$(TUNNEL_HOST)" + +tunnel: + @set -e; \ + server_pid=''; \ + cleanup() { \ + if [ -n "$$server_pid" ]; then \ + kill "$$server_pid" 2>/dev/null || true; \ + wait "$$server_pid" 2>/dev/null || true; \ + fi; \ + }; \ + trap cleanup EXIT; \ + trap 'exit 130' INT TERM; \ + uv run python -m http.server "$(PORT)" --bind "$(HOST)" & \ + server_pid=$$!; \ + printf '%s\n' "Serving $(TUNNEL_ORIGIN) at https://$(TUNNEL_HOST)"; \ + cloudflared tunnel --edge-ip-version "$(TUNNEL_EDGE_IP_VERSION)" run --url "$(TUNNEL_ORIGIN)" "$(TUNNEL_NAME)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d95988 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Interactive guitar tab sample + +This is a dependency-free proof of concept: a reusable browser renderer reads a versioned JSON tab file and turns it into an interactive practice sheet. + +The provided file is named `kesariya.pdf`, but the title printed inside it is **Haseen (Talwinder)**. The sample follows the title and music shown in the document. All eight bars are kept in one continuous array, in source order. The license watermark email is intentionally not copied into the web data. + +## Files + +- `index.html` - the embeddable page surface +- `styles.css` - responsive presentation +- `app.js` - generic renderer, JSON loader, bar navigation, practice cursor, metronome, zoom, and keyboard controls +- `haseen.tab.json` - the complete source data transcribed from the PDF +- `guitar-tab.schema.json` - the reusable `guitar-tab/v1` JSON Schema + +## Run the sample + +The page fetches its JSON, so serve this folder instead of opening `index.html` with a `file://` URL: + +```sh +make +``` + +This runs `uv run python -m http.server` on `127.0.0.1:8080`. Override either value when needed, for example `make PORT=9000 HOST=0.0.0.0`. `make serve` and `make start` are equivalent aliases. + +Run `make help` to see all targets and options. + +Then open `http://localhost:8080`. + +For the compact iframe view, open `http://localhost:8080/?embed=1`. + +To point the viewer at a hosted compatible file, add an encoded `src` parameter, for example `?embed=1&src=https%3A%2F%2Fexample.com%2Fsong.tab.json`. The JSON host must allow the browser request. + +## Share through Cloudflare Tunnel + +The Makefile defaults to the public hostname `tabs.bhakat.dev` and a named Cloudflare Tunnel called `guitar-tabs`. Ensure that `bhakat.dev` is active in the Cloudflare account used by `cloudflared`. + +Create the tunnel and its DNS route once: + +```sh +make tunnel-setup +``` + +Then, for each sharing session, run: + +```sh +make tunnel +``` + +This starts the local static server and leaves `cloudflared` in the foreground. Press `Ctrl+C` to stop both processes. The first command stores the tunnel credential file under `~/.cloudflared`; it is not part of this repository. + +If IPv6 connections to the Cloudflare edge are unreliable, force IPv4 for the tunnel: + +```sh +make tunnel TUNNEL_EDGE_IP_VERSION=4 +``` + +Use the same overrides with both commands to choose another tunnel name or subdomain: + +```sh +make tunnel-setup TUNNEL_NAME=my-tabs TUNNEL_HOST=guitar-tabs.bhakat.dev +make tunnel TUNNEL_NAME=my-tabs TUNNEL_HOST=guitar-tabs.bhakat.dev +``` + +## Embed it + +```html + +``` + +The “Copy embed snippet” button generates the equivalent snippet for the current URL. + +## The standard format + +JSON is the canonical interchange format because browsers can load it directly and JSON Schema can validate it. YAML or Markdown can still be used as an authoring format, but should be converted to this JSON shape before rendering. + +The hierarchy is: + +```text +song +├── metadata + instrument + timing + legend +└── arrangements + └── sections + └── blocks + ├── tab → bars → rows + └── chordLyrics → lines → spans +``` + +Each tab bar declares `columnCount` and owns one equal-width ASCII row per string. This keeps barlines, line wrapping, and responsive rendering deterministic. Optional column-positioned markers preserve strum arrows and similar annotations without inserting them into the tab grid. Stable IDs and optional beat events provide anchors for future audio sync or note-level highlighting. + +Important conventions: + +- Tuning pitches describe open strings before the capo. +- Fret numbers remain relative to the capo, matching ordinary tab notation. +- `rows[].text` contains the inside of the bar; the renderer supplies barlines. +- Every row in a bar must have the same Unicode-code-point width as `columnCount`. +- `markers[].atColumn` is zero-based within that bar-local row. +- `timeSignature`, bar beat counts, note events, repeats, and chord/lyric blocks are optional when the source does not provide them. +- `extensions` is the escape hatch for namespaced vendor data. + +The semantic checks in `app.js` are a normative second validation pass for rules JSON Schema cannot express across sibling values. They enforce unique IDs, `columnCount` and row-width agreement, tuning/string consistency, legend references, and in-range text, event, note, and marker positions. A production ingestion pipeline should run the JSON Schema first and equivalent semantic checks second. + +## Interaction included in the sample + +- Read all eight source bars as one continuous tab +- Click a bar or use arrow keys to focus it +- Start a BPM-controlled 4-count practice cursor and optional metronome +- Loop the current tab +- Resize the tab without losing alignment +- Load another compatible `.json` file from the browser +- Use keyboard shortcuts: `Space`, `←`, `→`, `M`, and `L` + +The source PDF states 82 BPM but does not state a time signature or exact beat mapping. The sample labels its 4-count cursor as a practice aid rather than presenting it as transcribed rhythm. diff --git a/app.js b/app.js new file mode 100644 index 0000000..83e1e52 --- /dev/null +++ b/app.js @@ -0,0 +1,906 @@ +(() => { + "use strict"; + + const DEFAULT_TAB_URL = "haseen.tab.json"; + const DEFAULT_PRACTICE_COUNTS = 4; + + const state = { + data: null, + arrangementIndex: 0, + barIndex: 0, + beatIndex: 0, + tempo: 82, + playing: false, + timer: null, + audioContext: null, + zoomStep: 0, + sourceName: DEFAULT_TAB_URL, + sourceKind: "default", + sourceUrl: null, + }; + + const dom = {}; + + document.addEventListener("DOMContentLoaded", init); + + async function init() { + cacheDom(); + bindEvents(); + + if (new URLSearchParams(window.location.search).get("embed") === "1") { + document.body.classList.add("embed"); + } + + try { + const params = new URLSearchParams(window.location.search); + const requestedSource = params.get("src"); + const sourceUrl = requestedSource ? safeHttpUrl(requestedSource) : null; + if (requestedSource && !sourceUrl) { + throw new Error("The src parameter must be an http(s) URL"); + } + const fetchUrl = sourceUrl || DEFAULT_TAB_URL; + const response = await fetch(fetchUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + loadTabData(data, requestedSource || DEFAULT_TAB_URL, requestedSource ? "url" : "default", sourceUrl); + } catch (error) { + showError( + `The sample JSON could not be fetched (${error.message}). Open this folder through a small local web server, or use “Load tab JSON” to choose haseen.tab.json directly.`, + ); + } + } + + function cacheDom() { + const ids = [ + "source-label", + "song-title", + "artist-line", + "metadata-row", + "arrangement-picker", + "arrangement-description", + "play-button", + "now-playing", + "previous-button", + "next-button", + "bar-position", + "beat-position", + "progress-fill", + "beat-dots", + "tempo-range", + "tempo-output", + "metronome-toggle", + "loop-toggle", + "practice-note", + "section-index", + "sheet-title", + "zoom-out", + "zoom-in", + "tab-sheet", + "legend-list", + "stat-arrangements", + "stat-bars", + "stat-strings", + "copy-embed", + "attribution", + "tab-file", + "load-tab-button", + "view-source-link", + "viewer-status", + "toast", + "error-panel", + "error-message", + ]; + + ids.forEach((id) => { + dom[toCamelCase(id)] = document.getElementById(id); + }); + } + + function bindEvents() { + dom.playButton.addEventListener("click", togglePractice); + dom.previousButton.addEventListener("click", () => moveBar(-1, true)); + dom.nextButton.addEventListener("click", () => moveBar(1, true)); + dom.tempoRange.addEventListener("input", handleTempoChange); + dom.zoomOut.addEventListener("click", () => changeZoom(-1)); + dom.zoomIn.addEventListener("click", () => changeZoom(1)); + dom.loadTabButton.addEventListener("click", () => dom.tabFile.click()); + dom.tabFile.addEventListener("change", handleFileLoad); + dom.copyEmbed.addEventListener("click", copyEmbedSnippet); + document.addEventListener("keydown", handleKeyboard); + document.addEventListener("visibilitychange", () => { + if (document.hidden && state.playing) stopPractice(); + }); + } + + function loadTabData(data, sourceName, sourceKind = "file", sourceUrl = null) { + const validation = validateTabData(data); + if (validation.errors.length) { + showError(validation.errors.join(" ")); + return false; + } + + hideError(); + stopPractice(); + state.data = data; + state.sourceName = sourceName; + state.sourceKind = sourceKind; + state.sourceUrl = sourceUrl; + state.arrangementIndex = 0; + state.barIndex = 0; + state.beatIndex = 0; + state.tempo = clamp(Math.round(data.timing?.tempoBpm || 82), 30, 240); + dom.tempoRange.value = String(state.tempo); + dom.tempoOutput.value = String(state.tempo); + + renderAll(); + updateSourceLink(); + dom.viewerStatus.textContent = `${data.metadata.title} loaded with ${flattenBars(activeArrangement()).length} bars.`; + if (validation.warnings.length) showToast(validation.warnings[0]); + return true; + } + + function validateTabData(data) { + const errors = []; + const warnings = []; + const ids = new Set(); + + if (!data || typeof data !== "object") { + return { errors: ["The root value must be a JSON object."], warnings }; + } + if (data.format !== "guitar-tab/v1") errors.push("Expected format \"guitar-tab/v1\"."); + if (!data.metadata?.title) errors.push("metadata.title is required."); + if (!Array.isArray(data.metadata?.artists) || !data.metadata.artists.length) { + errors.push("metadata.artists must contain at least one artist."); + } + const stringCount = data.instrument?.strings; + if (!Number.isInteger(stringCount) || stringCount < 1 || stringCount > 18) { + errors.push("instrument.strings must be a positive integer."); + } + const tuning = Array.isArray(data.instrument?.tuning) ? data.instrument.tuning : []; + if (!tuning.length) { + errors.push("instrument.tuning is required."); + } else if (Number.isInteger(stringCount)) { + const tuningNumbers = tuning.map((item) => item?.string); + const expected = Array.from({ length: stringCount }, (_, index) => index + 1); + if ( + tuning.length !== stringCount || + new Set(tuningNumbers).size !== tuningNumbers.length || + tuningNumbers.some((number, index) => number !== expected[index]) + ) { + errors.push(`instrument.tuning must list strings 1 through ${stringCount} exactly once, in order.`); + } + } + if (data.timing !== undefined && (!data.timing || typeof data.timing !== "object")) { + errors.push("timing must be an object when present."); + } + if ( + data.timing?.tempoBpm !== undefined && + (!Number.isFinite(data.timing.tempoBpm) || data.timing.tempoBpm <= 0 || data.timing.tempoBpm > 240) + ) { + errors.push("timing.tempoBpm must be a positive number no greater than 240 when present."); + } + if ( + data.timing?.timeSignature && + (!Number.isInteger(data.timing.timeSignature.beats) || + data.timing.timeSignature.beats < 1 || + data.timing.timeSignature.beats > 32) + ) { + errors.push("timing.timeSignature.beats must be an integer from 1 to 32."); + } + if (!Array.isArray(data.arrangements) || !data.arrangements.length) { + errors.push("arrangements must contain at least one arrangement."); + return { errors, warnings }; + } + + if (data.legend !== undefined && !Array.isArray(data.legend)) { + errors.push("legend must be an array when present."); + } + if (data.metadata?.links !== undefined && !Array.isArray(data.metadata.links)) { + errors.push("metadata.links must be an array when present."); + } + const legendTokens = new Set((Array.isArray(data.legend) ? data.legend : []).map((item) => item?.token)); + + const registerId = (id, label) => { + if (!id) { + errors.push(`${label} is missing an id.`); + } else if (ids.has(id)) { + errors.push(`Duplicate id \"${id}\".`); + } else { + ids.add(id); + } + }; + + registerId(data.id, "Document"); + data.arrangements.forEach((arrangement, arrangementIndex) => { + registerId(arrangement.id, `Arrangement ${arrangementIndex + 1}`); + if (!arrangement.title) errors.push(`${arrangement.id || "An arrangement"} is missing a title.`); + if (!Array.isArray(arrangement.sections) || !arrangement.sections.length) { + errors.push(`${arrangement.id || "An arrangement"} must contain at least one section.`); + return; + } + arrangement.sections.forEach((section, sectionIndex) => { + registerId(section.id, `Section ${sectionIndex + 1}`); + if (!Array.isArray(section.blocks) || !section.blocks.length) { + errors.push(`${section.id || "A section"} must contain at least one block.`); + return; + } + section.blocks.forEach((block, blockIndex) => { + registerId(block.id, `Block ${blockIndex + 1}`); + if (block.type === "tab") { + if (!Array.isArray(block.bars) || !block.bars.length) { + errors.push(`${block.id || "A tab block"} must contain at least one bar.`); + return; + } + block.bars.forEach((bar, barIndex) => { + registerId(bar.id, `Bar ${barIndex + 1}`); + if (!Number.isInteger(bar.columnCount) || bar.columnCount < 1 || bar.columnCount > 4096) { + errors.push(`${bar.id || "A bar"} needs columnCount between 1 and 4096.`); + } + if (bar.beats !== undefined && (!Number.isFinite(bar.beats) || bar.beats <= 0 || bar.beats > 32)) { + errors.push(`${bar.id || "A bar"}.beats must be greater than 0 and no more than 32.`); + } + if (!Array.isArray(bar.rows) || !bar.rows.length) { + errors.push(`${bar.id || "A bar"} has no tab rows.`); + return; + } + if (bar.rows.length !== stringCount) { + errors.push( + `${bar.id} has ${bar.rows.length} rows; instrument.strings says ${stringCount}.`, + ); + } + const rowNumbers = bar.rows.map((row) => row?.string); + const expectedRows = Array.from({ length: stringCount || 0 }, (_, index) => index + 1); + if ( + Number.isInteger(stringCount) && + (new Set(rowNumbers).size !== rowNumbers.length || + rowNumbers.some((number, index) => number !== expectedRows[index])) + ) { + errors.push(`${bar.id} must contain strings 1 through ${stringCount} exactly once, in order.`); + } + const rowTextsValid = bar.rows.every((row) => typeof row?.text === "string" && row.text.length > 0); + if (!rowTextsValid) errors.push(`${bar.id} has a row without text.`); + const widths = new Set( + bar.rows.map((row) => (typeof row?.text === "string" ? [...row.text].length : -1)), + ); + if (widths.size !== 1 || !widths.has(bar.columnCount)) { + errors.push(`${bar.id} rows must all match columnCount ${bar.columnCount}.`); + } + const width = [...widths][0] || 0; + if (bar.markers !== undefined && !Array.isArray(bar.markers)) { + errors.push(`${bar.id}.markers must be an array.`); + } + ["lyrics", "chords"].forEach((field) => { + if (bar[field] !== undefined && !Array.isArray(bar[field])) { + errors.push(`${bar.id}.${field} must be an array.`); + return; + } + (Array.isArray(bar[field]) ? bar[field] : []).forEach((item) => { + if (typeof item?.text !== "string" || !item.text.length) { + errors.push(`${bar.id}.${field} contains an item without text.`); + } + if ( + item.atColumn !== undefined && + (!Number.isInteger(item.atColumn) || item.atColumn < 0 || item.atColumn >= width) + ) { + errors.push(`${bar.id}.${field} contains an item outside its ${width}-column grid.`); + } + }); + }); + (Array.isArray(bar.markers) ? bar.markers : []).forEach((marker) => { + if (!Number.isInteger(marker.atColumn) || marker.atColumn < 0 || marker.atColumn >= width) { + errors.push(`${bar.id} has a marker outside its ${width}-column grid.`); + } + if (!legendTokens.has(marker.token)) { + errors.push(`${bar.id} uses marker token "${marker.token}" without a matching legend entry.`); + } + }); + if (bar.events !== undefined && !Array.isArray(bar.events)) { + errors.push(`${bar.id}.events must be an array.`); + } + (Array.isArray(bar.events) ? bar.events : []).forEach((event) => { + registerId(event.id, `Event in ${bar.id}`); + if (!Number.isFinite(event.atBeat) || event.atBeat < 0) { + errors.push(`${event.id || "An event"}.atBeat must be zero or greater.`); + } + if (!Number.isFinite(event.durationBeats) || event.durationBeats <= 0) { + errors.push(`${event.id || "An event"}.durationBeats must be greater than zero.`); + } + if ( + bar.beats !== undefined && + Number.isFinite(event.atBeat) && + Number.isFinite(event.durationBeats) && + event.atBeat + event.durationBeats > bar.beats + ) { + errors.push(`${event.id || "An event"} extends beyond ${bar.id}.beats.`); + } + if (!Array.isArray(event.notes) || !event.notes.length) { + errors.push(`${event.id || "An event"} must contain at least one note.`); + return; + } + event.notes.forEach((note) => { + if (!Number.isInteger(note.string) || note.string < 1 || note.string > stringCount) { + errors.push(`${event.id} contains a note on an invalid string.`); + } + if ( + !Number.isInteger(note.from) || + !Number.isInteger(note.to) || + note.from < 0 || + note.to <= note.from || + note.to > width + ) { + errors.push(`${event.id} contains an invalid note column range.`); + } + }); + }); + }); + } else if (block.type === "chordLyrics") { + if (!Array.isArray(block.lines) || !block.lines.length) { + errors.push(`${block.id || "A chord/lyric block"} must contain at least one line.`); + return; + } + block.lines.forEach((line) => { + registerId(line.id, "Chord/lyric line"); + if (!Array.isArray(line.spans) || !line.spans.length) { + errors.push(`${line.id || "A chord/lyric line"} must contain at least one span.`); + return; + } + line.spans.forEach((span) => registerId(span.id, "Chord/lyric span")); + }); + } else { + errors.push(`${block.id || "A block"} has unsupported type \"${block.type}\".`); + } + }); + }); + }); + + if (!data.timing?.timeSignature) { + warnings.push("Source meter is unspecified; the player uses a clearly labelled 4-count practice guide."); + } + return { errors, warnings }; + } + + function renderAll() { + renderMetadata(); + renderArrangementPicker(); + renderLegend(); + renderSheet(); + renderStats(); + renderPracticeState(); + } + + function renderMetadata() { + const { metadata, instrument, format } = state.data; + const timing = state.data.timing || {}; + const source = metadata.source; + dom.songTitle.textContent = metadata.title; + document.title = `${metadata.title} — Interactive guitar tabs`; + dom.sourceLabel.textContent = source?.file ? `from ${source.file}` : state.sourceName; + + dom.artistLine.replaceChildren(); + dom.artistLine.append("by "); + dom.artistLine.append(element("strong", "", metadata.artists.join(" & "))); + if (metadata.arranger) dom.artistLine.append(` · arranged by ${metadata.arranger}`); + + dom.metadataRow.replaceChildren( + metadataPill("Tuning", instrument.tuningName || tuningLabel(instrument.tuning)), + metadataPill("Capo", instrument.capo ? `fret ${instrument.capo}` : "none"), + metadataPill("Tempo", timing.tempoBpm ? `${timing.tempoBpm} BPM` : "not stated"), + metadataPill( + "Meter", + timing.timeSignature + ? `${timing.timeSignature.beats}/${timing.timeSignature.beatValue}` + : "not stated", + ), + ); + + const formatPill = document.querySelector(".format-pill"); + if (formatPill) formatPill.textContent = format; + + dom.attribution.replaceChildren(); + if (metadata.arranger) { + dom.attribution.append(`Arrangement: ${metadata.arranger}`); + const preferredLink = metadata.links?.[0]; + const preferredUrl = preferredLink ? safeHttpUrl(preferredLink.url) : null; + if (preferredLink && preferredUrl) { + dom.attribution.append(" · "); + const link = element("a", "", preferredLink.label); + link.href = preferredUrl; + link.target = "_blank"; + link.rel = "noreferrer"; + dom.attribution.append(link); + } + } + } + + function renderArrangementPicker() { + const arrangements = state.data.arrangements; + document.body.classList.toggle("single-arrangement", arrangements.length === 1); + dom.arrangementPicker.style.setProperty("--arrangement-count", arrangements.length); + dom.arrangementPicker.replaceChildren(); + + arrangements.forEach((arrangement, index) => { + const button = element("button", "segment-button", arrangement.title); + button.type = "button"; + button.role = "tab"; + button.id = `arrangement-tab-${arrangement.id}`; + button.setAttribute("aria-selected", String(index === state.arrangementIndex)); + button.tabIndex = index === state.arrangementIndex ? 0 : -1; + button.addEventListener("click", () => selectArrangement(index)); + button.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const delta = event.key === "ArrowRight" ? 1 : -1; + const next = (index + delta + arrangements.length) % arrangements.length; + selectArrangement(next); + dom.arrangementPicker.children[next]?.focus(); + }); + dom.arrangementPicker.append(button); + }); + + const activeArrangement = arrangements[state.arrangementIndex]; + dom.arrangementDescription.textContent = activeArrangement.description || ""; + } + + function renderLegend() { + dom.legendList.replaceChildren(); + (Array.isArray(state.data.legend) ? state.data.legend : []).forEach((item) => { + dom.legendList.append(element("dt", "", item.token), element("dd", "", item.label)); + }); + } + + function renderSheet() { + const arrangement = activeArrangement(); + const barEntries = flattenBars(arrangement); + const barIndexById = new Map(barEntries.map((entry, index) => [entry.bar.id, index])); + dom.tabSheet.replaceChildren(); + + const sectionNames = arrangement.sections.map((section) => section.title).filter(Boolean); + dom.sheetTitle.textContent = sectionNames.length === 1 ? sectionNames[0] : arrangement.title; + dom.sectionIndex.textContent = `${String(state.arrangementIndex + 1).padStart(2, "0")} / ${arrangement.title}`; + + arrangement.sections.forEach((section) => { + const sectionElement = element("section", "tab-section"); + sectionElement.setAttribute("aria-label", section.title || "Untitled section"); + const sectionHeader = element("div", "tab-section-header"); + sectionHeader.append(element("h3", "", section.title || "Untitled section")); + sectionElement.append(sectionHeader); + + section.blocks.forEach((block) => { + if (block.type === "tab") { + const barGrid = element("div", "tab-system"); + block.bars.forEach((bar) => { + barGrid.append(renderMeasure(bar, barIndexById.get(bar.id))); + }); + sectionElement.append(barGrid); + } else if (block.type === "chordLyrics") { + sectionElement.append(renderChordLyricsBlock(block)); + } + }); + + dom.tabSheet.append(sectionElement); + }); + } + + function renderMeasure(bar, globalIndex) { + const measure = element("article", "measure"); + measure.dataset.barIndex = String(globalIndex); + measure.tabIndex = 0; + measure.setAttribute("role", "button"); + measure.setAttribute("aria-label", `Select bar ${globalIndex + 1}`); + if (globalIndex === state.barIndex) measure.classList.add("active"); + + const head = element("div", "measure-head"); + head.append(element("span", "measure-number", `Bar ${String(globalIndex + 1).padStart(2, "0")}`)); + const markerCount = bar.markers?.length || 0; + head.append( + element( + "span", + "bar-hint", + markerCount ? `${markerCount} stroke${markerCount === 1 ? "" : "s"}` : "Tap to focus", + ), + ); + measure.append(head); + + const tabLines = element("div", "tab-lines"); + const width = bar.columnCount; + if (bar.markers?.length) { + const markerRow = element("div", "marker-row"); + markerRow.append(element("span", "string-label", "·")); + const markerText = Array(width).fill(" "); + bar.markers.forEach((marker) => { + markerText[marker.atColumn] = marker.token; + }); + markerRow.append(element("span", "tab-text", ` ${markerText.join("")} `)); + tabLines.append(markerRow); + } + + bar.rows.forEach((row) => { + const line = element("div", "tab-row"); + const tuning = state.data.instrument.tuning.find((item) => item.string === row.string); + line.append(element("span", "string-label", tuning?.label || String(row.string))); + line.append( + element( + "span", + "tab-text", + `${barlineToken(bar.barline?.left, "left")}${row.text}${barlineToken(bar.barline?.right, "right")}`, + ), + ); + tabLines.append(line); + }); + measure.append(tabLines); + + const lyricText = (Array.isArray(bar.lyrics) ? bar.lyrics : []).map((lyric) => lyric.text).join(" "); + measure.append(element("p", "lyrics-line", lyricText || " ")); + + measure.addEventListener("click", () => selectBar(globalIndex, false)); + measure.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectBar(globalIndex, false); + } + }); + return measure; + } + + function renderChordLyricsBlock(block) { + const container = element("div", "chord-lyrics-block"); + block.lines.forEach((line) => { + const lineElement = element("div", "chord-line"); + line.spans.forEach((span) => { + const spanElement = element("span", "chord-span"); + if (span.chord) spanElement.append(element("strong", "", span.chord)); + spanElement.append(element("span", "", span.text)); + lineElement.append(spanElement); + }); + container.append(lineElement); + }); + return container; + } + + function renderStats() { + const arrangements = state.data.arrangements; + dom.statArrangements.textContent = String(arrangements.length); + dom.statBars.textContent = String( + arrangements.reduce((total, arrangement) => total + flattenBars(arrangement).length, 0), + ); + dom.statStrings.textContent = String(state.data.instrument.strings); + } + + function renderPracticeState({ scroll = false } = {}) { + if (!state.data) return; + const bars = flattenBars(activeArrangement()); + const hasBars = bars.length > 0; + dom.playButton.disabled = !hasBars; + dom.previousButton.disabled = !hasBars; + dom.nextButton.disabled = !hasBars; + + if (!hasBars) { + dom.nowPlaying.textContent = "No tab bars in this arrangement"; + dom.barPosition.textContent = "No bars"; + dom.progressFill.style.width = "0%"; + dom.beatDots.replaceChildren(); + return; + } + + state.barIndex = clamp(state.barIndex, 0, bars.length - 1); + const current = bars[state.barIndex]; + const counts = practiceCounts(current.bar); + state.beatIndex = clamp(state.beatIndex, 0, counts - 1); + const lyrics = (Array.isArray(current.bar.lyrics) ? current.bar.lyrics : []) + .map((line) => line.text) + .join(" "); + dom.nowPlaying.textContent = lyrics || `${current.section.title || "Section"} · Bar ${state.barIndex + 1}`; + dom.barPosition.textContent = `Bar ${state.barIndex + 1} of ${bars.length}`; + dom.beatPosition.textContent = state.playing ? `Count ${state.beatIndex + 1}` : "Ready"; + const progress = ((state.barIndex + (state.playing ? (state.beatIndex + 1) / counts : 0)) / bars.length) * 100; + dom.progressFill.style.width = `${progress}%`; + + dom.beatDots.replaceChildren(); + for (let index = 0; index < counts; index += 1) { + const dot = element("span", "beat-dot"); + if (state.playing && index === state.beatIndex) dot.classList.add("active"); + dom.beatDots.append(dot); + } + + dom.playButton.querySelector(".play-icon").textContent = state.playing ? "Ⅱ" : "▶"; + dom.playButton.setAttribute("aria-label", state.playing ? "Pause practice guide" : "Start practice guide"); + + document.querySelectorAll(".measure").forEach((measure) => { + const active = Number(measure.dataset.barIndex) === state.barIndex; + measure.classList.toggle("active", active); + measure.setAttribute("aria-pressed", String(active)); + }); + + const inferred = !state.data.timing?.timeSignature || bars.some(({ bar }) => !bar.beats); + const sourceTempo = state.data.timing?.tempoBpm; + dom.practiceNote.textContent = inferred + ? `${sourceTempo ? `The source states ${sourceTempo} BPM` : "The source does not state a tempo"}, but it does not state meter or exact beat mapping. The 4-count cursor is an optional practice aid, not transcribed notation.` + : "The practice cursor follows the beat values encoded in the tab JSON."; + + if (scroll) { + document.querySelector(`.measure[data-bar-index="${state.barIndex}"]`)?.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + block: "nearest", + inline: "nearest", + }); + } + } + + function selectArrangement(index) { + if (index === state.arrangementIndex || !state.data.arrangements[index]) return; + stopPractice(); + state.arrangementIndex = index; + state.barIndex = 0; + state.beatIndex = 0; + renderArrangementPicker(); + renderSheet(); + renderPracticeState(); + dom.viewerStatus.textContent = `${activeArrangement().title} selected.`; + } + + function selectBar(index, scroll) { + const bars = flattenBars(activeArrangement()); + if (!bars.length) return; + state.barIndex = clamp(index, 0, bars.length - 1); + state.beatIndex = 0; + renderPracticeState({ scroll }); + dom.viewerStatus.textContent = `Bar ${state.barIndex + 1} selected.`; + } + + function moveBar(delta, fromUser = false) { + if (!state.data) return; + const bars = flattenBars(activeArrangement()); + if (!bars.length) return; + let nextIndex = state.barIndex + delta; + + if (nextIndex >= bars.length) { + if (dom.loopToggle.checked) nextIndex = 0; + else { + state.barIndex = bars.length - 1; + stopPractice(); + renderPracticeState(); + return; + } + } + if (nextIndex < 0) nextIndex = dom.loopToggle.checked ? bars.length - 1 : 0; + + state.barIndex = nextIndex; + state.beatIndex = 0; + renderPracticeState({ scroll: fromUser || state.playing }); + } + + function togglePractice() { + if (state.playing) stopPractice(); + else startPractice(); + } + + function startPractice() { + if (!state.data || !flattenBars(activeArrangement()).length) return; + state.playing = true; + state.beatIndex = 0; + playMetronomeTick(true); + renderPracticeState(); + startPracticeTimer(); + } + + function stopPractice() { + state.playing = false; + if (state.timer) window.clearInterval(state.timer); + state.timer = null; + if (state.data) renderPracticeState(); + } + + function startPracticeTimer() { + if (state.timer) window.clearInterval(state.timer); + const interval = 60000 / state.tempo; + state.timer = window.setInterval(() => { + const bars = flattenBars(activeArrangement()); + if (!bars.length) return stopPractice(); + const counts = practiceCounts(bars[state.barIndex].bar); + state.beatIndex += 1; + if (state.beatIndex >= counts) { + state.beatIndex = 0; + const previousIndex = state.barIndex; + moveBar(1); + if (!state.playing || (previousIndex === state.barIndex && !dom.loopToggle.checked)) return; + } + playMetronomeTick(state.beatIndex === 0); + renderPracticeState(); + }, interval); + } + + function playMetronomeTick(accent) { + if (!dom.metronomeToggle.checked) return; + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + state.audioContext ||= new AudioContext(); + if (state.audioContext.state === "suspended") state.audioContext.resume(); + + const oscillator = state.audioContext.createOscillator(); + const gain = state.audioContext.createGain(); + const now = state.audioContext.currentTime; + oscillator.frequency.setValueAtTime(accent ? 1120 : 820, now); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(accent ? 0.13 : 0.075, now + 0.003); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.045); + oscillator.connect(gain); + gain.connect(state.audioContext.destination); + oscillator.start(now); + oscillator.stop(now + 0.05); + } catch (_error) { + dom.metronomeToggle.checked = false; + } + } + + function handleTempoChange(event) { + state.tempo = Number(event.target.value); + dom.tempoOutput.value = String(state.tempo); + if (state.playing) startPracticeTimer(); + } + + function changeZoom(delta) { + state.zoomStep = clamp(state.zoomStep + delta, -2, 4); + const size = 0.96 + state.zoomStep * 0.09; + document.documentElement.style.setProperty("--tab-size", `${size.toFixed(2)}rem`); + showToast(`Tab size ${state.zoomStep > 0 ? "+" : ""}${state.zoomStep}`); + } + + async function handleFileLoad(event) { + const [file] = event.target.files; + if (!file) return; + try { + const data = JSON.parse(await file.text()); + if (loadTabData(data, file.name, "file")) showToast(`Loaded ${file.name}`); + } catch (error) { + showError(`Could not parse ${file.name}: ${error.message}`); + } finally { + event.target.value = ""; + } + } + + async function copyEmbedSnippet() { + if (state.sourceKind === "file") { + showToast("Upload the JSON to a URL first, then pass it with ?src=."); + return; + } + const url = new URL(window.location.href); + url.search = ""; + url.hash = ""; + url.searchParams.set("embed", "1"); + if (state.sourceKind === "url" && state.sourceUrl) url.searchParams.set("src", state.sourceUrl); + const snippet = ``; + try { + await navigator.clipboard.writeText(snippet); + } catch (_error) { + const textarea = element("textarea"); + textarea.value = snippet; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + showToast("Embed snippet copied"); + } + + function updateSourceLink() { + if (state.sourceKind === "file") { + dom.viewSourceLink.hidden = true; + return; + } + dom.viewSourceLink.hidden = false; + dom.viewSourceLink.href = state.sourceKind === "url" && state.sourceUrl ? state.sourceUrl : DEFAULT_TAB_URL; + } + + function handleKeyboard(event) { + if (!state.data || isInteractiveTarget(event.target)) return; + if (event.code === "Space") { + event.preventDefault(); + togglePractice(); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + moveBar(-1, true); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + moveBar(1, true); + } else if (event.key.toLowerCase() === "m") { + dom.metronomeToggle.checked = !dom.metronomeToggle.checked; + showToast(`Metronome ${dom.metronomeToggle.checked ? "on" : "off"}`); + } else if (event.key.toLowerCase() === "l") { + dom.loopToggle.checked = !dom.loopToggle.checked; + showToast(`Loop ${dom.loopToggle.checked ? "on" : "off"}`); + } + } + + function activeArrangement() { + return state.data.arrangements[state.arrangementIndex]; + } + + function flattenBars(arrangement) { + const result = []; + (arrangement?.sections || []).forEach((section) => { + (section.blocks || []).forEach((block) => { + if (block.type !== "tab") return; + (block.bars || []).forEach((bar) => result.push({ arrangement, section, block, bar })); + }); + }); + return result; + } + + function practiceCounts(bar) { + return clamp( + Math.round(bar.beats || state.data.timing?.timeSignature?.beats || DEFAULT_PRACTICE_COUNTS), + 1, + 32, + ); + } + + function barlineToken(type, side) { + const tokens = { + single: "|", + double: "||", + repeatStart: side === "left" ? "|:" : ":|", + repeatEnd: side === "right" ? ":|" : "|:", + final: "||", + none: "|", + }; + return tokens[type] || "|"; + } + + function metadataPill(label, value) { + const pill = element("span", "metadata-pill"); + pill.append(`${label} `, element("strong", "", value)); + return pill; + } + + function tuningLabel(tuning) { + return tuning.map((item) => item.label).join(" "); + } + + function element(tag, className = "", text = null) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== null && text !== undefined) node.textContent = text; + return node; + } + + function showError(message) { + dom.errorMessage.textContent = message; + dom.errorPanel.hidden = false; + } + + function hideError() { + dom.errorPanel.hidden = true; + } + + let toastTimer = null; + function showToast(message) { + window.clearTimeout(toastTimer); + dom.toast.textContent = message; + dom.toast.classList.add("show"); + toastTimer = window.setTimeout(() => dom.toast.classList.remove("show"), 2600); + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function toCamelCase(value) { + return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + } + + function isInteractiveTarget(target) { + return Boolean(target.closest("input, button, a, select, textarea, [role='button'], [contenteditable='true']")); + } + + function safeHttpUrl(value) { + try { + const url = new URL(value, window.location.href); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : null; + } catch (_error) { + return null; + } + } +})(); diff --git a/guitar-tab.schema.json b/guitar-tab.schema.json new file mode 100644 index 0000000..9ae5a12 --- /dev/null +++ b/guitar-tab.schema.json @@ -0,0 +1,345 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guitar Tab Interchange Format", + "description": "A portable, renderer-friendly format for guitar tablature, chord/lyric sheets, repeats, annotations, and beat-level playback anchors.", + "type": "object", + "additionalProperties": false, + "required": ["format", "id", "metadata", "instrument", "arrangements"], + "properties": { + "$schema": { "type": "string" }, + "format": { "const": "guitar-tab/v1" }, + "id": { "$ref": "#/$defs/id" }, + "metadata": { "$ref": "#/$defs/metadata" }, + "instrument": { "$ref": "#/$defs/instrument" }, + "timing": { "$ref": "#/$defs/timing" }, + "legend": { + "type": "array", + "items": { "$ref": "#/$defs/legendItem" }, + "default": [] + }, + "arrangements": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/arrangement" } + }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "$defs": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "extensions": { + "type": "object", + "description": "Namespaced vendor data. Keys should use reverse-domain notation.", + "additionalProperties": true, + "default": {} + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["title", "artists"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "artists": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "arranger": { "type": "string" }, + "album": { "type": "string" }, + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "type": "string" }, + "file": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "pages": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "uniqueItems": true + } + } + }, + "links": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["label", "url"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "format": "uri" } + } + } + } + } + }, + "instrument": { + "type": "object", + "additionalProperties": false, + "required": ["type", "strings", "tuning", "capo"], + "properties": { + "type": { "const": "guitar" }, + "strings": { "type": "integer", "minimum": 1, "maximum": 18 }, + "capo": { "type": "integer", "minimum": 0, "maximum": 24 }, + "tuningName": { "type": "string" }, + "tuning": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["string", "label", "pitch"], + "properties": { + "string": { "type": "integer", "minimum": 1 }, + "label": { "type": "string", "minLength": 1, "maxLength": 3 }, + "pitch": { "type": "string", "pattern": "^[A-G](?:#|b)?-?[0-9]+$" } + } + } + } + } + }, + "timing": { + "type": "object", + "additionalProperties": false, + "properties": { + "tempoBpm": { "type": "number", "exclusiveMinimum": 0, "maximum": 240 }, + "timeSignature": { + "type": "object", + "additionalProperties": false, + "required": ["beats", "beatValue"], + "properties": { + "beats": { "type": "integer", "minimum": 1, "maximum": 32 }, + "beatValue": { "type": "integer", "enum": [1, 2, 4, 8, 16, 32] } + } + } + } + }, + "legendItem": { + "type": "object", + "additionalProperties": false, + "required": ["token", "label"], + "properties": { + "token": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "kind": { + "type": "string", + "enum": ["technique", "stroke", "rhythm", "other"] + } + } + }, + "arrangement": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "sections"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "difficulty": { "type": "string" }, + "sections": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/section" } + }, + "extensions": { "$ref": "#/$defs/extensions" } + } + }, + "section": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "blocks"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "title": { "type": "string", "minLength": 1 }, + "repeat": { "$ref": "#/$defs/repeat" }, + "blocks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { "$ref": "#/$defs/tabBlock" }, + { "$ref": "#/$defs/chordLyricsBlock" } + ] + } + }, + "extensions": { "$ref": "#/$defs/extensions" } + } + }, + "repeat": { + "type": "object", + "additionalProperties": false, + "required": ["times"], + "properties": { + "times": { "type": "integer", "minimum": 2 } + } + }, + "tabBlock": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "bars"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "type": { "const": "tab" }, + "label": { "type": "string" }, + "bars": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/bar" } + }, + "repeat": { "$ref": "#/$defs/repeat" }, + "extensions": { "$ref": "#/$defs/extensions" } + } + }, + "bar": { + "type": "object", + "additionalProperties": false, + "required": ["id", "columnCount", "rows"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "columnCount": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "beats": { "type": "number", "exclusiveMinimum": 0, "maximum": 32 }, + "barline": { + "type": "object", + "additionalProperties": false, + "properties": { + "left": { "$ref": "#/$defs/barline" }, + "right": { "$ref": "#/$defs/barline" } + } + }, + "rows": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/tabRow" } + }, + "lyrics": { + "type": "array", + "items": { "$ref": "#/$defs/positionedText" } + }, + "chords": { + "type": "array", + "items": { "$ref": "#/$defs/positionedText" } + }, + "markers": { + "type": "array", + "items": { "$ref": "#/$defs/marker" } + }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/event" } + }, + "notes": { "type": "string" }, + "extensions": { "$ref": "#/$defs/extensions" } + } + }, + "barline": { + "type": "string", + "enum": ["none", "single", "double", "repeatStart", "repeatEnd", "final"] + }, + "tabRow": { + "type": "object", + "additionalProperties": false, + "required": ["string", "text"], + "properties": { + "string": { "type": "integer", "minimum": 1 }, + "text": { "type": "string", "minLength": 1 } + } + }, + "positionedText": { + "type": "object", + "additionalProperties": false, + "required": ["text"], + "properties": { + "text": { "type": "string", "minLength": 1 }, + "atColumn": { "type": "integer", "minimum": 0 } + } + }, + "marker": { + "type": "object", + "additionalProperties": false, + "required": ["atColumn", "token"], + "properties": { + "atColumn": { "type": "integer", "minimum": 0 }, + "token": { "type": "string", "minLength": 1 }, + "label": { "type": "string" } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["id", "atBeat", "durationBeats", "notes"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "atBeat": { "type": "number", "minimum": 0 }, + "durationBeats": { "type": "number", "exclusiveMinimum": 0 }, + "notes": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/note" } + }, + "label": { "type": "string" } + } + }, + "note": { + "type": "object", + "additionalProperties": false, + "required": ["string", "from", "to"], + "properties": { + "string": { "type": "integer", "minimum": 1 }, + "fret": { "type": "integer", "minimum": 0 }, + "muted": { "type": "boolean" }, + "from": { "type": "integer", "minimum": 0 }, + "to": { "type": "integer", "minimum": 1 }, + "techniques": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "anyOf": [ + { "required": ["fret"] }, + { "properties": { "muted": { "const": true } }, "required": ["muted"] } + ] + }, + "chordLyricsBlock": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "lines"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "type": { "const": "chordLyrics" }, + "label": { "type": "string" }, + "lines": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "spans"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "spans": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "text"], + "properties": { + "id": { "$ref": "#/$defs/id" }, + "text": { "type": "string" }, + "chord": { "type": "string" } + } + } + } + } + } + }, + "repeat": { "$ref": "#/$defs/repeat" }, + "extensions": { "$ref": "#/$defs/extensions" } + } + } + } +} diff --git a/haseen.tab.json b/haseen.tab.json new file mode 100644 index 0000000..39be0bd --- /dev/null +++ b/haseen.tab.json @@ -0,0 +1,191 @@ +{ + "$schema": "./guitar-tab.schema.json", + "format": "guitar-tab/v1", + "id": "haseen-talwinder-arun-kashyap", + "metadata": { + "title": "Haseen", + "artists": ["Talwinder"], + "arranger": "Arun Kashyap", + "source": { + "label": "User-provided PDF", + "file": "kesariya.pdf", + "pages": [1, 2] + }, + "links": [ + { + "label": "Arun Kashyap on YouTube", + "url": "https://www.youtube.com/@arunkashyap5631" + }, + { + "label": "@arunplaysguitar", + "url": "https://www.instagram.com/arunplaysguitar/" + } + ] + }, + "instrument": { + "type": "guitar", + "strings": 6, + "capo": 4, + "tuningName": "Standard", + "tuning": [ + { "string": 1, "label": "e", "pitch": "E4" }, + { "string": 2, "label": "B", "pitch": "B3" }, + { "string": 3, "label": "G", "pitch": "G3" }, + { "string": 4, "label": "D", "pitch": "D3" }, + { "string": 5, "label": "A", "pitch": "A2" }, + { "string": 6, "label": "E", "pitch": "E2" } + ] + }, + "timing": { + "tempoBpm": 82 + }, + "legend": [ + { "token": "h", "label": "Hammer-on", "kind": "technique" }, + { "token": "p", "label": "Pull-off", "kind": "technique" }, + { "token": "/", "label": "Slide up", "kind": "technique" }, + { "token": "\\", "label": "Slide down", "kind": "technique" }, + { "token": "x", "label": "Slap", "kind": "technique" }, + { "token": "R", "label": "Finger roll", "kind": "technique" }, + { "token": "↑", "label": "Down strum / stroke", "kind": "stroke" }, + { "token": "↓", "label": "Up strum / stroke", "kind": "stroke" } + ], + "arrangements": [ + { + "id": "complete-tab", + "title": "Complete tab", + "description": "All eight bars in the same order as the source document.", + "sections": [ + { + "id": "all-bars", + "title": "Bars 1-8", + "blocks": [ + { + "id": "all-bars-tab", + "type": "tab", + "bars": [ + { + "id": "bar-01", + "columnCount": 29, + "barline": { "left": "single", "right": "single" }, + "rows": [ + { "string": 1, "text": "---0-0-0-0h1-0h1h3-1-1p0-0---" }, + { "string": 2, "text": "-----1---------------------3-" }, + { "string": 3, "text": "-2---------------------------" }, + { "string": 4, "text": "-3-----------3---------------" }, + { "string": 5, "text": "-----------------------------" }, + { "string": 6, "text": "-----------------------------" } + ], + "lyrics": [{ "text": "Tere ishq da jaam haseen ae", "atColumn": 0 }] + }, + { + "id": "bar-02", + "columnCount": 29, + "barline": { "left": "none", "right": "single" }, + "rows": [ + { "string": 1, "text": "---------0h1-0h1p0-----------" }, + { "string": 2, "text": "---3-3-3-----------3-3-3p1---" }, + { "string": 3, "text": "---1-------------------------" }, + { "string": 4, "text": "-2-----------2---------------" }, + { "string": 5, "text": "-----------------------------" }, + { "string": 6, "text": "-----------------------------" } + ], + "lyrics": [{ "text": "Subah haseen meri shaam haseen ae", "atColumn": 0 }] + }, + { + "id": "bar-03", + "columnCount": 29, + "barline": { "left": "single", "right": "single" }, + "rows": [ + { "string": 1, "text": "-----------------------------" }, + { "string": 2, "text": "---1-1-------1-1-1-3/5-------" }, + { "string": 3, "text": "---2-2-----2-----------------" }, + { "string": 4, "text": "---------2-------------------" }, + { "string": 5, "text": "-0-----0---------------------" }, + { "string": 6, "text": "-----------------------------" } + ], + "lyrics": [{ "text": "Eh be-matlabi zindagi", "atColumn": 0 }] + }, + { + "id": "bar-04", + "columnCount": 29, + "barline": { "left": "none", "right": "single" }, + "rows": [ + { "string": 1, "text": "-----------------------------" }, + { "string": 2, "text": "-0-0-0-0---0h1-0h1p0---------" }, + { "string": 3, "text": "---------2-----------2-2-2---" }, + { "string": 4, "text": "-----------------------------" }, + { "string": 5, "text": "-----------------------------" }, + { "string": 6, "text": "-3-------------3-------------" } + ], + "lyrics": [{ "text": "Jado di tere naam haseen ae", "atColumn": 0 }] + }, + { + "id": "bar-05", + "columnCount": 29, + "barline": { "left": "single", "right": "single" }, + "rows": [ + { "string": 1, "text": "---0-0---0-0h1-0h1h3-1-0-0---" }, + { "string": 2, "text": "-----1-----------------0---3-" }, + { "string": 3, "text": "-2---------------------2-----" }, + { "string": 4, "text": "-----------------------------" }, + { "string": 5, "text": "-------x---------------x-----" }, + { "string": 6, "text": "-1-----x-------1-------x-----" } + ], + "markers": [{ "atColumn": 23, "token": "↑", "label": "Down strum / stroke" }], + "lyrics": [{ "text": "Tere ishq da jaam haseen ae", "atColumn": 0 }] + }, + { + "id": "bar-06", + "columnCount": 29, + "barline": { "left": "none", "right": "single" }, + "rows": [ + { "string": 1, "text": "---------0h1-0h1p0---0-------" }, + { "string": 2, "text": "---3-3-3-----------3-3-3p1---" }, + { "string": 3, "text": "---1-1-1---------------------" }, + { "string": 4, "text": "-------2---------------------" }, + { "string": 5, "text": "-------x-------------x-------" }, + { "string": 6, "text": "-0-----x-----0-------x-------" } + ], + "markers": [ + { "atColumn": 7, "token": "↑", "label": "Down strum / stroke" }, + { "atColumn": 21, "token": "↑", "label": "Down strum / stroke" } + ], + "lyrics": [{ "text": "Subah haseen meri shaam haseen ae", "atColumn": 0 }] + }, + { + "id": "bar-07", + "columnCount": 29, + "barline": { "left": "single", "right": "single" }, + "rows": [ + { "string": 1, "text": "-----------------------------" }, + { "string": 2, "text": "---1-1-------1-1---1-3/5-----" }, + { "string": 3, "text": "---2-2-----------------------" }, + { "string": 4, "text": "-----------2-----------------" }, + { "string": 5, "text": "-0-----x-0-------x-----------" }, + { "string": 6, "text": "-------x---------x-----------" } + ], + "lyrics": [{ "text": "Eh be-matlabi zindagi", "atColumn": 0 }] + }, + { + "id": "bar-08", + "columnCount": 29, + "barline": { "left": "none", "right": "single" }, + "rows": [ + { "string": 1, "text": "-----------------------------" }, + { "string": 2, "text": "-0-0-0-0-0h1-0h1p0-----------" }, + { "string": 3, "text": "-------2---------------------" }, + { "string": 4, "text": "-----------------------------" }, + { "string": 5, "text": "-------x---------------------" }, + { "string": 6, "text": "-3-----x-----3---------------" } + ], + "markers": [{ "atColumn": 7, "token": "↑", "label": "Down strum / stroke" }], + "lyrics": [{ "text": "Jado di tere naam.......", "atColumn": 0 }] + } + ] + } + ] + } + ] + } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..8fc5fd2 --- /dev/null +++ b/index.html @@ -0,0 +1,169 @@ + + + + + + + + + Fretform — Interactive guitar tabs + + + +
+
+ + + Fretform + +
+ guitar-tab/v1 + + + + View source + +
+
+ +
+
+
+

Interactive tab sheetLoading JSON…

+

Loading tab…

+

+ +
+ +
+
Choose an arrangement
+
+

+
+
+ +
+
+ +
+ Practice guide + Select a bar to begin +
+
+ + +
+
+ +
+
+ Bar 1 of 1 + Ready +
+ +
+
+ +
+ + + +
+
+ +

+ +
+
+
+
+

01 / Tab

+

Tablature

+
+
+ Tab size + + +
+
+
+

+
+ + +
+ +
+
+

For the team

+

Drop it into an existing page.

+

Use the full viewer or add ?embed=1 for a compact, iframe-friendly surface.

+
+ +
+
+ +
+ Interactive sample • source data stays separate from the viewer + +
+
+ +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..762ee95 --- /dev/null +++ b/styles.css @@ -0,0 +1,1162 @@ +:root { + color-scheme: light; + --paper: #f2efe7; + --paper-strong: #fffdf7; + --ink: #171b1a; + --muted: #6b706d; + --line: #d5d0c5; + --forest: #173f37; + --forest-deep: #0d2d28; + --orange: #f45a35; + --orange-soft: #f9d9ce; + --lime: #d6ed9a; + --shadow: 0 16px 45px rgba(23, 31, 28, 0.09); + --tab-size: 0.96rem; + --radius-large: 26px; + --radius-medium: 18px; + --radius-small: 11px; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: + radial-gradient(circle at 92% 3%, rgba(214, 237, 154, 0.5), transparent 25rem), + var(--paper); + color: var(--ink); + min-width: 320px; +} + +button, +input, +output { + font: inherit; +} + +button, +a, +label { + -webkit-tap-highlight-color: transparent; +} + +button, +.file-button { + cursor: pointer; +} + +a { + color: inherit; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid var(--orange); + outline-offset: 3px; +} + +.page-shell { + width: min(1480px, calc(100% - 40px)); + margin: 0 auto; +} + +.topbar { + min-height: 88px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid rgba(23, 27, 26, 0.15); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + text-decoration: none; +} + +.brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--forest); + color: white; + font-size: 0.7rem; + letter-spacing: -0.06em; +} + +.topbar-actions, +.metadata-row, +.transport-buttons, +.progress-copy, +.zoom-control { + display: flex; + align-items: center; +} + +.topbar-actions { + gap: 9px; +} + +.format-pill, +.metadata-pill { + border: 1px solid rgba(23, 27, 26, 0.18); + border-radius: 999px; + background: rgba(255, 253, 247, 0.52); + white-space: nowrap; +} + +.format-pill { + padding: 9px 13px; + font: 700 0.7rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; + color: var(--muted); +} + +.button { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 17px; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 750; + text-decoration: none; + transition: transform 160ms ease, background 160ms ease, color 160ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button-quiet { + background: transparent; + border-color: rgba(23, 27, 26, 0.22); +} + +.button-dark { + background: var(--ink); + color: white; +} + +.button-light { + background: white; + color: var(--forest-deep); +} + +.hero { + min-height: 470px; + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(310px, 0.55fr); + align-items: end; + gap: clamp(40px, 8vw, 120px); + padding: 84px 4vw 68px; +} + +.eyebrow { + display: flex; + gap: 12px; + align-items: center; + margin: 0 0 23px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; + color: var(--muted); +} + +.eyebrow span + span::before { + content: "•"; + margin-right: 12px; + color: var(--orange); +} + +.hero h1 { + max-width: 900px; + margin: 0; + font-family: Iowan Old Style, Baskerville, Georgia, serif; + font-size: clamp(4.4rem, 10vw, 9.8rem); + line-height: 0.79; + letter-spacing: -0.075em; + font-weight: 600; +} + +.artist-line { + margin: 30px 0 20px; + font-size: clamp(1.1rem, 2.2vw, 1.55rem); + color: var(--muted); +} + +.artist-line strong { + color: var(--ink); +} + +.metadata-row { + flex-wrap: wrap; + gap: 9px; +} + +.metadata-pill { + display: inline-flex; + gap: 7px; + padding: 9px 13px; + font-size: 0.75rem; + color: var(--muted); +} + +.metadata-pill strong { + color: var(--ink); +} + +.arrangement-panel { + align-self: end; + padding: 25px; + border: 1px solid rgba(23, 27, 26, 0.14); + border-radius: var(--radius-large); + background: rgba(255, 253, 247, 0.7); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); +} + +.panel-kicker, +.section-index { + margin: 0; + font-size: 0.65rem; + line-height: 1.2; + font-weight: 850; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--orange); +} + +.segmented-control { + display: grid; + grid-template-columns: repeat(var(--arrangement-count, 2), minmax(0, 1fr)); + gap: 6px; + padding: 5px; + margin: 14px 0 15px; + border-radius: 14px; + background: #e9e5dc; +} + +.segment-button { + min-height: 44px; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--muted); + font-size: 0.78rem; + font-weight: 750; +} + +.segment-button[aria-selected="true"] { + background: var(--paper-strong); + color: var(--ink); + box-shadow: 0 5px 16px rgba(23, 27, 26, 0.1); +} + +.arrangement-description { + min-height: 2.8em; + margin: 0; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.5; +} + +body.single-arrangement .arrangement-panel { + display: none; +} + +body.single-arrangement .hero { + grid-template-columns: minmax(0, 1fr); +} + +body.single-arrangement .hero-copy { + max-width: 1100px; +} + +.practice-player { + position: sticky; + top: 12px; + z-index: 20; + display: grid; + grid-template-columns: minmax(290px, 0.9fr) minmax(260px, 1.25fr) minmax(310px, 1fr); + gap: 28px; + align-items: center; + padding: 19px 22px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-large); + background: var(--forest); + color: white; + box-shadow: 0 20px 55px rgba(13, 45, 40, 0.25); +} + +.transport { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 13px; + align-items: center; +} + +.play-button { + width: 54px; + height: 54px; + display: grid; + place-items: center; + border: 0; + border-radius: 50%; + background: var(--orange); + color: white; + transition: transform 160ms ease; +} + +.play-button:hover { + transform: scale(1.04); +} + +.play-icon { + transform: translateX(1px); + font-size: 1rem; +} + +.transport-copy { + min-width: 0; +} + +.transport-copy span, +.practice-settings label > span:last-child { + color: rgba(255, 255, 255, 0.66); +} + +.transport-label { + display: block; + margin-bottom: 4px; + font-size: 0.67rem; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.transport-copy strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; +} + +.transport-buttons { + gap: 6px; +} + +.icon-button { + width: 39px; + height: 39px; + display: grid; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; + background: transparent; + color: inherit; + font-size: 1rem; + transition: background 160ms ease, border 160ms ease; +} + +.icon-button:hover { + background: rgba(255, 255, 255, 0.11); +} + +.icon-button.small { + width: 31px; + height: 31px; + border-color: rgba(23, 27, 26, 0.22); + color: var(--ink); +} + +.beat-and-progress { + min-width: 0; +} + +.progress-copy { + justify-content: space-between; + gap: 20px; + margin-bottom: 9px; + color: rgba(255, 255, 255, 0.68); + font-size: 0.7rem; + font-weight: 700; +} + +.progress-track { + height: 3px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); +} + +.progress-track span { + display: block; + width: 0; + height: 100%; + background: var(--lime); + transition: width 220ms ease; +} + +.beat-dots { + display: flex; + gap: 6px; + margin-top: 10px; +} + +.beat-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.24); + transition: transform 100ms ease, background 100ms ease; +} + +.beat-dot.active { + background: var(--lime); + transform: scale(1.45); +} + +.practice-settings { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 18px; +} + +.tempo-control { + display: grid; + grid-template-columns: auto auto; + gap: 2px 10px; + min-width: 125px; +} + +.tempo-control > span { + color: rgba(255, 255, 255, 0.65); + font-size: 0.68rem; +} + +.tempo-control strong { + justify-self: end; + font-size: 0.72rem; +} + +.tempo-control input { + grid-column: 1 / -1; + width: 100%; + accent-color: var(--lime); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + font-size: 0.72rem; +} + +.toggle-row input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; +} + +.toggle { + width: 30px; + height: 18px; + padding: 2px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + transition: background 160ms ease; +} + +.toggle::after { + content: ""; + display: block; + width: 14px; + height: 14px; + border-radius: 50%; + background: white; + transition: transform 160ms ease; +} + +.toggle-row input:checked + .toggle { + background: var(--orange); +} + +.toggle-row input:checked + .toggle::after { + transform: translateX(12px); +} + +.toggle-row input:focus-visible + .toggle { + outline: 3px solid var(--orange); + outline-offset: 3px; +} + +.practice-note { + margin: 12px 22px 0; + color: var(--muted); + font-size: 0.72rem; + line-height: 1.45; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 315px; + gap: 24px; + padding: 58px 0 80px; +} + +.sheet, +.side-card { + border: 1px solid rgba(23, 27, 26, 0.13); + background: var(--paper-strong); +} + +.sheet { + min-width: 0; + padding: clamp(22px, 4vw, 50px); + border-radius: var(--radius-large); + box-shadow: var(--shadow); +} + +.sheet-header { + display: flex; + justify-content: space-between; + align-items: end; + gap: 20px; + margin-bottom: 30px; +} + +.sheet-header h2, +.side-card h2, +.embed-strip h2 { + margin: 7px 0 0; + font-family: Iowan Old Style, Baskerville, Georgia, serif; + font-weight: 600; + letter-spacing: -0.035em; +} + +.sheet-header h2 { + font-size: clamp(2.1rem, 4vw, 3.5rem); +} + +.zoom-control { + gap: 7px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; +} + +.tab-section + .tab-section { + margin-top: 48px; +} + +.tab-section-header { + display: flex; + align-items: center; + gap: 13px; + margin: 0 0 14px; +} + +.tab-section-header h3 { + margin: 0; + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tab-section-header::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.tab-system { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.tab-system + .tab-system { + margin-top: 12px; +} + +.measure { + position: relative; + min-width: 0; + overflow: hidden; + padding: 18px 18px 17px; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: #faf8f2; + transition: transform 170ms ease, border 170ms ease, background 170ms ease, color 170ms ease; + cursor: pointer; +} + +.measure:hover { + transform: translateY(-2px); + border-color: rgba(23, 63, 55, 0.5); +} + +.measure.active { + border-color: var(--forest); + background: var(--forest); + color: white; + box-shadow: 0 16px 32px rgba(23, 63, 55, 0.19); +} + +.measure-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 11px; +} + +.measure-number { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.64rem; + font-weight: 850; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.measure-number::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--line); +} + +.measure.active .measure-number, +.measure.active .bar-hint { + color: rgba(255, 255, 255, 0.63); +} + +.measure.active .measure-number::before { + background: var(--orange); + box-shadow: 0 0 0 5px rgba(244, 90, 53, 0.18); +} + +.bar-hint { + font-size: 0.64rem; + color: var(--muted); +} + +.tab-lines { + overflow-x: auto; + padding: 4px 0 3px; + scrollbar-width: thin; +} + +.tab-row, +.marker-row { + display: grid; + grid-template-columns: 1.6ch max-content; + gap: 0.7ch; + width: max-content; + min-width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--tab-size); + line-height: 1.47; + letter-spacing: -0.03em; + white-space: pre; + font-variant-ligatures: none; +} + +.string-label { + color: var(--orange); + font-weight: 850; +} + +.marker-row { + min-height: 1.3em; + color: var(--orange); + font-weight: 900; +} + +.marker-row .string-label { + color: transparent; +} + +.lyrics-line { + min-height: 1.5em; + margin: 12px 0 0 2.3ch; + color: var(--orange); + font-family: Iowan Old Style, Baskerville, Georgia, serif; + font-size: 1rem; + font-style: italic; + line-height: 1.35; +} + +.measure.active .lyrics-line { + color: var(--lime); +} + +.chord-lyrics-block { + display: flex; + flex-wrap: wrap; + gap: 14px 24px; + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: #faf8f2; +} + +.chord-span { + display: inline-grid; + gap: 3px; +} + +.chord-span strong { + color: var(--orange); + font: 800 0.76rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 16px; +} + +.side-card { + padding: 25px; + border-radius: var(--radius-medium); +} + +.side-card h2 { + font-size: 1.7rem; +} + +.legend-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px 15px; + margin: 23px 0 0; +} + +.legend-list dt { + min-width: 27px; + height: 27px; + display: grid; + place-items: center; + margin: 0; + border-radius: 8px; + background: var(--orange-soft); + color: #a92c12; + font: 850 0.79rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.legend-list dd { + align-self: center; + margin: 0; + color: var(--muted); + font-size: 0.78rem; +} + +.data-card { + background: var(--lime); +} + +.data-card p:not(.panel-kicker) { + margin: 14px 0 0; + color: #405344; + font-size: 0.84rem; + line-height: 1.55; +} + +.data-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin: 23px 0; +} + +.data-stats div { + display: grid; + gap: 2px; +} + +.data-stats strong { + font-family: Iowan Old Style, Baskerville, Georgia, serif; + font-size: 2rem; + font-weight: 600; +} + +.data-stats span { + color: #536756; + font-size: 0.64rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.text-link { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 0.77rem; + font-weight: 800; + text-underline-offset: 4px; +} + +.shortcuts-card ul { + display: grid; + gap: 9px; + margin: 19px 0 0; + padding: 0; + list-style: none; +} + +.shortcuts-card li { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 5px; + align-items: center; +} + +.shortcuts-card li span { + justify-self: end; + color: var(--muted); + font-size: 0.75rem; +} + +kbd, +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +kbd { + min-width: 25px; + padding: 5px 7px; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 6px; + background: #f4f1e9; + font-size: 0.66rem; + text-align: center; +} + +.embed-strip { + display: flex; + align-items: end; + justify-content: space-between; + gap: 40px; + margin-bottom: 58px; + padding: clamp(30px, 5vw, 58px); + border-radius: var(--radius-large); + background: var(--orange); + color: white; +} + +.embed-strip .panel-kicker { + color: var(--forest-deep); +} + +.embed-strip h2 { + max-width: 650px; + font-size: clamp(2.2rem, 5vw, 4.5rem); + line-height: 0.98; +} + +.embed-strip p:not(.panel-kicker) { + max-width: 650px; + margin: 16px 0 0; + color: rgba(255, 255, 255, 0.78); + font-size: 0.87rem; +} + +.embed-strip code { + padding: 2px 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.16); +} + +footer { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-top: 1px solid rgba(23, 27, 26, 0.15); + color: var(--muted); + font-size: 0.72rem; +} + +footer a { + text-underline-offset: 3px; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 50; + max-width: min(360px, calc(100% - 44px)); + padding: 12px 16px; + border-radius: 12px; + background: var(--ink); + color: white; + box-shadow: var(--shadow); + font-size: 0.8rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.error-panel { + position: fixed; + inset: auto 20px 20px 20px; + z-index: 60; + max-width: 680px; + margin: auto; + padding: 18px; + border-radius: 14px; + background: #721f16; + color: white; + box-shadow: var(--shadow); +} + +.error-panel strong, +.error-panel span { + display: block; +} + +.error-panel span { + margin-top: 5px; + color: rgba(255, 255, 255, 0.78); + font-size: 0.8rem; +} + +body.embed { + background: var(--paper); +} + +body.embed .page-shell { + width: min(1120px, calc(100% - 24px)); +} + +body.embed .topbar, +body.embed .embed-strip, +body.embed footer, +body.embed .shortcuts-card, +body.embed .data-card { + display: none; +} + +body.embed .hero { + min-height: auto; + padding: 34px 12px 28px; +} + +body.embed .hero h1 { + font-size: clamp(3.6rem, 10vw, 7rem); +} + +body.embed .content-grid { + grid-template-columns: minmax(0, 1fr) 250px; + padding: 28px 0 20px; +} + +@media (max-width: 1120px) { + .practice-player { + grid-template-columns: 1fr 1fr; + } + + .practice-settings { + grid-column: 1 / -1; + justify-content: space-between; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.13); + } + + .content-grid, + body.embed .content-grid { + grid-template-columns: 1fr; + } + + .sidebar { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + body.embed .sidebar { + grid-template-columns: 1fr; + } +} + +@media (max-width: 780px) { + .page-shell { + width: min(100% - 22px, 1480px); + } + + .topbar { + min-height: 72px; + } + + .format-pill, + .button-quiet { + display: none; + } + + .hero { + grid-template-columns: 1fr; + min-height: auto; + gap: 38px; + padding: 60px 10px 40px; + } + + .hero h1 { + font-size: clamp(4.1rem, 24vw, 7.4rem); + } + + .practice-player { + position: relative; + top: auto; + grid-template-columns: 1fr; + gap: 18px; + padding: 18px; + } + + .practice-settings { + grid-column: auto; + flex-wrap: wrap; + justify-content: flex-start; + } + + .tempo-control { + flex: 1 0 150px; + } + + .content-grid { + padding-top: 34px; + } + + .sheet { + padding: 22px 12px; + } + + .sheet-header { + padding: 0 9px; + } + + .tab-system { + grid-template-columns: 1fr; + } + + .measure { + padding-inline: 14px; + } + + .sidebar { + grid-template-columns: 1fr; + } + + .embed-strip { + align-items: flex-start; + flex-direction: column; + } + + footer { + align-items: flex-start; + flex-direction: column; + justify-content: center; + } +} + +@media (max-width: 480px) { + .topbar-actions .button-dark { + padding-inline: 13px; + } + + .brand > span:last-child { + display: none; + } + + .eyebrow { + align-items: flex-start; + flex-direction: column; + } + + .eyebrow span + span::before { + display: none; + } + + .arrangement-panel { + padding: 20px; + } + + .transport { + grid-template-columns: auto minmax(0, 1fr); + } + + .transport-buttons { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .sheet-header { + align-items: flex-start; + flex-direction: column; + } + + .zoom-control { + align-self: flex-end; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +}