levineuwirth.org/static/css/score-reader.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);
}