510 lines
15 KiB
CSS
510 lines
15 KiB
CSS
/* score-reader.css — Full-page score reader layout.
|
|
Used only on /music/{slug}/score/ pages via score-reader-default.html.
|
|
|
|
Two ideas drive the design. The sheet is the only bright object on
|
|
screen — it sits on the theme's ground with a hairline edge and a soft
|
|
shadow, reading as paper on a stand rather than as a document bleeding
|
|
into the background. And the chrome recedes: after a few seconds of
|
|
stillness the bar and the page-turn affordances fade, because a score
|
|
reader should show a score. */
|
|
|
|
.score-reader-page {
|
|
/* Bar height. The viewport is offset by exactly this much and does not
|
|
reflow when the bar fades — recomputing the fit on every idle
|
|
transition would make the page jump while being read. */
|
|
--score-chrome: 2.75rem;
|
|
--score-gutter: 1.25rem;
|
|
|
|
/* Paper. Set per theme rather than derived, because "slightly brighter
|
|
than the ground" is a different mix in a warm off-white, a near-black,
|
|
and a brown. */
|
|
--score-paper: #fffdfa;
|
|
|
|
overflow: hidden; /* the viewport scrolls, not the document */
|
|
}
|
|
|
|
[data-theme="dark"] .score-reader-page { --score-paper: #1c1c1c; }
|
|
[data-theme="cappuccino"] .score-reader-page { --score-paper: #5e4230; }
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme]) .score-reader-page { --score-paper: #1c1c1c; }
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Top bar
|
|
------------------------------------------------------------------ */
|
|
|
|
.score-reader-bar {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0 1.25rem;
|
|
height: var(--score-chrome);
|
|
background: var(--bg);
|
|
border-bottom: 1px solid var(--border);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.825rem;
|
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
}
|
|
|
|
.score-reader-bar-left {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.score-reader-movements {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
flex: 1 1 auto;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
}
|
|
.score-reader-movements::-webkit-scrollbar { display: none; }
|
|
|
|
.score-reader-bar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.score-reader-back {
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
.score-reader-back:hover { color: var(--text); }
|
|
|
|
.score-reader-mvt {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.8rem;
|
|
padding: 0.2rem 0.5rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
border-radius: 3px;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.score-reader-mvt:hover,
|
|
.score-reader-mvt.is-active {
|
|
background: var(--bg-subtle);
|
|
color: var(--text);
|
|
}
|
|
|
|
.score-reader-pdf {
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
font-size: 0.8rem;
|
|
}
|
|
.score-reader-pdf:hover { color: var(--text); }
|
|
|
|
/* Zoom group ------------------------------------------------------- */
|
|
|
|
.score-reader-zoom {
|
|
display: flex;
|
|
align-items: center;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.score-reader-zoom-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.75rem;
|
|
line-height: 1;
|
|
padding: 0.35rem 0.5rem;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.score-reader-zoom-btn + .score-reader-zoom-btn {
|
|
border-left: 1px solid var(--border);
|
|
}
|
|
.score-reader-zoom-btn:hover:not(:disabled) {
|
|
background: var(--bg-subtle);
|
|
color: var(--text);
|
|
}
|
|
.score-reader-zoom-btn:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
|
|
.score-reader-zoom-mode {
|
|
min-width: 3.6em;
|
|
font-variant-caps: all-small-caps;
|
|
letter-spacing: 0.06em;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Stage and viewport
|
|
------------------------------------------------------------------ */
|
|
|
|
.score-reader-stage {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.score-reader-viewport {
|
|
position: absolute;
|
|
top: var(--score-chrome);
|
|
left: 0; right: 0; bottom: 0;
|
|
overflow: auto;
|
|
display: flex;
|
|
padding: var(--score-gutter);
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
/* `margin: auto` rather than `justify-content: center`: it centres the
|
|
sheet while it fits, but unlike the flex property it does not clip the
|
|
leading edge once the sheet is larger than the viewport — which is the
|
|
whole point of zooming into an orchestral page. */
|
|
.score-page {
|
|
margin: auto;
|
|
flex: 0 0 auto;
|
|
position: relative;
|
|
|
|
/* Page 1's width/height, written into the element by the build. */
|
|
--ar: var(--score-aspect-num, 1.294);
|
|
aspect-ratio: var(--ar) / 1;
|
|
|
|
/* Fit-to-height without scripting. The width has to be derived from the
|
|
viewport height rather than capped with max-height: `width` would be a
|
|
definite size, so a max-height clamp shortens the box without
|
|
narrowing it, and the aspect ratio breaks instead of the box
|
|
shrinking. min() keeps a tall page from overflowing a narrow screen. */
|
|
--score-fit-width: min(100%,
|
|
calc((100vh - var(--score-chrome)
|
|
- 2 * var(--score-gutter))
|
|
* var(--ar)));
|
|
width: var(--score-fit-width);
|
|
}
|
|
|
|
/* A portrait sheet fills the width instead and scrolls, matching what the
|
|
reader's own default does once its script is running. */
|
|
.score-page[data-orient="portrait"] {
|
|
--score-fit-width: 100%;
|
|
background: var(--score-paper);
|
|
border: 1px solid var(--border);
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05),
|
|
0 10px 28px rgba(0, 0, 0, 0.06);
|
|
|
|
/* The ink colour every inlined score inherits. */
|
|
color: var(--text);
|
|
|
|
/* `fill` is an inherited SVG property, and it crosses the HTML→SVG
|
|
boundary — so any element that never declares a fill of its own
|
|
(the common case for glyph outlines) draws in the theme's ink.
|
|
`stroke` is deliberately NOT set here: its initial value is `none`,
|
|
and inheriting a stroke would outline every filled notehead. */
|
|
fill: currentColor;
|
|
}
|
|
|
|
/* Once the reader is live, zoom owns the sheet's width and the fit caps
|
|
have to come off — otherwise max-height would clamp every zoom level
|
|
back to one screenful. */
|
|
.js-ready .score-page {
|
|
/* Falls back to the same fit the stylesheet computes on its own, not to
|
|
100%. --score-page-width is only set once the first page has been
|
|
fetched and measured, and on a 1.5 MB orchestral page that is a long
|
|
gap to spend at full container width. */
|
|
width: var(--score-page-width, var(--score-fit-width));
|
|
}
|
|
|
|
/* Recolour the engraver's own values instead of rewriting them at build
|
|
time. A presentation attribute (fill="#000000") carries specificity
|
|
zero, so any author-stylesheet rule beats it — no per-engraver build
|
|
step, and nothing to change when a fourth engraver appears.
|
|
|
|
Ink and ground are mapped separately, and everything else is left
|
|
alone. Recolouring *every* fill is the obvious shortcut and it is
|
|
wrong: MuseScore opens each page with a full-bleed white path, so a
|
|
blanket rule paints an opaque sheet of ink over notation that was
|
|
themed correctly underneath it. LilyPond emits no white at all, which
|
|
is why that only shows up on a real MuseScore export.
|
|
|
|
Leaving unmatched colours untouched also preserves intentional colour —
|
|
analytical markings, cue-note grey, a coloured ossia — instead of
|
|
flattening it into the text colour. */
|
|
.score-page svg [fill="#000000" i],
|
|
.score-page svg [fill="#000" i],
|
|
.score-page svg [fill="black" i] { fill: currentColor; }
|
|
|
|
.score-page svg [stroke="#000000" i],
|
|
.score-page svg [stroke="#000" i],
|
|
.score-page svg [stroke="black" i] { stroke: currentColor; }
|
|
|
|
/* Page grounds join the sheet rather than staying paper-white in a dark
|
|
theme. `none` is untouched throughout — slurs, ties and beams need it. */
|
|
.score-page svg [fill="#ffffff" i],
|
|
.score-page svg [fill="#fff" i],
|
|
.score-page svg [fill="white" i] { fill: var(--score-paper); }
|
|
|
|
.score-page svg {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Scripting-off fallback. Renders in the engraver's own colours, which is
|
|
an honest degradation — legible in any theme, if not native to it. */
|
|
.score-page-fallback {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
}
|
|
|
|
.score-page.is-loading {
|
|
opacity: 0.35;
|
|
transition: opacity 0.2s ease 0.15s;
|
|
}
|
|
|
|
.score-page-error {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
margin: 0;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
font-family: var(--font-sans);
|
|
font-size: 0.85rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Page turning
|
|
------------------------------------------------------------------ */
|
|
|
|
.score-reader-nav {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
z-index: 50;
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 50%;
|
|
color: var(--text-muted);
|
|
font-size: 1.15rem;
|
|
cursor: pointer;
|
|
transition: color 0.15s, border-color 0.15s, opacity 0.4s ease;
|
|
}
|
|
.score-reader-nav:hover:not(:disabled) {
|
|
color: var(--text);
|
|
border-color: var(--text-muted);
|
|
}
|
|
.score-reader-nav:disabled {
|
|
opacity: 0.25;
|
|
cursor: default;
|
|
}
|
|
|
|
.score-reader-prev { left: 0.85rem; }
|
|
.score-reader-next { right: 0.85rem; }
|
|
|
|
/* Folio — the page number as it would be printed on the sheet, rather
|
|
than as a counter chip in the corner of an application. */
|
|
.score-folio {
|
|
position: absolute;
|
|
bottom: 0.7rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 50;
|
|
font-family: var(--font-sans);
|
|
font-size: 0.72rem;
|
|
font-variant-caps: all-small-caps;
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0.07em;
|
|
color: var(--text-faint);
|
|
pointer-events: none;
|
|
transition: opacity 0.4s ease;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Instrument-label gutter
|
|
|
|
A second copy of the page, clipped to its label column and translated
|
|
to sit against the viewport's left edge, so the instrument names stay
|
|
readable once the real ones have been panned off. It scrolls
|
|
vertically with the page — it is positioned inside the sheet, not
|
|
inside the viewport — which is what keeps each name beside its own
|
|
staff.
|
|
------------------------------------------------------------------ */
|
|
|
|
.score-gutter {
|
|
position: absolute;
|
|
left: 0; top: 0; bottom: 0;
|
|
z-index: 2;
|
|
width: var(--score-gutter-w, 0px);
|
|
overflow: hidden;
|
|
background: var(--score-paper);
|
|
border-right: 1px solid var(--border);
|
|
box-shadow: 3px 0 8px rgba(0, 0, 0, 0.07);
|
|
|
|
/* The page underneath owns every drag, swipe and click. */
|
|
pointer-events: none;
|
|
|
|
opacity: 0;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
.score-gutter.is-visible { opacity: 1; }
|
|
|
|
/* Full page width inside the clip, so the copy lines up with the original
|
|
rather than being squashed into the gutter. The width is set from the
|
|
measured page box in updateGutter(); this is only the starting value. */
|
|
.score-gutter-inner {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.score-gutter-inner svg {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Zoomed past the viewport, the folio is over the staves rather than in
|
|
the page's bottom margin, so it takes a ground of its own. */
|
|
.is-panning .score-folio {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
padding: 0.1rem 0.55rem;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Idle — chrome recedes
|
|
------------------------------------------------------------------ */
|
|
|
|
.score-reader-page.is-idle .score-reader-bar {
|
|
opacity: 0;
|
|
transform: translateY(-100%);
|
|
pointer-events: none;
|
|
}
|
|
.score-reader-page.is-idle .score-reader-nav,
|
|
.score-reader-page.is-idle .score-folio {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Narrow screens
|
|
------------------------------------------------------------------ */
|
|
|
|
@media (max-width: 640px) {
|
|
.score-reader-page { --score-gutter: 0.5rem; }
|
|
|
|
/* Swipe replaces them; at this width they would sit on the music. */
|
|
.score-reader-nav { display: none; }
|
|
|
|
.score-reader-zoom { display: none; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.score-reader-bar,
|
|
.score-reader-nav,
|
|
.score-folio,
|
|
.score-gutter,
|
|
.score-page.is-loading {
|
|
transition: none;
|
|
}
|
|
.score-reader-page.is-idle .score-reader-bar {
|
|
transform: none;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Composition landing page additions (metadata block)
|
|
------------------------------------------------------------------ */
|
|
|
|
.composition-details {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0 1.5rem;
|
|
color: var(--text-muted);
|
|
font-family: var(--font-sans);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.comp-detail { white-space: nowrap; }
|
|
|
|
.composition-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.comp-btn {
|
|
display: inline-block;
|
|
padding: 0.35em 0.9em;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
font-family: var(--font-sans);
|
|
font-size: 0.825rem;
|
|
text-decoration: none;
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
transition: background 0.15s, border-color 0.15s;
|
|
}
|
|
.comp-btn:hover {
|
|
background: var(--bg-subtle);
|
|
border-color: var(--text-muted);
|
|
}
|
|
.comp-btn--secondary { color: var(--text-muted); }
|
|
|
|
/* ------------------------------------------------------------------
|
|
Movement list
|
|
------------------------------------------------------------------ */
|
|
|
|
.composition-movements {
|
|
border-top: 1px solid var(--border);
|
|
margin: 1.5rem 0;
|
|
padding-top: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.comp-movement-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
font-family: var(--font-sans);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.comp-movement-name {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
|
|
.comp-movement-duration {
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.comp-movement-score {
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
text-decoration: none;
|
|
margin-left: auto;
|
|
}
|
|
.comp-movement-score:hover { color: var(--text); }
|
|
|
|
.movement-audio {
|
|
width: 100%;
|
|
margin-top: 0.4rem;
|
|
accent-color: var(--text-muted);
|
|
}
|