@ugfoundation/swaralipi-js 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,9 @@ interface Arc {
116
116
  /** 0-based index of the arc's first / last **cell** within the row. */
117
117
  start: number;
118
118
  end: number;
119
+ /** Index, within that cell's notes, of the note the arc hangs from / into. */
120
+ startNote: number;
121
+ endNote: number;
119
122
  /** True when the matching end lies on a previous / following system. */
120
123
  openLeft: boolean;
121
124
  openRight: boolean;
@@ -123,11 +126,14 @@ interface Arc {
123
126
  line?: boolean;
124
127
  }
125
128
  /**
126
- * Resolve under-arcs for one row, in **cell** indices (cells are the grid's
127
- * columns). Sources, scanned left→right:
128
- * - below-annotation raws: `w` opens at its cell, `x` closes at its cell
129
- * ("xw" in one cell = close-then-open, in the order typed);
130
- * - spec `meend: 'start' | 'end'` fields feed the same scan.
129
+ * Resolve under-arcs for one row down to the **note** each end hangs from, so a
130
+ * slur inside one half-matra cell (মজ্ঞা) still runs centre to centre. Sources,
131
+ * scanned left→right:
132
+ * - spec `meend: 'start' | 'end'` — exact notes;
133
+ * - below-annotation raws, which belong to the whole cell (NLTR types them
134
+ * centred under the column): `w` opens on the cell's first sounded note,
135
+ * `x` closes on its last, `$` (Swarabitan's short in-cell arc) runs first to
136
+ * last, and `xw` — close, then re-open — meets in the middle instead.
131
137
  * Unmatched `x` → arc from cell 0 with a squared-off (open) left end;
132
138
  * unclosed `w` → arc to the last cell with an open right end — both match
133
139
  * the print's behavior when an arc continues across a system wrap.
@@ -116,6 +116,9 @@ interface Arc {
116
116
  /** 0-based index of the arc's first / last **cell** within the row. */
117
117
  start: number;
118
118
  end: number;
119
+ /** Index, within that cell's notes, of the note the arc hangs from / into. */
120
+ startNote: number;
121
+ endNote: number;
119
122
  /** True when the matching end lies on a previous / following system. */
120
123
  openLeft: boolean;
121
124
  openRight: boolean;
@@ -123,11 +126,14 @@ interface Arc {
123
126
  line?: boolean;
124
127
  }
125
128
  /**
126
- * Resolve under-arcs for one row, in **cell** indices (cells are the grid's
127
- * columns). Sources, scanned left→right:
128
- * - below-annotation raws: `w` opens at its cell, `x` closes at its cell
129
- * ("xw" in one cell = close-then-open, in the order typed);
130
- * - spec `meend: 'start' | 'end'` fields feed the same scan.
129
+ * Resolve under-arcs for one row down to the **note** each end hangs from, so a
130
+ * slur inside one half-matra cell (মজ্ঞা) still runs centre to centre. Sources,
131
+ * scanned left→right:
132
+ * - spec `meend: 'start' | 'end'` — exact notes;
133
+ * - below-annotation raws, which belong to the whole cell (NLTR types them
134
+ * centred under the column): `w` opens on the cell's first sounded note,
135
+ * `x` closes on its last, `$` (Swarabitan's short in-cell arc) runs first to
136
+ * last, and `xw` — close, then re-open — meets in the middle instead.
131
137
  * Unmatched `x` → arc from cell 0 with a squared-off (open) left end;
132
138
  * unclosed `w` → arc to the last cell with an open right end — both match
133
139
  * the print's behavior when an arc continues across a system wrap.
@@ -194,42 +194,64 @@ function alignRows(rows) {
194
194
  }
195
195
  function resolveArcs(cells) {
196
196
  const arcs = [];
197
- let openStart;
197
+ let open;
198
198
  let sawEnds = false;
199
199
  const fillCells = [];
200
- const openAt = (i) => {
200
+ const openAt = (cell, note) => {
201
201
  sawEnds = true;
202
- if (openStart === void 0) openStart = i;
202
+ open ??= { cell, note };
203
203
  };
204
- const closeAt = (i) => {
204
+ const closeAt = (cell, note) => {
205
205
  sawEnds = true;
206
- if (openStart !== void 0) {
207
- arcs.push({ start: openStart, end: i, openLeft: false, openRight: false });
208
- openStart = void 0;
209
- } else {
210
- arcs.push({ start: 0, end: i, openLeft: true, openRight: false });
211
- }
206
+ const from = open ?? { cell: 0, note: 0 };
207
+ arcs.push({
208
+ start: from.cell,
209
+ startNote: from.note,
210
+ end: cell,
211
+ endNote: note,
212
+ openLeft: open === void 0,
213
+ openRight: false
214
+ });
215
+ open = void 0;
212
216
  };
213
217
  cells.forEach((cell, i) => {
214
- for (const note of cell.notes) {
215
- if (note.meend === "start") openAt(i);
216
- else if (note.meend === "end") closeAt(i);
217
- for (const b of note.below ?? []) {
218
- if (!isArcRaw(b.raw)) continue;
219
- for (const ch of b.raw) {
220
- if (ch === "w" || ch === "W") openAt(i);
221
- else if (ch === "x" || ch === "X") closeAt(i);
222
- else fillCells.push(i);
223
- }
224
- }
218
+ const pitched = cell.notes.flatMap((n, j) => isPitched(n) ? [j] : []);
219
+ const first = pitched[0] ?? 0;
220
+ const last = pitched[pitched.length - 1] ?? cell.notes.length - 1;
221
+ let marks = "";
222
+ cell.notes.forEach((note, j) => {
223
+ if (note.meend === "start") openAt(i, j);
224
+ else if (note.meend === "end") closeAt(i, j);
225
+ for (const b of note.below ?? []) if (isArcRaw(b.raw)) marks += b.raw;
226
+ });
227
+ const reopens = /[xX].*[wW]/.test(marks);
228
+ for (const ch of marks) {
229
+ if (ch === "w" || ch === "W") openAt(i, reopens ? last : first);
230
+ else if (ch === "x" || ch === "X") closeAt(i, reopens ? first : last);
231
+ else if (ch === "$") {
232
+ openAt(i, first);
233
+ closeAt(i, last);
234
+ } else fillCells.push(i);
225
235
  }
226
236
  });
227
- if (openStart !== void 0) {
228
- arcs.push({ start: openStart, end: Math.max(cells.length - 1, 0), openLeft: false, openRight: true });
237
+ const lastCell = Math.max(cells.length - 1, 0);
238
+ const lastNote = (c) => Math.max((cells[c]?.notes.length ?? 1) - 1, 0);
239
+ if (open !== void 0) {
240
+ arcs.push({
241
+ start: open.cell,
242
+ startNote: open.note,
243
+ end: lastCell,
244
+ endNote: lastNote(lastCell),
245
+ openLeft: false,
246
+ openRight: true
247
+ });
229
248
  } else if (!sawEnds && fillCells.length > 0) {
249
+ const end = Math.max(...fillCells);
230
250
  arcs.push({
231
251
  start: Math.min(...fillCells),
232
- end: Math.max(...fillCells),
252
+ startNote: 0,
253
+ end,
254
+ endNote: lastNote(end),
233
255
  openLeft: true,
234
256
  openRight: true,
235
257
  line: true
@@ -237,6 +259,35 @@ function resolveArcs(cells) {
237
259
  }
238
260
  return arcs;
239
261
  }
262
+ function arcInk(cells, arcs) {
263
+ const ink = { cells: cells.map(() => ""), notes: cells.map((c) => c.notes.map(() => "")) };
264
+ const mark = (c, j, cls, clip = false) => {
265
+ ink.notes[c][j] += ` ${cls}`;
266
+ if (clip && !ink.cells[c].includes(" sw-mk")) ink.cells[c] += " sw-mk";
267
+ };
268
+ for (const arc of arcs) {
269
+ const { start: s, end: e, startNote: j, endNote: k } = arc;
270
+ const hookL = arc.line !== true && !arc.openLeft;
271
+ const hookR = arc.line !== true && !arc.openRight;
272
+ if (s === e && hookL && hookR) {
273
+ if (j === k) {
274
+ mark(s, j, "sw-mos");
275
+ mark(s, j, "sw-mcs");
276
+ } else {
277
+ mark(s, j, "sw-mob");
278
+ for (let n = j + 1; n < k; n++) mark(s, n, "sw-mf");
279
+ mark(s, k, "sw-mcb");
280
+ }
281
+ continue;
282
+ }
283
+ for (let c = s; c <= e; c++) {
284
+ if (c === s && hookL) mark(c, j, "sw-mo", true);
285
+ else if (c === e && hookR) mark(c, k, "sw-mc", true);
286
+ else mark(c, 0, "sw-mm", true);
287
+ }
288
+ }
289
+ return ink;
290
+ }
240
291
  var SVARA_CHAR = /[srgmpqndtkuv]/;
241
292
  function rawMarks(raw, count) {
242
293
  if (raw === void 0) return void 0;
@@ -412,25 +463,9 @@ function renderNote(note, kind, opts, ctx) {
412
463
  const raw = rawDisplay(note);
413
464
  return bracketGlyphs(note, "open") + (raw !== void 0 ? `<span class="sw-raw" title="${escapeHtml(raw)}">${escapeHtml(raw)}</span>` : "") + bracketGlyphs(note, "close");
414
465
  }
415
- function renderArc(arc, offset) {
416
- const open = arc.line === true;
417
- const openL = arc.openLeft || open;
418
- const openR = arc.openRight || open;
419
- const fill = '<i class="sw-arc-m"></i>';
420
- const hookL = openL ? "" : '<i class="sw-arc-l">w</i>';
421
- const hookR = openR ? "" : '<i class="sw-arc-r">x</i>';
422
- const piece = (col, cls, inner) => `<span class="sw-arc${cls}" style="grid-column:${col}">${inner}</span>`;
423
- const f = offset + arc.start + COL_BASE;
424
- const t = offset + arc.end + COL_BASE;
425
- const openCls = (openL ? " sw-arc--openl" : "") + (openR ? " sw-arc--openr" : "");
426
- if (t === f) return piece(`${f}`, openCls, hookL + fill + hookR);
427
- const head = piece(`${f}`, openL ? " sw-arc--openl" : " sw-arc--head", hookL + fill);
428
- const tail = piece(`${t}`, openR ? " sw-arc--openr" : " sw-arc--tail", fill + hookR);
429
- const mid = t > f + 1 ? piece(`${f + 1} / ${t}`, " sw-arc--mid", fill) : "";
430
- return head + mid + tail;
431
- }
432
466
  function renderRow(row, partIdx, rowIdx, offset, ctx) {
433
467
  const cells = groupRowIntoCells(row.notes);
468
+ const ink = arcInk(cells, resolveArcs(cells));
434
469
  const out = [];
435
470
  if (row.label !== void 0)
436
471
  out.push(`<span class="sw-label">${escapeHtml(ctx.tr(row.label))}</span>`);
@@ -440,11 +475,10 @@ function renderRow(row, partIdx, rowIdx, offset, ctx) {
440
475
  const barCls = cell.kind === "bar" ? " sw-bar-cell" : "";
441
476
  const notes = cell.notes.map((note, j) => {
442
477
  const path = `${partIdx}.${rowIdx}.${cell.start + j}`;
443
- const cls = isPitched(note) ? " sw-p" : "";
444
478
  const bar = note.bar !== void 0 ? ` sw-bar sw-bar--${note.bar}` : "";
445
- return `<span class="sw-n${cls}${bar}" data-note="${path}">${renderNote(note, cell.kind, opts[j], ctx)}</span>`;
479
+ return `<span class="sw-n${bar}${ink.notes[ci][j]}" data-note="${path}">${renderNote(note, cell.kind, opts[j], ctx)}</span>`;
446
480
  }).join("");
447
- out.push(`<span class="sw-cell${barCls}" style="grid-column:${col}">${notes}</span>`);
481
+ out.push(`<span class="sw-cell${barCls}${ink.cells[ci]}" style="grid-column:${col}">${notes}</span>`);
448
482
  const slices = cellLyricSlices(cell);
449
483
  if (slices !== void 0) {
450
484
  const html = slices.map((t) => `<span>${escapeHtml(lyricText(t, ctx))}</span>`).join("");
@@ -457,7 +491,6 @@ function renderRow(row, partIdx, rowIdx, offset, ctx) {
457
491
  if (below.length > 0)
458
492
  out.push(`<span class="sw-below" style="grid-column:${col}">${renderAnnotation(below, ctx)}</span>`);
459
493
  });
460
- for (const arc of resolveArcs(cells)) out.push(renderArc(arc, offset));
461
494
  return `<div class="sw-row">${out.join("")}</div>`;
462
495
  }
463
496
  function renderHeader(meta, ctx) {
@@ -515,7 +548,7 @@ function fitSwaralipi(root, minScale = 0, zoom = 1, minSpacing = 0.35, minSize =
515
548
  sheet.style.removeProperty("--sw-size");
516
549
  sheet.style.removeProperty("--sw-tight");
517
550
  const available = scroll.clientWidth;
518
- let natural = block.scrollWidth;
551
+ let natural = block.offsetWidth;
519
552
  if (available === 0 || natural === 0) continue;
520
553
  if (natural > available && minSpacing < 1) {
521
554
  let loose = minSpacing;
@@ -523,11 +556,11 @@ function fitSwaralipi(root, minScale = 0, zoom = 1, minSpacing = 0.35, minSize =
523
556
  for (let i = 0; i < 6; i++) {
524
557
  const mid = (loose + tight) / 2;
525
558
  sheet.style.setProperty("--sw-tight", String(mid));
526
- if (block.scrollWidth <= available) loose = mid;
559
+ if (block.offsetWidth <= available) loose = mid;
527
560
  else tight = mid;
528
561
  }
529
562
  sheet.style.setProperty("--sw-tight", String(loose));
530
- natural = block.scrollWidth;
563
+ natural = block.offsetWidth;
531
564
  }
532
565
  const base = parseFloat(getComputedStyle(sheet).fontSize);
533
566
  if (!(base > 0)) continue;
@@ -625,31 +658,45 @@ ${FONT_FACES}
625
658
  column-gap: calc(var(--sw-cell-gap, 0.4em) * var(--sw-tight, 1));
626
659
  align-items: end;
627
660
  width: max-content;
661
+ /* The last column's cell spacers overhang half a gap past the grid; clip
662
+ that sideways so it neither counts as width to fit nor raises a scrollbar. */
663
+ overflow-x: clip;
628
664
  }
