Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 0 additions & 42 deletions .agents/skills/render-musicxml/SKILL.md

This file was deleted.

11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
After making code changes:

- Run `vex fix` to typecheck, format, and lint the project.
- Run `vex test` to test the project.
- `vex fix` typecheck, format, and lint the project.
- `vex test` test the project.

MusicXML tools:

- `vex validate -i <path>` validate a MusicXML file
- `vex render -i <path>` render a MusicXML file to a PNG

Please delete screenshots when you are done, unless you're showing the user something.
9 changes: 9 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dev } from './dev';
import { fix } from './fix';
import { render } from './render';
import { test } from './test';
import { validate } from './validate';

// Where the user actually ran `vex`, before we chdir to the repo root below.
const invocationDir = process.cwd();
Expand Down Expand Up @@ -66,4 +67,12 @@ program
});
});

program
.command('validate')
.description('validate a musicxml file against the MusicXML XSD with xmllint')
.requiredOption('-i, --input <path>', 'input musicxml file')
.action(async (opts) => {
await validate({ input: opts.input, cwd: invocationDir });
});

program.parse();
10 changes: 10 additions & 0 deletions cli/validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isAbsolute, resolve } from 'node:path';
import { run } from './run';

export async function validate(opts: { input: string; cwd: string }) {
// index.ts chdir'd to the repo root, so resolve the user path against their cwd.
const at = isAbsolute(opts.input)
? opts.input
: resolve(opts.cwd, opts.input);
await run('./xmllint/validate.sh', [at]);
}
135 changes: 125 additions & 10 deletions site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export default function App() {
const [dragging, setDragging] = useState(false);
const [debouncing, setDebouncing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [dark, setDark] = useState(false);
const [stored, setStored] = useState(
() => localStorage.getItem(STORAGE_KEY) !== null,
);
Expand All @@ -90,9 +91,13 @@ export default function App() {
const noteSpacing = config.noteSpacing ?? 36;
const softmaxFactor = config.softmaxFactor ?? 10;
const systemSpacing = config.systemSpacing ?? 30;
const maxSystemFill = config.maxSystemFill ?? 0.9;
const width =
config.layout?.type === 'standard' ? (config.layout.width ?? 900) : 900;
const notationFont = config.fonts?.notation?.family ?? 'Bravura';
const reset = (key: 'noteSpacing' | 'softmaxFactor' | 'systemSpacing') =>
setConfig(({ [key]: _, ...rest }) => rest);
const reset = (
key: 'noteSpacing' | 'softmaxFactor' | 'systemSpacing' | 'maxSystemFill',
) => setConfig(({ [key]: _, ...rest }) => rest);

// `config` stays live so the sliders/reset respond instantly; `renderConfig` lags
// behind it by the debounce so dragging a slider re-renders once it settles, not on
Expand All @@ -119,10 +124,17 @@ export default function App() {
}
setError(null);
const start = performance.now();
// Engrave once at the default (8.5in) width; CSS then scales the canvas to fit its
// container — down when narrow, never past 100% when wide — so resizing the window
// re-scales instantly without re-rendering.
render(input, canvas, { ...renderConfig, layout: { type: 'standard' } })
// Engrave once at the configured reference width; CSS then scales the canvas to fit
// its container — down when narrow, never past 100% when wide — so resizing the
// window re-scales instantly without re-rendering.
const layoutWidth =
renderConfig.layout?.type === 'standard'
? renderConfig.layout.width
: undefined;
render(input, canvas, {
...renderConfig,
layout: { type: 'standard', width: layoutWidth },
})
.then(() => {
canvas.style.width = '100%';
canvas.style.height = 'auto';
Expand Down Expand Up @@ -360,6 +372,18 @@ export default function App() {
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
Config
</span>
<label
htmlFor="darkMode"
className="flex items-center gap-2 text-xs font-medium text-zinc-500"
>
<input
id="darkMode"
type="checkbox"
checked={dark}
onChange={(e) => setDark(e.target.checked)}
/>
Dark mode
</label>
<div className="flex flex-col gap-1.5">
<label
htmlFor="notationFont"
Expand Down Expand Up @@ -517,6 +541,92 @@ export default function App() {
closer together down the page.
</p>
</div>

<div className="flex flex-col gap-1.5">
<label
htmlFor="maxSystemFill"
className="flex items-center justify-between text-xs font-medium text-zinc-500"
>
Max system fill
<span className="flex items-center gap-1.5">
<span className="font-mono text-zinc-400">
{maxSystemFill.toFixed(2)}
</span>
<button
type="button"
onClick={() => reset('maxSystemFill')}
disabled={config.maxSystemFill === undefined}
aria-label="Reset max system fill"
className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
>
<ResetIcon />
</button>
</span>
</label>
<input
id="maxSystemFill"
type="range"
min={0.1}
max={1}
step={0.05}
value={maxSystemFill}
onChange={(e) =>
setConfig((c) => ({
...c,
maxSystemFill: e.target.valueAsNumber,
}))
}
/>
<p className="text-xs text-zinc-400">
How full a system gets before the next measure wraps to a new
line. Lower leaves more air; 1 packs each line to the edge.
</p>
</div>

<div className="flex flex-col gap-1.5">
<label
htmlFor="width"
className="flex items-center justify-between text-xs font-medium text-zinc-500"
>
Reference width
<span className="flex items-center gap-1.5">
<span className="font-mono text-zinc-400">{width}</span>
<button
type="button"
onClick={() =>
setConfig(({ layout: _, ...rest }) => rest)
}
disabled={config.layout === undefined}
aria-label="Reset width"
className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
>
<ResetIcon />
</button>
</span>
</label>
<input
id="width"
type="range"
min={400}
max={2000}
step={50}
value={width}
onChange={(e) =>
setConfig((c) => ({
...c,
layout: {
type: 'standard',
width: e.target.valueAsNumber,
},
}))
}
/>
<p className="text-xs text-zinc-400">
The width the score is engraved to; the rendering then scales
up or down to fit its container. Wider fits more measures per
system before wrapping.
</p>
</div>
</div>
</div>
</aside>
Expand All @@ -542,10 +652,15 @@ export default function App() {
)
)}
{input != null && (
// White page capped at 8.5in (US Letter). The canvas is engraved at that width
// and CSS-scaled to fit, shrinking on narrow viewports, never past 100%.
<div className="relative mx-auto w-full max-w-204 bg-white py-8 shadow-md ring-1 ring-zinc-200 sm:py-16">
<canvas ref={canvasRef} className="block" />
// The canvas is engraved at that width and CSS-scaled to fit, shrinking on narrow viewports, never past 100%.
<div
className={`relative mx-auto w-full max-w-237.5 py-8 px-4 shadow-md ring-1 sm:py-16 ${dark ? 'bg-zinc-900 ring-zinc-700' : 'bg-white ring-zinc-200'}`}
>
{/* ponytail: invert the black glyphs to light for dark mode instead of re-engraving in a light color. */}
<canvas
ref={canvasRef}
className={`block ${dark ? 'invert' : ''}`}
/>
</div>
)}
{debouncing && (
Expand Down
Loading
Loading