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
101 changes: 61 additions & 40 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ input, textarea { font-family: inherit; }

/* ── Root ── */
.daw {
/* The rail's width, as a variable: the topbar's logo cell is sized from the
same number, so the divider under the logo is the one the rail already
draws, and the bar proper starts where the library does. */
--rail-w: 66px;
width: 100vw;
height: 100vh;
background: var(--bg);
Expand Down Expand Up @@ -58,36 +62,54 @@ input, textarea { font-family: inherit; }
inside a 77px bar, because align-items: center hid the gap; give the
composer a second row and the box fills the bar edge to edge and touches
the top of the window. */
padding: 10px 16px;
/* No left padding and no vertical padding of its own any more: the brand
cell owns the left edge and runs the full height of the bar, so the
vertical padding moved onto the composer stack beside it. */
padding: 0 16px 0 0;
gap: 14px;
}
.daw-topbar > .daw-composer-stack { padding: 10px 0; }

/* Brand */
/* Logo cell.
*
* The top-left corner, exactly as wide as the rail below it and closed by the
* same border, so the bar proper begins where the library does. It was the
* full 390px of the library at first, which put 330px of empty bar beside the
* logo and cost the composer the width it was short of. The rail's width is
* the part that has something in it, and it does not move when the library
* collapses, so the cell needs no transition of its own. */
.daw-brand {
align-self: stretch;
box-sizing: border-box;
width: var(--rail-w);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 9px;
flex-shrink: 0;
}
.daw-brand img { height: 30px; width: auto; display: block; }
.daw-brand-name {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1;
border-right: 1px solid var(--border);
overflow: hidden;
}
.daw-brand-name .fg { color: var(--fg); }
.daw-brand-name .accent{ color: var(--accent); }