629
665
  .sw-part { display: contents; }
666
+ /* Four FIXED bands per row \u2014 above / notation / arc / lyric \u2014 so a beat label,
667
+ * a \u0995\u09A3 or a \u09AE\u09C0\u09A1\u09BC never changes how far a line sits from its words: the
668
+ * notation hugs its lyric and the next system starts a little further off,
669
+ * as the printed \u09B8\u09CD\u09AC\u09B0\u09AC\u09BF\u09A4\u09BE\u09A8 sets it. Content taller than its band overflows
670
+ * rather than pushing the rhythm apart. */
630
671
  .sw-row {
631
672
  grid-column: 1 / -1;
632
673
  display: grid;
633
674
  grid-template-columns: subgrid;
634
- grid-template-rows: auto auto auto auto;
675
+ grid-template-rows: 0.95em 1.35em 0.4em 1.35em;
635
676
  align-items: end;
636
- padding: 0.4em 0;
677
+ padding-bottom: 0.35em;
678
+ line-height: 1.35;
637
679
  }
638
680
  .sw-label { grid-column: 1; grid-row: 2; padding-right: 0.5em; font-weight: 600; }
639
681
 
640
- /* One column per cell; sub-notes are equal slices of it (min-width:0 kills
641
- * flex's min-content floor, else a wide sub-note steals width from its
642
- * neighbours while the syllable below it does not, and the two bands drift). */
643
- .sw-cell, .sw-lyr { display: flex; justify-content: center; }
644
- .sw-cell > .sw-n { white-space: nowrap; }
645
- .sw-cell > .sw-n.sw-p,
646
- .sw-lyr > span { flex: 1 1 0; min-width: 0; text-align: center; white-space: nowrap; }
647
- .sw-cell { grid-row: 2; }
648
- .sw-above { grid-row: 1; font-size: 0.62em; justify-self: center; white-space: nowrap; }
649
- .sw-arc,
650
- .sw-below { grid-row: 3; }
651
- .sw-lyr { grid-row: 4; padding-top: 0.3em; }
652
- .sw-below { font-size: 0.62em; justify-self: center; white-space: nowrap; }
682
+ /* One column per cell, its notes set tight as one word (\u09B0\u09B8\u09BE) and centred by
683
+ * two flex spacers. Each spacer overhangs half the column gap (a min-width
684
+ * paid back by a negative margin, so the column is sized by the notes alone),
685
+ * which lets neighbouring cells' spacers meet mid-gap: a \u09AE\u09C0\u09A1\u09BC crossing cells is
686
+ * inked by these boxes with no hole between them. */
687
+ .sw-cell, .sw-lyr { display: flex; justify-content: center; white-space: nowrap; }
688
+ .sw-cell {
689
+ --sw-half-gap: calc(var(--sw-cell-gap, 0.4em) * var(--sw-tight, 1) / 2);
690
+ grid-row: 2;
691
+ align-self: stretch;
692
+ }
693
+ .sw-cell::before, .sw-cell::after { content: ''; flex: 1 0 0; min-width: var(--sw-half-gap); }
694
+ .sw-cell::before { margin-left: calc(var(--sw-half-gap) * -1); }
695
+ .sw-cell::after { margin-right: calc(var(--sw-half-gap) * -1); }
696
+ .sw-cell > .sw-n { position: relative; }
697
+ .sw-above { grid-row: 1; font-size: 0.62em; line-height: 1; justify-self: center; white-space: nowrap; }
698
+ .sw-below { grid-row: 3; font-size: 0.62em; line-height: 1; justify-self: center; white-space: nowrap; }
699
+ .sw-lyr { grid-row: 4; }
653
700
 
