I've been studying guitar with an app I built with Claude as an artifact: the fretboard, chords, scales, improvisation and thirteen jazz standards, with a metronome, backing tracks and a daily routine. I called it Mástil, Spanish for a guitar's neck.
It was good enough that I wanted it on my site, where anyone can use it. It's now at music.dinartux.com/lab, with a Spanish version. I didn't rewrite it. This post is about the glue.
What an artifact gives you
One HTML file. Mine has about 6,000 lines and weighs 500 KB:
- a
<style>block, - a small page shell (top bar, side menu, an empty
<main>), - one
<script>with everything else: music theory, audio, fretboard drawings and 53 screens behind a hash router (#acordes,#dic-escalas,#tema-autumn...).
No framework and no build. Text is plain string literals glued together with +. Progress is saved in localStorage.
It was also made for one person: it's all in Spanish, it loads its fonts from Google Fonts, and a few lines talk about "your PDF" or tell you to report errors to Claude.
The decision: don't port it, wrap it
I had three options:
- Rewrite it as Vue components inside VitePress, which runs my sites. Weeks of work, and from then on two apps to keep in sync.
- Put it in an iframe. Quick, but still Spanish only, still fetching its fonts from Google, and ignoring the light or dark mode you picked on my site.
- Keep the artifact exactly as exported and change it at build time.
I went with the third. The export sits in the repo, untouched, as lab/mastil.html. A build step takes it apart and writes two pages. If I improve the app in Claude tomorrow, I export it again, replace one file, and the build tells me what's new.
Piece 1: a build step, not a page
VitePress has a buildEnd hook that runs when the site is built. Mine writes the lab next to the regular pages:
import { buildLab, labSitemapItems } from './lab'
export default defineConfig({
sitemap: { hostname: urls.music, transformItems: (items) => [...items, ...labSitemapItems(urls.music)] },
buildEnd: buildLab,
})buildLab reads the artifact and writes:
lab.htmlandes/lab.html, small pages with the shell, a title, a description andhreflanglinks to each other,- one stylesheet, shared by both,
- one script per language,
- the fonts.
Every file under /lab/ has a hash of its content in its name (mastil.en.878fd3be.js), so one rule in _headers lets browsers keep them for good:
/lab/*
Cache-Control: public, max-age=31536000, immutablePiece 2: translate by parsing, not by search and replace
The app has around 3,500 different strings in its script. Some are text for people, like 'Racha: '. Many are not: CSS classes, element names, ids. The letter 'a' is a link tag in one place and a syllable for counting rhythm in another. Search and replace would break things.
So the build reads the script with a real JavaScript parser (@babel/parser), which knows exactly where each string literal starts and ends. Then it swaps only the ones that appear in a table:
function translateScript(code: string, table: Table): string {
let out = ''
let at = 0
for (const literal of literals(code)) {
const to = table[literal.text]
if (to === undefined || to === literal.text) continue
out += code.slice(at, literal.start) + asSource(to, literal.quote)
at = literal.end
}
return out + code.slice(at)
}The table is a JSON file, Spanish on the left and English on the right:
{
"Racha: ": "Streak: ",
"Tiempo: ": "Time: ",
" · errores: ": " · mistakes: ",
"Jam": "Jam"
}An entry that equals its key means "I looked at this one, keep it". That matters for the check: npm run lab:check lists every string that looks like text and has no entry, every entry that no longer matches anything, and every translation whose leading or trailing spaces differ from the original. The app glues pieces together, so a missing space shows up as "Streak:5".
Today the English table has about 2,000 translations and 500 "keep" entries. A second, tiny table rewords eight Spanish lines for the public page, like the one that mentioned Claude.
Piece 3: patches, for what a table can't do
A table maps one string to one string. A few places need more:
- The same string with two meanings.
'T'is a whole step (tono) in one function and the tonic in another; English needs "W" and "Tonic".'Tema'is a tune in one table and a topic in another. - Word order. Spanish says "Forma C", English says "C shape", and the name comes from a variable.
- Formats. A decimal comma, a space before
%.
For these, the build has a short list of exact code replacements. For example:
{
part: 'js',
lang: 'en',
find: "label: 'Forma ' + x.k",
replace: "label: x.k + ' shape'",
why: 'word order: "C shape"',
},Each find must appear in the artifact exactly once (or be marked as "all of them"). If a new export changes that line, the build fails and tells me which patch lost its place, instead of shipping a page that is half patched. There are 30 patches: 20 only for English, and the rest for both languages (most of those just square the corners in the stylesheet, like my site's buttons).
Piece 4: make it feel like the same site
The first version kept the artifact's own look, and it felt like landing somewhere else: other colors, other fonts, and no obvious way back to the rest of the site. Three things fixed that:
- The top bar is the site's. Same name on the left (the way home), same links, with "Lab" marked as the current page, plus the language link and a light/dark button. The links come from one file that both VitePress and the lab read, so they can't drift.
- The site's palette and fonts. The artifact draws everything from a handful of CSS variables (
--paper,--ink,--display...), so a small stylesheet loaded last points them at my site's greys, its brass accent and its fonts: Jost, Inter and DM Mono, taken from Fontsource packages and served from/lab/fonts/. No third-party requests. Brass is only for the page's chrome, though: inside the lessons, color means a musical function (root, third, fifth), and I didn't want the accent to read as "the third". - Light or dark is one setting. The page reads the same setting VitePress saves, and its button writes it back, so the choice follows you from the lab to the rest of the site.
The rest is house rules:
Reduced motion is respected. The artifact already turned its animations off; a patch does the same for its smooth scrolling.
The language link keeps your place. Screen ids are the same in both languages (
#acordesin English too), so switching from English to Spanish lands on the same screen.VitePress links need a hint.
/labisn't a VitePress page, so its router would show a 404.target="_self"on the link makes the browser load the page for real:md[Open Mástil →](/lab){.dx-button target="_self"}
Where your progress lives
I expected to need cookies. I didn't: there's no server, so nothing needs to travel with a request. Everything is in localStorage, under one key.
Inside Claude, the artifact could also sync progress to my account. That code looks for window.claude, doesn't find it on my site, and falls back to "Saved in this browser". I didn't have to change a line.
The limits are honest ones:
- Progress stays in that browser, on that device. The Progress screen has a backup you can copy as text and paste into another one.
- Both languages share the same progress, but a few history rows keep the language they were recorded in.
Things I ran into
constructorgot translated. A plain object used as a table answerstable['constructor']with a function, because ofObject.prototype. The script happens to contain that word. The fix is a table with no prototype:Object.create(null).- Short strings hide.
' y ',' de 'and'.ª'don't look like text to an automatic check. I listed everything the check skipped and went through it by hand. - One word, two meanings. The scans above catch errors and leftover Spanish, not correct English in the wrong place. For short words that appear more than once, I read every use.
- Translated strings can be logic, too. The app compares a few display strings (
S.cat === 'Todos'). It works because both sides go through the same table, but I had the build's parser list every comparison, key and CSS class that uses a translated string, to be sure. - Fonts without ♭. With the new fonts, chord names came out as "B ♭ maj7". Jost and Inter have no flat or sharp signs, so the browser borrowed them from a symbol font with very wide glyphs. Putting the system font right after mine in the font list fixed it.
- Testing 53 screens. A small script in the browser opens every route in both languages, and looks for errors, leftover Spanish on the English page, and anything wider than a phone screen.
How Claude helped
The app itself came out of many conversations with Claude: I asked for lessons, tried them, and asked for changes.
The move to the site was Claude Code working in my repo. It proposed the build-step approach, wrote the script, translated the 2,000 strings to English with the context of each one, and tested every screen in both languages before telling me it was done.
What I did: decide what goes public, review the result, and approve the deploy.
One thing to keep in mind if your app is about music: the standards library has chord changes and notes on how to practice each tune, but no melodies and no lyrics, which are under copyright. You learn those by ear from the recordings, which is how jazz teachers tell you to do it anyway.
Claude Code helped me draft this post too, and I reviewed it before it went out.
When this fits
This works when the artifact is self-contained (one file, no server) and you want to keep improving it in Claude. The artifact stays the source of truth, and the build is a thin, strict layer on top.
If the app needs accounts, a database or other people's data, an export won't be enough. And if you only need one language and don't mind the fonts, an artifact is already a web page: you might not need any of this.