Skip to content
Closed
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
11 changes: 9 additions & 2 deletions src/components/FreePlayPicker.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { STAGE_NAMES } from '@/config/game'
import { formatQualityLabel } from '@/music/display'
import { formatQualityLabel, isMinorSymbolLabel } from '@/music/display'
import type { FreePlayStageAccess } from '@/stores/progress'
import type { ChordQuality } from '@/music/data/chords'
import type { ScaleMode } from '@/music/data/scales'
Expand Down Expand Up @@ -73,7 +73,10 @@ function selectSubStage(quality: ChordQuality | ScaleMode | IntervalGroup, acces
v-for="(subStage, ssIndex) in stage.subStages"
:key="ssIndex"
class="sub-stage-btn"
:class="{ 'sub-stage-btn--locked': !subStage.accessible }"
:class="{
'sub-stage-btn--locked': !subStage.accessible,
'sub-stage-btn--symbol': isMinorSymbolLabel(subStage.quality),
}"
:disabled="!subStage.accessible"
@click="selectSubStage(subStage.quality, subStage.accessible)"
>
Expand Down Expand Up @@ -162,6 +165,10 @@ function selectSubStage(quality: ChordQuality | ScaleMode | IntervalGroup, acces
cursor: default;
}

.sub-stage-btn--symbol {
text-transform: none;
}