.daw-version {
display: none !important; /* version shown in notification panel instead */
/* Lined up with the rail buttons below it, which means lined up by the bars,
not by the file. The symbol's artboard is not centred on its own artwork:
the bars run x110-x452 of a 512 box, so their middle sits 25 units right of
the middle of the image. Centring the image would therefore push the bars
2.5px right of the rail's centre line, which is exactly what it looked like.
4px is what puts the bars' middle on 32.5px, the centre of the 65px cell.
52px, not the 40px of a rail button: that same padding means the bars
themselves land at about 35px. */
/* The logo is the link, so the anchor has to be the flex item the cell
centres, not an inline box that would add a line box's worth of height
around a 52px image. */
.daw-brand a {
display: flex;
align-items: center;
text-decoration: none;
}

/* Topbar separator */
.daw-sep {
width: 1px;
height: 28px;
background: var(--border);
.daw-brand img {
width: 52px;
height: 52px;
margin-left: 4px;
margin-right: auto;
display: block;
flex-shrink: 0;
}

Expand Down Expand Up @@ -310,15 +332,14 @@ input, textarea { font-family: inherit; }
color: var(--accent);
}

/* Vocals: All / Lead + Backing (on-demand split, #275) */
/* Vocals: Combined / Lead + Backing (on-demand split, #275) */
.vocal-mode-toggle {
display: inline-flex;
height: 30px;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.vocal-mode-toggle.hidden { display: none; }
.vocal-mode-btn {
height: 100%;
padding: 0 10px;
Expand Down Expand Up @@ -519,7 +540,7 @@ input, textarea { font-family: inherit; }

/* Rail */
.sidebar-rail {
width: 66px;
width: var(--rail-w);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -1195,7 +1216,7 @@ input, textarea { font-family: inherit; }

/* Collapsed sidebar strip */
.cat-collapsed-strip { display: none; }
.daw.cat-collapsed .sidebar { width: 66px; }
.daw.cat-collapsed .sidebar { width: var(--rail-w); }
.daw.cat-collapsed .sidebar-body { opacity: 0; pointer-events: none; visibility: hidden; }
.daw.cat-collapsed .cat-collapsed-strip { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 8px 0; overflow-y: auto; }

Expand Down Expand Up @@ -3171,7 +3192,7 @@ input, textarea { font-family: inherit; }
display: block;
visibility: hidden;
}
.metro-note:empty::before { content: "a0"; }
.metro-note:empty::before { content: "\00a0"; }
.metro-note.warn { color: var(--danger); }

/* Export chip button */
Expand Down Expand Up @@ -4260,17 +4281,20 @@ input, textarea { font-family: inherit; }
}

/* Row two, column one. This is what sizes the column, and therefore the search
box above it. The chips wrap rather than forcing the row wider than the bar:
at max-content they are 655px, which on their own overflow a 1100px window
once the two actions have been paid for. */
box above it. min-width: 0 is what lets it be narrower than the chips inside
it: at max-content they are 655px, which on their own overflow a 1100px
window once the two actions have been paid for.

The chips used to be allowed to wrap here, which is how that overflow was
absorbed before the row could fold. It cannot be allowed now: the rule was
three classes deep and quietly outranked the two-class `flex-wrap: nowrap`
the folding row declares for itself, so the row kept wrapping to a second
line in any language whose words are longer than English (#673). */
.daw-composer > .daw-stem-section {
grid-column: 1;
grid-row: 2;
min-width: 0;
}
.daw-composer > .daw-stem-section .daw-stem-chips {
flex-wrap: wrap;
}

/* The rule stays, but it now separates the search from the two actions on the
same line, which is what it does in the mockup. It used to divide the row
Expand Down Expand Up @@ -4715,11 +4739,8 @@ input, textarea { font-family: inherit; }
gap: 0;
margin-right: 5px;
}
.stem-group:not(:has(.vocal-mode-toggle:not(.hidden))) {
margin-right: 0;
}
/* Facing edges square, and only one border between them. */
.stem-group:has(.vocal-mode-toggle:not(.hidden)) .stem-choice[data-stem="vocals"] {
.stem-group .stem-choice[data-stem="vocals"] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
Expand All @@ -4739,9 +4760,9 @@ input, textarea { font-family: inherit; }
* selected, the active mode is tinted because it is the mode, and the other
* one is plain.
*
* Keyed off the chip's pressed state, because the toggle is only on screen
* while Vocals is selected and should not be left as the one thing still
* wearing the colour if that ever changes. */
* Keyed off the chip's pressed state rather than on the toggle being present,
* because it is always present: with Vocals off, the group drops back to the
* plain border and the toggle dims beside it. */
.stem-group:has(.stem-choice[data-stem="vocals"][aria-pressed="true"]) .vocal-mode-toggle {
--color: var(--vocals);
border-color: color-mix(in srgb, var(--color) 60%, transparent);
Expand Down
22 changes: 22 additions & 0 deletions static/img/stemdeck-waveform-symbol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 28 additions & 13 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,21 @@

<!-- ══ TOPBAR ══ -->
<div class="daw-topbar">
<!-- Brand -->
<!-- Brand. Sized to the rail's width, so the bar starts where the
library does and the divider is the rail's own. -->
<div class="daw-brand">
<span class="daw-brand-name">
<span class="fg">Stem</span><span class="accent">Deck</span>
</span>
<!-- An anchor rather than a click handler: the document-level
a[target="_blank"] listener in main.js already sends these to the
system browser through Tauri's open_url on the desktop, and leaves
them to the browser everywhere else. A handler here would have to
repeat that, and would get the desktop case wrong by opening the
site inside the app's own window. -->
<a href="https://stemdeck.app" target="_blank" rel="noopener noreferrer"
title="Open stemdeck.app" data-i18n-title="topbar.website"
aria-label="Open stemdeck.app" data-i18n-aria-label="topbar.website">
<img src="/img/stemdeck-waveform-symbol.svg" alt="StemDeck" width="52" height="52" />
</a>
</div>
<span id="brandVersion" class="daw-version"></span>

<!-- Separator -->
<div class="daw-sep"></div>

<!-- Composer pill -->
<div class="daw-composer-stack">
Expand Down Expand Up @@ -96,14 +101,20 @@
Loose in the row, the mode buttons read as peers of Drums and
Bass rather than as a choice about the chip beside them. -->
<div class="stem-group stem-group-vocals">
<!-- Switching vocals on from here rather than from a mode button
says nothing about how they should come out, so it takes
Combined. The two buttons beside it can do the same thing and
name the mode in one press. -->
<button class="stem-choice" type="button" data-stem="vocals" aria-pressed="true" style="--color:var(--vocals)">
<span data-i18n="stem.vocals">Vocals</span>
</button>
<!-- On-demand lead/backing vocal split (#275). Shown only while
Vocals is selected above; toggling "Lead + Backing" auto-runs
<!-- On-demand lead/backing vocal split (#275). Always on screen,
and dimmed to "unavailable" by refreshStemChoiceVisuals while
Vocals is not selected above, rather than leaving the row and
changing the group's shape. Toggling "Lead + Backing" auto-runs
the split once the import finishes (see main.js/job.js). -->
<div class="vocal-mode-toggle hidden" id="vocalModeToggle" role="group" aria-label="Vocals mode" data-i18n-aria-label="vocalMode.groupAria">
<button type="button" class="vocal-mode-btn" data-mode="all" aria-pressed="true" data-i18n="vocalMode.all">All</button>
<div class="vocal-mode-toggle" id="vocalModeToggle" role="group" aria-label="Vocals mode" data-i18n-aria-label="vocalMode.groupAria">
<button type="button" class="vocal-mode-btn" data-mode="all" aria-pressed="true" data-i18n="vocalMode.all">Combined</button>
<button type="button" class="vocal-mode-btn" data-mode="split" aria-pressed="false" data-i18n="vocalMode.split">Lead + Backing</button>
</div>
</div>
Expand Down Expand Up @@ -171,7 +182,11 @@
<div class="track-panel-info">
<div class="daw-track-title track-panel-title" id="title">—</div>
</div>
<button class="daw-fav-btn" id="fav-btn" aria-label="Toggle favorite" data-i18n-aria-label="fav.toggleAria" aria-pressed="false">
<!-- type="button" is load-bearing. This sits inside <form id="job-form">
since the now-playing block moved up into the composer, and a
button with no type inside a form is a submit button: pressing the
heart started an extraction. -->
<button class="daw-fav-btn" type="button" id="fav-btn" aria-label="Toggle favorite" data-i18n-aria-label="fav.toggleAria" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
Expand Down
28 changes: 28 additions & 0 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,8 @@ function applyTrackInfoToPanel(track) {
if (trackExtracted) trackExtracted.textContent = fmtExtracted(track.createdAt);
if (trackSource) trackSource.textContent = deriveSource(track.sourceUrl);
if (trackQuality) trackQuality.textContent = deriveQuality(track.sourceUrl);
// The now-playing square shows the same extension the library row does.
document.querySelector("#np-art .np-art-placeholder")?.setAttribute("data-ext", extLabel(track));
if (favBtn) {
favBtn.classList.toggle("active", Boolean(track.favorite));
favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite)));
Expand Down Expand Up @@ -1326,8 +1328,34 @@ export function displayTitle(title) {
}
}

/**
* The file extension to show in place of artwork, or "" for none.
*
* Only an uploaded file has one worth showing. A URL import either brings its
* own thumbnail or has no filename at all, and "local:<name>" is where the
* name survives (deriveQuality reads the same field). Validated rather than
* trusted: it is interpolated into markup, and a name with no dot, a trailing
* dot or something that is not an extension falls back to the note icon.
*
* Styled by .thumb-ext and .np-art-placeholder[data-ext] in daw.css. Those
* rules shipped in #665 without this function, so every file-only track still
* showed the generic note (#678).
*/
export function extLabel(track) {
if (!track || track.thumb) return "";
const src = track.sourceUrl || "";
if (!src.startsWith("local:")) return "";
const name = src.slice("local:".length);
const dot = name.lastIndexOf(".");
if (dot < 1) return "";
const ext = name.slice(dot + 1);
return /^[a-z0-9]{1,5}$/i.test(ext) ? ext.toUpperCase() : "";
}

function thumbHtml(track) {
if (track.thumb) return `<img src="${esc(track.thumb)}" alt="" loading="lazy" />`;
const ext = extLabel(track);
if (ext) return `<span class="thumb-ext">${ext}</span>`;
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`;
}

Expand Down
Loading
Loading