diff --git a/CLAUDE.md b/CLAUDE.md index 0e603b7..a9a1f1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1448,6 +1448,52 @@ is therefore **reported at runtime** to `window.__yjIconMisses` and drawn as a fallback — an e2e sweep asserts there are none — since a missing icon used to be impossible, the CDN having had everything. +**What each icon *means* is a second table, and it is +`utils/icon-language.ts`.** Bundling answers "does this name resolve"; +nothing answered "does this name mean what the one next to it means", +and a wrong-but-real icon renders perfectly. So `plus` came to mean add +to the queue, add to a playlist, make a new playlist **and** you do not +own this — the first two *adjacent in the same context menu* — while +`list` meant the queue, the Playlists destination and adding to the +queue. + +The rule the table is built on: **an icon names the noun it acts on, +not the verb.** "Add to queue" and "add to playlist" are one verb on +two nouns, so the noun is what has to differ — which is why adding to a +playlist wears the Playlists destination's own icon, and why the queue +got `bars-staggered` and stopped wearing Playlists'. `plus` keeps the +one meaning it is unambiguous about, making something that is not there +yet. + +Four things about it are load-bearing: + +- **The request toggle is one glyph in two weights** + (`regular/bookmark` → `solid/bookmark`), because two states of a + toggle have to read as each other's opposite and a plus against a + bookmark does not. The pair was *already in the app and already + right* on `explore-album-details`'s "Request this" button while the + badge forty pixels away showed a plus — `utils/library-status.ts`'s + fault one layer down, having made the two agree on what wanting means + and left them disagreeing on what it looks like. +- **Downloads keeps the solid bookmark, deliberately.** That is the + same word twice, not two words: the badge says "this is on your + list" and the nav item is that list. +- **`icon-language.test.ts` sweeps the source**, because the rule is + about every call site and checking one checks nothing — the same + shape as `TestNoDirectRuntimeEmits`. It reads every `src/**/*.ts` as + raw text and fails on a literal `name="plus"` or `icon: 'list'` + outside the table, and its **first assertion is that it read + anything at all**, since a sweep over an empty glob passes. +- **It also asserts every `ICON_*` is bundled**, which closes the loop + the runtime cannot: `bookmark-check` is Font Awesome **Pro** and sat + on `explore-artist-details`'s Follow button, drawn for every followed + artist as a circled question mark. `offline-icons.spec.ts` sweeps + `__yjIconMisses` and could not see it, because no spec had ever + followed an artist — the same fault `requested-badge.spec.ts` was + written for, one component over, still live. A name computed from + state was only checkable from the state; now it is checkable from the + table. + **An album page says how much of the album is yours.** `explore-album-details` is a *catalog* page and there is no library-side album detail page at all, so the album on it may be diff --git a/frontend/index.html b/frontend/index.html index 14b3786..cb14257 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,7 +39,10 @@ \ No newline at end of file diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 209dc49..16659af 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -37,6 +37,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js' import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@components/playlist-picker/playlist-picker.js'; import { dict, list } from '@utils/binding'; +import { + ICON_PLAYLIST, + ICON_QUEUE, +} from '@utils/icon-language'; /** Pixels to change card width per scroll tick. */ const ZOOM_STEP = 16; @@ -1371,7 +1375,7 @@ export class ArtistsView > Add to Queue @@ -1407,7 +1411,7 @@ export class ArtistsView > Add to Playlist Add to Queue @@ -2156,7 +2160,7 @@ export class CoverGrid > Add to Playlist this.queueOwned()} > - + Add to queue ${partial @@ -2740,7 +2746,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { of the same Free glyph carry the toggle instead. --> ${this.isRequested ? 'Requested' : 'Request this'} @@ -3338,7 +3344,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { @click=${() => this.onContextMenuAction('add-to-queue')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > - + Add to Queue - + Add to Playlist diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index c18ca92..9c2389e 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -59,6 +59,12 @@ import { dict, dictByName } from '@utils/binding'; import type { TrackDetails } from '@components/track-details/track-details.js'; import { showTrackDetailsForPath } from '@utils/track-details-opener.js'; import '@components/playlist-picker/playlist-picker.js'; +import { + ICON_CAN_REQUEST, + ICON_PLAYLIST, + ICON_QUEUE, + ICON_REQUESTED, +} from '@utils/icon-language'; /* ── Constants ── */ @@ -2674,7 +2680,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost @click=${() => this.onContextMenuAction('add-to-queue')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > - + Add to Queue - + Add to Playlist @@ -2737,7 +2743,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost Play void this.onReleaseAction('add-to-queue')}> - + Add to Queue void this.onReleaseAction('play-next')}> @@ -2751,7 +2757,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost void this.onReleaseRequestToggle()}> ${requested ? 'Cancel Request' : 'Request This'} @@ -2789,9 +2795,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost appearance=${request ? 'filled' : 'outlined'} @click=${() => void this.toggleFollow(request?.id)} > + ${request ? 'Following' : 'Follow for new releases'} diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index b9b1257..b67e003 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -36,6 +36,7 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js'; import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import { dict, dictByName } from '@utils/binding'; +import { ICON_QUEUE } from '@utils/icon-language'; /** The region explore's own action failures (play/queue) are rendered in. */ export const ExploreRegion = 'explore'; @@ -1342,7 +1343,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte Play this.onContextMenuAction('add-to-queue')}> - + Add to Queue this.onContextMenuAction('play-next')}> diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 92e45f7..8c0a6f7 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -35,6 +35,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js' import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; import '@components/playlist-picker/playlist-picker.js'; import { dictByName } from '@utils/binding'; +import { + ICON_PLAYLIST, + ICON_QUEUE, +} from '@utils/icon-language'; /** Pixels to change card width per scroll tick. */ const ZOOM_STEP = 16; @@ -1211,7 +1215,7 @@ export class GenresView > Add to Queue @@ -1257,7 +1261,7 @@ export class GenresView > Add to Playlist - + `; diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index e188099..c2e5d75 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -70,6 +70,10 @@ import { } from '@utils/explore-link'; import { designTokens } from '../../styles/tokens.css'; import { list } from '@utils/binding'; +import { + ICON_PLAYLIST, + ICON_QUEUE, +} from '@utils/icon-language'; /** One playlist row: the track and its position in the *playlist*, * which is not its position in the filtered view. */ @@ -1358,7 +1362,7 @@ export class PlaylistDetails
@@ -1665,7 +1669,7 @@ export class PlaylistDetails > Add to Queue @@ -1720,7 +1724,7 @@ export class PlaylistDetails > Add to Playlist - + New Playlist
diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 4e7aa62..8ffe1cd 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -37,6 +37,10 @@ import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { FavoritesController } from '@store/controllers/favorites-controller'; import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js'; import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js'; +import { + ICON_NEW, + ICON_PLAYLIST, +} from '@utils/icon-language'; const SCROLL_DEBOUNCE_MS = 100; @@ -1496,7 +1500,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) { @dragleave=${this.onNewButtonDragLeave} @drop=${this.onNewButtonDrop} > - + New Playlist @@ -1794,11 +1799,11 @@ export class QueuePanel ? html`

Queue is empty

@@ -1875,7 +1880,7 @@ export class QueuePanel > Add to Playlist Add to Queue @@ -1521,7 +1525,7 @@ export class SmartPlaylistDetails > Add to Playlist this.onContextMenuAction('add-to-queue')} @mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()} > - + Add to Queue - + Add to Playlist diff --git a/frontend/src/icons/names.txt b/frontend/src/icons/names.txt index 81ce440..309e42b 100644 --- a/frontend/src/icons/names.txt +++ b/frontend/src/icons/names.txt @@ -24,6 +24,7 @@ solid/arrows-rotate solid/arrow-up-short-wide solid/backward-step solid/bars +solid/bars-staggered regular/bookmark solid/bookmark solid/box-open diff --git a/frontend/src/utils/icon-language.ts b/frontend/src/utils/icon-language.ts new file mode 100644 index 0000000..28ffc94 --- /dev/null +++ b/frontend/src/utils/icon-language.ts @@ -0,0 +1,100 @@ +/** + * What each icon in this app means, once. + * + * The set was a mix: `plus` meant "add to the queue", "add to a + * playlist", "make a new playlist" and "you do not own this" — the + * first two *adjacent in the same context menu* — while `list` meant + * the queue, the Playlists destination, and (in `queue-panel` alone) + * adding to the queue. Two icons carrying seven meanings between them + * is not a vocabulary, and a user cannot learn one that says four + * things. + * + * The rule these are chosen by: **an icon names the noun it acts on, + * not the verb.** "Add to queue" and "add to playlist" are the same + * verb on different nouns, so the noun is what has to differ — which is + * also why adding to a playlist wears the Playlists destination's own + * icon rather than a generic plus. `plus` survives for exactly the one + * thing it is unambiguous about, making something that did not exist. + * + * Import these rather than writing a name inline. A literal string is + * how the last set drifted, and nothing catches it: a wrong-but-real + * icon renders perfectly. + */ + +/** Start playing this now. */ +export const ICON_PLAY = 'play'; + +/** Start playing this now, in a shuffled order. */ +export const ICON_SHUFFLE = 'shuffle'; + +/** + * The queue, and putting something into it. + * + * One glyph for the noun and the action, so the button that opens the + * queue and the menu item that adds to it are visibly the same subject. + * The queue used to wear `list`, which is the Playlists destination. + */ +export const ICON_QUEUE = 'bars-staggered'; + +/** Put this next in the queue rather than at the end. */ +export const ICON_PLAY_NEXT = 'forward-step'; + +/** + * A playlist, and adding something to one. + * + * The same icon as the Playlists destination in the sidebar, which is + * the point: the menu item says where the thing is going. + */ +export const ICON_PLAYLIST = 'list'; + +/** + * Make a new thing that did not exist — a playlist, a rule, a library. + * + * This is the only meaning `plus` keeps. It used to carry four. + */ +export const ICON_NEW = 'plus'; + +/** + * The request ("want") toggle, as an outline/solid pair. + * + * Two states of one control have to read as each other's opposite, + * which a plus and a bookmark do not. The pair was already in the app + * and already correct — `explore-album-details`'s "Want this" button + * has used it since it was written, and `favorites-controller` uses the + * same shape for `regular/heart` → `heart` — while the badge forty + * pixels away showed a plus for the same state. + * + * That is `utils/library-status.ts`'s fault one layer down: it made the + * two surfaces agree on *what wanting means* and left them disagreeing + * on what it looks like. + */ +export const ICON_CAN_REQUEST = 'regular/bookmark'; +export const ICON_REQUESTED = 'solid/bookmark'; + +/** + * You have this. + * + * Deliberately not drawn on the common case — see the tracklist, where + * absence is what gets marked. This is for the places that answer the + * question directly, like the badge on a catalog card. + */ +export const ICON_IN_LIBRARY = 'check'; + +/** + * Something is being fetched right now. + * + * Distinct from `ICON_REQUESTED`: a request may sit on the list + * forever without anything happening, which is exactly why the badge's + * "queued" state stopped being an hourglass. + */ +export const ICON_DOWNLOADING = 'download'; + +/** + * Take this away. + * + * One icon for removing from a playlist, from the queue and from the + * library, because the difference that matters is stated in the words + * beside it and in the confirmation — "Remove from Library" says in its + * impact line that the files are not deleted. + */ +export const ICON_REMOVE = 'trash'; diff --git a/frontend/test/components/chrome.test.ts b/frontend/test/components/chrome.test.ts index 4d11c12..1384145 100644 --- a/frontend/test/components/chrome.test.ts +++ b/frontend/test/components/chrome.test.ts @@ -19,6 +19,11 @@ import { update, visual, } from '@test/support/render'; +import { + ICON_CAN_REQUEST, + ICON_IN_LIBRARY, + ICON_REQUESTED, +} from '@utils/icon-language'; describe('', () => { it('renders a testid per destination, which is how e2e navigates', async () => { @@ -154,9 +159,20 @@ describe('', () => { it('defaults to "not in library"', async () => { const el = await fixture('library-status-indicator'); - expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus'); + expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe(ICON_CAN_REQUEST); }); + /** + * Named from the vocabulary rather than written out, or this test + * pins the glyphs *against* the table it is supposed to follow — + * which is what it did: it asserted `plus` for the un-owned state, + * the same glyph two adjacent menu items were using for two other + * meanings, and passing was the reason nobody looked. + * + * What is still worth asserting is that the three differ, which is + * the property the states need and the one the table cannot state + * about itself here. + */ it('uses a distinct glyph per state', async () => { const glyphs: (string | null | undefined)[] = []; @@ -166,7 +182,8 @@ describe('', () => { glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name')); } - expect(glyphs).toEqual(['check', 'bookmark', 'plus']); + expect(glyphs).toEqual([ICON_IN_LIBRARY, ICON_REQUESTED, ICON_CAN_REQUEST]); + expect(new Set(glyphs).size).toBe(3); }); it('phrases its label around the entity it describes', async () => { diff --git a/frontend/test/components/icon-language.test.ts b/frontend/test/components/icon-language.test.ts new file mode 100644 index 0000000..1c14fe5 --- /dev/null +++ b/frontend/test/components/icon-language.test.ts @@ -0,0 +1,140 @@ +/** + * The icon vocabulary is one table, and nothing writes around it. + * + * A wrong-but-real icon name renders perfectly: no error, no fallback, + * no failing assertion anywhere. That is how `plus` came to mean "add + * to the queue", "add to a playlist", "make a new playlist" and "you do + * not own this" — the first two adjacent in the same context menu — + * while `list` meant the queue, the Playlists destination *and* adding + * to the queue. + * + * `src/icons/index.ts` catches a name that is not *bundled*. Nothing + * catches a name that is bundled and means something else, so this + * sweeps the source for the governed ones. It is the same shape as + * `TestNoDirectRuntimeEmits` and `TestNoWritesOnTheReadPool` in the + * backend, and exists for the same reason: the rule is about every call + * site, so checking one is checking nothing. + */ +import { describe, expect, it } from 'vitest'; + +import { bundledIconNames } from '../../src/icons'; +import * as icons from '@utils/icon-language'; + +/** Every component source, as text. */ +const SOURCES = import.meta.glob('../../src/**/*.ts', { + eager: true, + query: '?raw', + import: 'default', +}); + +/** + * The names that carry a meaning the table owns. + * + * Deliberately not every bundled name. `check` is `ICON_IN_LIBRARY` + * here and also the "Copied" confirmation in `job-log-view`, which is + * a different, perfectly good meaning — governing it would force a + * false rename. What belongs on this list is a name that was actually + * overloaded. + */ +const GOVERNED = [ + 'plus', + 'list', + 'bookmark', + 'solid/bookmark', + 'regular/bookmark', + 'bars-staggered', +]; + +/** The one file allowed to say them, plus its own test. */ +const DEFINITION = /icon-language\.(ts|test\.ts)$/; + +describe('the icon vocabulary', () => { + /** + * A sweep over nothing passes. This is the assertion that makes the + * rest of the file mean something, and it is the first thing that + * breaks if the glob pattern stops matching after a move. + */ + it('actually reads the source', () => { + const paths = Object.keys(SOURCES); + + expect(paths.length).toBeGreaterThan(100); + expect(paths.some((p) => p.endsWith('/track-list.ts'))).toBe(true); + expect(SOURCES[paths[0]!]).toContain('import'); + }); + + it.each(GOVERNED)('is not written around for %s', (name) => { + const offenders: string[] = []; + + for (const [path, source] of Object.entries(SOURCES)) { + if (DEFINITION.test(path)) continue; + + // Both spellings: an icon in a template, and an icon name in a + // data table (which is how the sidebar and bottom-nav carry + // theirs). + const literal = new RegExp( + `(name="${name}"|icon: '${name}'|name=\\$\\{[^}]*'${name}')`, + ); + + if (literal.test(source)) offenders.push(path); + } + + expect(offenders).toEqual([]); + }); + + /** + * A meaning with no icon behind it is the state the badge's `queued` + * spent a year in — declared, styled, and produced by nothing. + */ + it('gives every meaning a name', () => { + const values = Object.entries(icons).filter(([k]) => k.startsWith('ICON_')); + + expect(values.length).toBeGreaterThan(0); + + for (const [key, value] of values) { + expect(`${key}=${value}`).toMatch(/^ICON_[A-Z_]+=[a-z]+[a-z/-]*$/); + } + }); + + /** + * Every name in the table is a name the app actually ships. + * + * This is the loop the vocabulary closes. A name that is not bundled + * renders a circled question mark and reports itself to + * `__yjIconMisses` — at *runtime*, from a state something has to + * reach first. `bookmark-check` is Font Awesome **Pro**, and it was + * on `explore-artist-details`'s Follow button, drawn for every + * followed artist, invisible to `offline-icons.spec.ts` because no + * spec had ever followed one. Reaching the state is no longer how + * this is found. + */ + it('names only icons that are bundled', () => { + const bundled = new Set(bundledIconNames()); + const missing = Object.entries(icons) + .filter(([k]) => k.startsWith('ICON_')) + .filter(([, v]) => !bundled.has(v as string)) + .map(([k, v]) => `${k} (${v})`); + + expect(missing).toEqual([]); + }); + + /** + * The two states of the request toggle have to be the same glyph in + * two weights, or they do not read as each other's opposite — which + * is what a plus against a bookmark was. + */ + it('makes the request toggle an outline/solid pair', () => { + expect(icons.ICON_CAN_REQUEST).toBe(`regular/${icons.ICON_REQUESTED.replace('solid/', '')}`); + }); + + /** + * The queue and the Playlists destination wore the same icon, and + * "add to queue" and "add to playlist" sat next to each other wearing + * a third same one. Whatever the table says, these three have to + * differ from each other. + */ + it('keeps the queue, playlists and creating something apart', () => { + const three = [icons.ICON_QUEUE, icons.ICON_PLAYLIST, icons.ICON_NEW]; + + expect(new Set(three).size).toBe(3); + }); +});