654
701
  /* ---- notation clusters & marks ---- */
655
702
  .sw-cl { position: relative; display: inline-block; }
@@ -670,12 +717,16 @@ ${FONT_FACES}
670
717
  .sw-ati-taar::before { top: -0.3em; }
671
718
  .sw-ati-mandra::before { bottom: -0.35em; }
672
719
 
673
- /* grace notes: small raised clusters \u2014 \u0995\u09A3 before the note, \u09B0\u09C7\u09B6 after it */
720
+ /* grace notes: small raised clusters \u2014 \u0995\u09A3 before the note, \u09B0\u09C7\u09B6 after it.
721
+ * line-height 0 (here and on .sw-micro): a raised inline box taller than the
722
+ * strut grows its line box and drops that one note's baseline below its
723
+ * neighbours' (css2 \xA710.8). */
674
724
  .sw-kan,
675
725
  .sw-resh {
676
726
  position: relative;
677
727
  display: inline-block;
678
728
  font-size: 0.58em;
729
+ line-height: 0;
679
730
  vertical-align: 0.75em;
680
731
  }
681
732
  .sw-kan { margin-right: 0.08em; }
@@ -707,7 +758,7 @@ ${FONT_FACES}
707
758
  .sw-cut::before { top: 0.2em; }
708
759
  .sw-cut::after { top: 0.55em; }
