Imported from sentimony/sentimony-nuxt (
app/AGENTS.md). Install upstream withnpx skills add sentimony/sentimony-nuxt --skill app. Copyright stays with the author.
AGENTS.md - app/
Client-side context: the Nuxt app directory. Loaded on demand when working under
app/; universal guardrails, commands and the deploy path stay in the root
AGENTS.md.
- Last reviewed: 2026-10-19
Architecture (client)
Composables. Each entity has useXxx() (wraps useAsyncData + $fetch('/api/xxx')) and useXxxLikes() built on the createLikes(key, apiBase, countsUrl) factory (app/composables/createLikes.ts). useLikes() (app/composables/useLikes.ts) is the release reference; artist/video/track/event/playlist variants follow it. A failed like reverts the optimistic update and fires toast.error (vue-sonner); <Toaster> is mounted once in app/app.vue. toArray() (app/composables/toArray.ts) normalises Firebase object-keyed and Supabase array responses into one array.
Audio player. State is one useState-backed singleton in app/composables/useAudioPlayer.ts (current, isPlaying, currentTime, duration, volume, seekTo, repeatMode, playToken) - there is no Pinia store.
<AudioBridge>(mounted once inapp/app.vue) owns the only<audio>element, mirrors it into that state, persists volume inlocalStorage['player-volume']and the last track inlocalStorage['player-current'](restored paused viarestore(), which skipsplayTokenso a restore is not counted as a play;<GlobalPlayer>proposes the newest release's first track when nothing is stored) and drives Media Session; UI components (<GlobalPlayer>,<PagePlayer>,<AudioMixPlayer>) never touch the element, they only read/call the composable.- Queue navigation is pure and unit-tested -
nextQueueIndex/prevQueueIndex/shuffleQueueOrderinapp/utils/audioQueue.ts, repeat modesoff|all|one. - Shuffle is a permutation layer over
queueIndex(shuffleQueueOrderkeeps the current track first so each track plays once per pass); likerepeatModeit is session state, not persisted, andstepIndex()is the single next/prev resolver both the UI (canPrev/canNext) andonEndedread. playTokenincrements on every playback start (new track, queue move, repeat replay) so a listener counts exactly one play per cycle:<PagePlayer>watches it and POSTs/api/track-plays; totals are read from the same route via GET and merged withmergePlayCounts()(app/utils/playCounts.ts), which keeps the max of local vs server like the likes flow.
Pages. All in app/pages/; list page (releases.vue) + detail (release/[id].vue). Item is the universal card across list pages. Every detail page (release, artist, video, event) follows the same shape - release/[id].vue is the reference: centered <h1>, a lg:flex-row hero with <OpenImage> + metadata + <LikeButton> + link buttons on the left and a max-w-[540px] <Tabs> column on the right, then <ItemContent> for prose plus <RelativeItem> cross-links.
Releases genre filter. /releases/{psytrance,psychill,ungrouped} share <GenreTabs> (tab buttons with live per-genre counts) + <ReleasesFiltered>, which filters visible releases through matchesGenre(style, keyword) (app/utils/releaseGenres.ts): case-insensitive style.includes(keyword), except ungrouped = visible releases whose style contains neither psytrance nor psychill (RELEASE_GENRES).
- Per-genre counts on the tabs and the grid both filter through the same util.
/releases/allis the exception (mirrors/artists/all): it lists all releases includingvisible:falsevia/api/releases-all(same fields as/api/releasesbut no.eq('visible', true)) +sortByDate(), not<ReleasesFiltered>.- Correspondingly
/api/release/[id]fetches by slug without avisiblefilter (same as/api/artist/[id]), so hidden releases open by direct URL and are reachable from/releases/all.
Styling (Tailwind v4). Via the @tailwindcss/vite plugin - no tailwind.config/postcss.config.
- Theme tokens (light in
:root, dark in.dark- source order matters since both are specificity-equal), thedarkvariant, and a globalinput:-webkit-autofilloverride (tied tovar(--foreground)) live inapp/assets/css/tailwind.css(@theme/@theme inline). - A global forest-background overlay (
html::before/html::afterintailwind.css, fixed/negativez-index) applies the sametrees-origin_v1.jpgon every route at low opacity (0.33 light / 0.17 dark) under a green/white tint gradient -bodyno longer carries its own background-image. - The homepage mounts
HomepageAtmosphere.vueonly on/, repeating the identical image+gradient treatment as a foreground layer (z-index: 0) behind the existing fractal/content layers. - Default is dark (set pre-paint by an inline script in
app.head, persisted inlocalStorage['theme']);useTheme()+<ThemeToggle>switch with a View Transitions circular reveal. - Many components still hardcode
text-white/Xetc. (baked in for dark) - light theme is token-level only, not yet polished per-component. - Named colours are tokens:
--color-moss/--color-moss-dark(bg-moss,fill-moss) and--forest-tint(switched by.dark) - no literal#b5ccb5/#2a4030or duplicated tint gradients outside@theme/:root; the Bandcamp/SoundCloudtracks-Nheight map lives once intailwind.css. - Guarded by
tests/unit/interactionStates.test.tsandcatalogSemantics.test.ts.
PWA. Manual SW (no module): public/custom-sw.js (precache + offline fallback), registered by app/plugins/pwa.client.ts in production only (import.meta.dev guard) to avoid stale localhost:3000 caches. Assets: public/site.webmanifest, public/offline.html. Run npm run verify:pwa after touching these.
Images. No @nuxt/image (removed) - rendered media uses plain <img> with the pre-generated static _th.jpg variant, no on-the-fly resizing.
- The auto-imported
thumb()util (app/utils/thumb.ts) derives the_thURL from any_xl/_ogfield (regex-rewrites the suffix), so call sites keep referencing the canonical_xl/_ogfields and wrap them inthumb(). OpenImageis the only place that opens the full-res_xl.jpg(zoom dialog); its preview isthumb(image_th).- OG/Twitter meta keep the
_ogvariant untouched. - There is no
_th→_xlfallback - a missing_thfile just 404s, so every catalog image must have its_thuploaded tocontent.sentimony.com(releases/artists carrycover|photo_th/_og/_xl; events carryflyer_a_xl/_th/_og+flyer_b_xl/_th, backed by Supabase columns from migration20260722_add_flyer_variants_to_events.sql).
UI. Local primitives in app/components/ui/* (alert, button, card, input, label, sonner, tooltip), auto-imported via ~/components/ui with pathPrefix: false + extensions: ['vue']; cn() in app/lib/utils.ts; built on reka-ui. They are project-owned components - no external registry, no generator CLI; add or change primitives by editing the files directly. Gotchas:
-
Auto-import keys off the
.vuefilename, not theindex.tsbarrel -Sonner.vue→<Sonner>, notToaster. Import explicitly when export name ≠ filename (<Toaster>inapp/app.vue). -
No
@lucide/vue- icons use@nuxt/iconwith lucide as the UI set (<Icon name="lucide:…" />, native 24px/stroke-2) and Simple Icons for brands. Lucide has no solid variants - fill an outline icon by forcing SVG render mode and overriding the path'sfill="none":<Icon name="lucide:heart" mode="svg" :class="liked && '[&_path]:fill-current'" />(plainfill-currenton the<svg>is beaten by the path's ownfill="none"; mask/css mode has no path to target). -
Brand icons → Simple Icons (
<Icon name="simple-icons:…" />); registry inapp/constants/icons.tsmaps eachIconKeyto asimple-icons:*name. A few marks Simple Icons lacks stay as custom SVG (kind: 'svg'/img=plain<img>): JunoDownload, Amazon Music, Qobuz. -
Buttons live in
app/components/buttons/with a dedicated{ path: '~/components/buttons', pathPrefix: false }entry innuxt.configso they resolve as<PrimaryButton>/<DefaultButton>/<LikeButton>(Nuxt prefixes nested component names by default → without it they'd be<ButtonsPrimaryButton>).- All button components wrap
ui/button:PrimaryButton= link/icon wrapper (internal-vs-external<a>/NuxtLinkdetection,glassvariant),DefaultButton= sidebar-style link button (defaultvariant; propscount→· Ninfont-mono,small→ 30px height,outline→ persistent border; internal links getexactActiveClassfor the active-tab highlight),LikeButton= accumulate/clap like button (glassforsize="lg",softotherwise),CloseBtn= icon-only dismiss button for dialogs/overlays. ui/buttonis the single styling authority (buttonVariantscva:default(the cva default - transparent border + hover bg/border matching the mobile sidebar nav) /outline/link/glass/soft/submit(form submits; one utility set that reads correctly in both themes, nodark:duplicates)).
- All button components wrap
-
Audio players use the same
pathPrefix: falsetrick under~/components/player-<GlobalPlayer>(fixed bottom bar, mounted once inlayouts/default.vue),<PagePlayer>(on-page tracklist on release/artist/track pages), plus shared<PlayerControls>(prev/play/next,sizesm|md,showSidestoggles the side buttons),<PlayerTrackInfo>(cover + bold-artist / dim-name + release tooltip) and<PlayerSeek>(elapsed + range + total, emitsseek).PagePlayerrenders track info and the seek slider even while idle - the info falls back to the first playable track and the slider is disabled until that page's queue is the active one; the.player-rangestyling is global intailwind.cssbecause the volume input reuses it. -
No global
border-borderbase layer (many bareborderutilities rely oncurrentColor) - set borders per instance (auth Cards useborder-white/20). -
font-mono= Azeret Mono for technical data: BPM, catalog numbers (SR-042), track duration (4:32), total release duration. Applyfont-monoto any element rendering these values. Text tiers and focus states. Тексту два семантичні рівні:text-foreground(основний) іtext-muted-foreground(secondary: лейбли, placeholder, допоміжні лінки). -
Третього, тусклішого рівня немає -
text-foreground/40і/50не проходять WCAG AA у світлій темі (2.58-3.94), і не можна кластиopacity-*на текст усередині напівпрозорого батька (лічильники, підписи) - це той самий третій тир через задні двері. -
Focus-стан інтерактивних елементів із переліку
a,button,[role="button"],input[type="range"],summary(текстові інпути в нього не входять:ui/inputтримає власний рецептfocus-visible:outline-*) задає одне нешарове правило:is(…):focus-visibleуtailwind.css: воно поза@layer, тому перебиває будь-який локальнийoutline-noneчиfocus-visible:ring-*незалежно від специфічності - не загортати його в@layerі не додавати компонентам власні focus-класи (буде подвійна індикація). -
Не додавати безумовний
outline-none. Тому на елементах, які підпадають під глобальний селектор,outline-noneіfocus-visible:ring-*мертві (подвійна індикація або просто шум), а на решті - тихо вбивають фокус. Розширювати треба сам селектор, не додавати локальні правила. -
Не замінювати outline інпута на
ring-*без живої перевірки: разом ізshadow-xsring-слот лишався прозорим. -
Заливка
--cardнавмисно інвертується між темами (світла - біла 55%, темна - чорна 25%), бо на світлому фоні затемнення картки погіршує контраст чорного тексту. -
Guarded by
tests/unit/interactionStates.test.ts.
Landmarks. Один <main id="main" tabindex="-1"> живе в app/layouts/default.vue; app/error.vue - єдиний виняток. Скіп-лінка цілиться в #main. Списки й імена: треклісти, лайнапи і списки зв'язків - <ol>/<ul class="list-none"> + <li class="mb-2"> (ритм абзацу відтворюється явно), не повторювані <p>; зображення всередині посилання/кнопки з видимим текстом або aria-label - alt=""; кожен <iframe> має title з назвою плеєра; кожна list-сторінка має <CollectionStatus>. Guarded by tests/unit/catalogSemantics.test.ts, accessibleNames.test.ts, collectionStatus.test.ts.
Likes (client). The createLikes(key, apiBase, countsUrl) factory, the anonymous
device-id cookie and the accumulate/clap model are documented once in
server/AGENTS.md - client and server share one contract.
For landmarks, focus surfaces, scoped CSS, Edge Functions, SEO, artist ordering and
the docs tree in depth, read
docs/agent-context/frontend-and-docs.md.