@media (prefers-color-scheme: dark) {
.stage-item {
border-color: #444;
Expand Down
9 changes: 7 additions & 2 deletions src/components/IdleView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FEATURES } from '@/config/features'
import FreePlayPicker from '@/components/FreePlayPicker.vue'
import SubStageProgressBar from '@/components/SubStageProgressBar.vue'
import TheoryModal from '@/components/TheoryModal.vue'
import { formatQualityLabel, isMinorSymbolLabel } from '@/music/display'
import type { ChordQuality } from '@/music/data/chords'
import type { ScaleMode } from '@/music/data/scales'
import type { IntervalGroup } from '@/music/data/intervals'
Expand Down Expand Up @@ -100,8 +101,8 @@ function onStageOpen(stageIndex: number): void {
<main class="idle-main">
<template v-if="idleMode === 'learn'">
<div class="learning-position">{{ stageName }}</div>
<div class="quality-label">
<span>Quality: {{ quality }}</span>
<div class="quality-label" :class="{ 'quality-label--symbol': isMinorSymbolLabel(quality) }">
<span>Quality: {{ formatQualityLabel(quality) }}</span>
<button
v-if="FEATURES.THEORY_MODAL"
class="info-btn"
Expand Down Expand Up @@ -241,6 +242,10 @@ function onStageOpen(stageIndex: number): void {
text-transform: capitalize;
}

.quality-label--symbol {
text-transform: none;
}

.info-btn {
background: transparent;
border: 1px solid #aaa;
Expand Down
7 changes: 5 additions & 2 deletions src/components/TheoryModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ScaleMode } from '@/music/data/scales'
import { INTERVAL_CATALOG } from '@/music/data/intervals'
import type { IntervalGroup } from '@/music/data/intervals'
import { SCALE_DESCRIPTIONS, MODE_PARENT } from '@/music/data/theory'
import { formatQualityLabel } from '@/music/display'
import { formatQualityLabel, isMinorSymbolLabel } from '@/music/display'
import { intervalToDegreeLabel } from '@/music/matrix'
import PianoKeyboard from '@/components/PianoKeyboard.vue'

Expand Down Expand Up @@ -58,7 +58,10 @@ const catalogEntry = computed(() => {

const intervals = computed<string[]>(() => catalogEntry.value?.intervals ?? [])

const title = computed(() => capitalize(formatQualityLabel(props.quality)))
const title = computed(() => {
const label = formatQualityLabel(props.quality)
return isMinorSymbolLabel(props.quality) ? label : capitalize(label)
})

const subtitle = computed<string | null>(() => {
const meta = MODE_PARENT[props.quality as ScaleMode]
Expand Down
31 changes: 31 additions & 0 deletions src/components/__tests__/FreePlayPicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,35 @@ describe('FreePlayPicker', () => {
expect(wrapper.emitted('play')).toBeUndefined()
})
})

describe('minor symbol casing', () => {
it('renders m7 text and disables capitalize transform for a minor tetrad sub-stage', async () => {
const access = makeAccess([
{
accessible: true,
subStages: [
{ accessible: true, quality: 'm7' },
{ accessible: true, quality: 'mMaj7' },
],
},
])
const wrapper = mount(FreePlayPicker, {
props: { freePlayAccess: access, initialStage: 0 },
})
await nextTick()
const buttons = wrapper.findAll('.sub-stage-btn')
expect(buttons[0]!.text()).toBe('m7')
expect(buttons[0]!.classes()).toContain('sub-stage-btn--symbol')
expect(buttons[1]!.text()).toBe('mΔ7')
expect(buttons[1]!.classes()).toContain('sub-stage-btn--symbol')
})

it('does not disable the capitalize transform for a triad sub-stage', async () => {
const wrapper = mount(FreePlayPicker, {
props: { freePlayAccess: ACCESS_STAGE0_PARTIAL, initialStage: 0 },
})
await nextTick()
expect(wrapper.findAll('.sub-stage-btn')[0]!.classes()).not.toContain('sub-stage-btn--symbol')
})
})
})
19 changes: 19 additions & 0 deletions src/components/__tests__/IdleView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ describe('IdleView', () => {
expect(wrapper.text()).toContain('thirds')
})

it('shows lowercase m7 label and disables the capitalize transform', async () => {
const progress = useProgressStore()
// Stage 3 (Tetrads), subStage 3 → 'm7'
progress.state.learning = { stage: 3, subStage: 3 }
const wrapper = mountView()
expect(wrapper.find('.quality-label').text()).toContain('m7')
expect(wrapper.find('.quality-label').classes()).toContain('quality-label--symbol')
})

it('shows the formatted mΔ7 label instead of leaking the raw mMaj7 key', async () => {
const progress = useProgressStore()
// Stage 3 (Tetrads), subStage 4 → 'mMaj7'
progress.state.learning = { stage: 3, subStage: 4 }
const wrapper = mountView()
expect(wrapper.find('.quality-label').text()).toContain('mΔ7')
expect(wrapper.find('.quality-label').text()).not.toContain('mMaj7')
expect(wrapper.find('.quality-label').classes()).toContain('quality-label--symbol')
})

it('reflects accumulatedScore progress in the progress bar', async () => {
const progress = useProgressStore()
// Default position is stage 1, subStage 1 → 'seconds' (3 intervals → 6 non-given cells,
Expand Down
10 changes: 10 additions & 0 deletions src/components/__tests__/TheoryModal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,14 @@ describe('TheoryModal', () => {
'5th mode of the harmonic minor scale',
)
})

it('shows lowercase m7 title without capitalizing the minor symbol', () => {
const wrapper = mount(TheoryModal, { props: { quality: 'm7' } })
expect(wrapper.find('.modal-title').text()).toBe('m7')
})

it('shows lowercase mΔ7 title without capitalizing the minor symbol', () => {
const wrapper = mount(TheoryModal, { props: { quality: 'mMaj7' } })
expect(wrapper.find('.modal-title').text()).toBe('mΔ7')
})
})
40 changes: 39 additions & 1 deletion src/music/__tests__/display.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { formatPuzzleTitle } from '@/music/display'
import { formatPuzzleTitle, isMinorSymbolLabel } from '@/music/display'

describe('formatPuzzleTitle', () => {
describe('triads', () => {
Expand Down Expand Up @@ -114,3 +114,41 @@ describe('formatPuzzleTitle', () => {
})
})
})

describe('isMinorSymbolLabel', () => {
it('returns true for m7', () => {
expect(isMinorSymbolLabel('m7')).toBe(true)
})

it('returns true for mMaj7', () => {
expect(isMinorSymbolLabel('mMaj7')).toBe(true)
})

it('returns false for the minor triad despite its m symbol', () => {
expect(isMinorSymbolLabel('minor')).toBe(false)
})

it('returns false for major', () => {
expect(isMinorSymbolLabel('major')).toBe(false)
})

it('returns false for sus2', () => {
expect(isMinorSymbolLabel('sus2')).toBe(false)
})

it('returns false for sus4', () => {
expect(isMinorSymbolLabel('sus4')).toBe(false)
})

it('returns false for dom7sus4', () => {
expect(isMinorSymbolLabel('dom7sus4')).toBe(false)
})

it('returns false for a scale mode', () => {
expect(isMinorSymbolLabel('ionian')).toBe(false)
})

it('returns false for an interval group', () => {
expect(isMinorSymbolLabel('seconds')).toBe(false)
})
})
7 changes: 7 additions & 0 deletions src/music/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const SCALE_LABEL: Record<ScaleMode, string> = {
halfWholeDim: 'half-whole diminished scale',
}

export function isMinorSymbolLabel(quality: ChordQuality | ScaleMode | IntervalGroup): boolean {
if (!(quality in CHORD_CATALOG)) return false
const q = quality as ChordQuality
if (TRIAD_LABEL[q]) return false
return CHORD_CATALOG[q].symbol.startsWith('m')
}

export function formatQualityLabel(quality: ChordQuality | ScaleMode | IntervalGroup): string {
if (quality in CHORD_CATALOG) {
const def = CHORD_CATALOG[quality as ChordQuality]
Expand Down