709
760
  .sw-br { opacity: 0.8; padding: 0 0.06em; }
710
- .sw-micro { font-size: 0.6em; vertical-align: super; opacity: 0.8; }
761
+ .sw-micro { font-size: 0.6em; line-height: 0; vertical-align: super; opacity: 0.8; }
711
762
 
712
763
  /* raw fallback: never dropped, visibly non-notation. Swarabitan first \u2014
713
764
  * raw IS that font's encoding, so undecoded cells show NLTR's own glyphs
@@ -727,49 +778,46 @@ ${FONT_FACES}
727
778
  /* a bar owns a whole column but needs no matra of width */
728
779
  .sw-bar-cell { min-width: 0; }
729
780
 
730
- /* ---- \u09AE\u09C0\u09A1\u09BC under-arcs (from w/x segments and spec meend) ----
731
- * Swarabitan draws the arc as three pieces \u2014 'w' the opening hook, 'y' a
732
- * flat fill, 'x' the closing hook. We use the font's own hooks and stretch
733
- * the fill as a CSS bar (the 'y' glyph IS a plain rectangle), so one arc
734
- * spans any number of columns. An open side = the arc continues onto the
735
- * next system: drop that hook and leave the fill squared off. */
736
- .sw-arc {
737
- display: flex;
738
- align-items: baseline;
739
- line-height: 1;
740
- font-family: Swarabitan, var(--sw-font, sans-serif);
741
- /* The 0.9em sits on the children, not here: the end pieces below size
742
- themselves off the block's own column-gap, which is in the block's em. */
743
- }
744
- .sw-arc > i, .sw-arc::before { font-size: 0.9em; }
745
- .sw-arc-l, .sw-arc-r { flex: none; font-style: normal; }
746
- /* Every piece needs the same baseline \u2014 the fill rides it, so a piece holding
747
- only fill would otherwise drop to the bottom of the row. A zero-width copy
748
- of the hook glyph is the strut (same font, same metrics). */
749
- .sw-arc::before { content: 'w'; flex: none; width: 0; overflow: hidden; }
750
- /* The ends hang from the two notes' CENTRES, as the print sets them, so the
751
- arc is three grid items: each end piece takes half of its own column plus
752
- the column-gap it has to bridge, and the negative margin on the same side
753
- lets that bridge overhang the grid area instead of shifting the piece. */
754
- .sw-arc--head, .sw-arc--tail {
755
- --sw-arc-gap: calc(var(--sw-cell-gap, 0.4em) * var(--sw-tight, 1));
756
- width: calc(50% + var(--sw-arc-gap));
757
- }
758
- .sw-arc--head { justify-self: end; margin-right: calc(var(--sw-arc-gap) * -1); }
759
- .sw-arc--tail { justify-self: start; margin-left: calc(var(--sw-arc-gap) * -1);
760
- justify-content: flex-end; }
761
- /* The fill's geometry is measured off the font's own 'y' glyph: ink 0.11em
762
- tall whose bottom sits 0.01em above the baseline. An empty inline-block
763
- takes its bottom margin edge as its baseline, so -0.01em lifts the bar to
764
- exactly where the hooks' flat stroke runs \u2014 no seam at either join. */
765
- .sw-arc-m {
766
- flex: 1 1 0;
767
- min-width: 0;
768
- align-self: baseline;
769
- height: 0.11em;
770
- margin-bottom: -0.01em;
771
- background: currentColor;
772
- }
781
+ /* ---- \u09AE\u09C0\u09A1\u09BC under-arcs (from w/x/$ segments and spec meend) ----
782
+ * Swarabitan's own arc, all langs: w is the opening hook, curling down from
783
+ * its tip into a flat, x its mirror, y that flat alone \u2014 and they join
784
+ * hole-free, so one run of text is a whole end (tools/align-check/glyph.html).
785
+ * Each end sets its hook tip on its note's centre and runs the flat toward the
786
+ * other end (render.ts arcInk): across cells it is cut at the mid-gap, where
787
+ * the next cell's run carries on (.sw-mk clips a cell half a gap past each
788
+ * side, as far as its spacers reach); inside one cell, at its note's edge. So
789
+ * a long arc extends the flat and a short one trims it off. line-height 0 sets
790
+ * every run's baseline the same distance below its note. The clip reaches
791
+ * 0.5px further so two cells' anti-aliased edges overlap instead of leaving a
792
+ * hairline seam between their identical flats. */
793
+ .sw-mk { clip-path: inset(-3em calc(var(--sw-half-gap) * -1 - 0.5px)); }
794
+ .sw-mo::after, .sw-mob::after, .sw-mos::after,
795
+ .sw-mc::before, .sw-mcb::before, .sw-mcs::before,
796
+ .sw-mf::before, .sw-mm::before {
797
+ content: 'yyyyyyyyyyyy';
798
+ position: absolute;
799
+ top: calc(100% + 0.05em);
800
+ font-family: Swarabitan;
801
+ font-size: 0.9em;
802
+ line-height: 0;
803
+ white-space: pre;
804
+ overflow-x: clip;
805
+ }
806
+ .sw-mo::after, .sw-mob::after, .sw-mos::after { content: 'wyyyyyyyyyyyy'; }
807
+ /* rtl: a closing run cut short keeps its hook on its right edge */
808
+ .sw-mc::before, .sw-mcb::before, .sw-mcs::before { content: 'yyyyyyyyyyyyx'; direction: rtl; }
809
+ .sw-mo::after, .sw-mob::after { left: 50%; }
810
+ .sw-mc::before, .sw-mcb::before { right: 50%; }
811
+ .sw-mos::after { left: 0; }
812
+ .sw-mcs::before { right: 0; }
813
+ .sw-mf::before { left: 0; right: 0; }
814
+ .sw-mm::before { left: -12em; }
815
+ /* Inside one cell a hook gets half a note to curl in, about a third of its
816
+ * curl: squeezed to half width (same stroke) it lands flat at the cut, so the
817
+ * two flats meet level whatever the notes' widths \u2014 a small arc, not a V. */
818
+ .sw-mob::after, .sw-mos::after, .sw-mcb::before, .sw-mcs::before { width: 100%; transform: scaleX(0.5); }
819
+ .sw-mob::after, .sw-mos::after { transform-origin: left; }
820
+ .sw-mcb::before, .sw-mcs::before { transform-origin: right; }
773
821
 
774
822
  /* ---- annotation content ---- */
775
823
  .sw-beat { position: relative; display: inline-block; padding: 0 0.1em; }