diff --git a/.gitignore b/.gitignore index 31fcd7ad..4cf5ccef 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,5 @@ vitest.config.*.timestamp* # Impeccable design skill — machine-local state (config.json is committed) .impeccable/config.local.json .impeccable/live/ +.impeccable/mocks/ +.impeccable/review/ diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 09b2636e..4dd60779 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,13 +1,15 @@ import { Logger, Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { ConfigService } from './config/config.service'; -import { ConfigController } from './config/config.controller'; +import { BundleJobService } from './bundles/bundle-job.service'; +import { BundlesController } from './bundles/bundles.controller'; +import { CollectionCacheService } from './cache/collection-cache.service'; import { CollectionsController } from './collections/collections.controller'; -import { ResourcesController } from './collections/resources/resources.controller'; import { FoldersController } from './collections/folders/folders.controller'; import { LocalesController } from './collections/locales/locales.controller'; -import { CollectionCacheService } from './cache/collection-cache.service'; +import { ResourcesController } from './collections/resources/resources.controller'; +import { ConfigController } from './config/config.controller'; +import { ConfigService } from './config/config.service'; import { TranslationJobService } from './translation-job/translation-job.service'; @Module({ @@ -19,8 +21,9 @@ import { TranslationJobService } from './translation-job/translation-job.service ResourcesController, FoldersController, LocalesController, + BundlesController, ], - providers: [AppService, ConfigService, CollectionCacheService, TranslationJobService, Logger], + providers: [AppService, ConfigService, CollectionCacheService, TranslationJobService, BundleJobService, Logger], }) export class AppModule { constructor() { diff --git a/apps/api/src/app/bundles/bundle-job.service.spec.ts b/apps/api/src/app/bundles/bundle-job.service.spec.ts new file mode 100644 index 00000000..79fd0634 --- /dev/null +++ b/apps/api/src/app/bundles/bundle-job.service.spec.ts @@ -0,0 +1,272 @@ +import { Logger } from '@nestjs/common'; +import type { BundleProgressEvent, GenerateBundleParams, GenerateBundleResult } from '@simoncodes-ca/core'; +import { BundleJobService, JOB_RETENTION_MS, MAX_RETAINED_JOBS } from './bundle-job.service'; + +const mockGenerateBundle = jest.fn(); + +jest.mock('@simoncodes-ca/core', () => { + const actual = jest.requireActual('@simoncodes-ca/core'); + return { + ...actual, + generateBundle: (params: unknown) => mockGenerateBundle(params), + }; +}); + +const flush = async (): Promise => { + await new Promise((resolve) => setImmediate(resolve)); +}; + +const bundleDefinition = { + bundleName: '{locale}', + dist: './dist/i18n', + collections: 'All' as const, + typeDistFile: './dist/i18n-types/main.ts', +}; + +const config = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr'], + collections: { app: { translationsFolder: './i18n' } }, +}; + +const makeResult = (overrides: Partial = {}): GenerateBundleResult => ({ + bundleKey: 'main', + filesGenerated: 2, + warnings: [], + localesProcessed: ['en', 'fr'], + keysPerLocale: { en: 5, fr: 5 }, + typeGenerationResult: { + bundleKey: 'main', + typeDistFile: './dist/i18n-types/main.ts', + keysCount: 5, + fileGenerated: true, + }, + ...overrides, +}); + +const makeParams = (bundleName = 'main') => ({ bundleName, bundleDefinition, config }); + +describe('BundleJobService', () => { + let service: BundleJobService; + let logger: jest.Mocked>; + + beforeEach(() => { + jest.clearAllMocks(); + logger = { error: jest.fn(), log: jest.fn(), warn: jest.fn() }; + service = new BundleJobService(logger as unknown as Logger); + }); + + it('startJob returns an ID and getJob exposes the pending job', () => { + mockGenerateBundle.mockReturnValue(new Promise(() => {})); + + const jobId = service.startJob(makeParams()); + const job = service.getJob(jobId); + + expect(typeof jobId).toBe('string'); + expect(job?.jobId).toBe(jobId); + expect(job?.bundleName).toBe('main'); + expect(job?.status).toBe('pending'); + expect(job?.progress).toEqual({ current: 0, total: 0 }); + }); + + it('getJob returns undefined for an unknown ID', () => { + expect(service.getJob('nope')).toBeUndefined(); + }); + + it('passes the bundle params, locales and an onProgress callback to generateBundle', async () => { + mockGenerateBundle.mockResolvedValue(makeResult()); + + service.startJob({ ...makeParams(), locales: ['fr'] }); + await flush(); + + expect(mockGenerateBundle).toHaveBeenCalledTimes(1); + const params = mockGenerateBundle.mock.calls[0][0] as GenerateBundleParams; + expect(params.bundleKey).toBe('main'); + expect(params.bundleDefinition).toBe(bundleDefinition); + expect(params.config).toBe(config); + expect(params.locales).toEqual(['fr']); + expect(typeof params.onProgress).toBe('function'); + }); + + it('omits locales from the core call when none were requested', async () => { + mockGenerateBundle.mockResolvedValue(makeResult()); + + service.startJob(makeParams()); + await flush(); + + const params = mockGenerateBundle.mock.calls[0][0] as GenerateBundleParams; + expect('locales' in params).toBe(false); + }); + + it('reflects onProgress events in the job snapshot while running', async () => { + let capturedProgress: ((event: BundleProgressEvent) => void) | undefined; + mockGenerateBundle.mockImplementation((params: GenerateBundleParams) => { + capturedProgress = params.onProgress; + return new Promise(() => {}); + }); + + const jobId = service.startJob(makeParams()); + await flush(); + + expect(service.getJob(jobId)?.status).toBe('running'); + expect(service.getJob(jobId)?.startedAt).toEqual(expect.any(String)); + + capturedProgress?.({ locale: 'fr', index: 2, total: 3, file: 'dist/i18n/fr.json' }); + + expect(service.getJob(jobId)?.progress).toEqual({ current: 2, total: 3, currentFile: 'dist/i18n/fr.json' }); + }); + + it('marks the job completed with the mapped result and ISO timestamps', async () => { + mockGenerateBundle.mockImplementation(async (params: GenerateBundleParams) => { + params.onProgress?.({ locale: 'en', index: 1, total: 2, file: 'dist/i18n/en.json' }); + params.onProgress?.({ locale: 'fr', index: 2, total: 2, file: 'dist/i18n/fr.json' }); + return makeResult({ warnings: ['careful'] }); + }); + + const jobId = service.startJob(makeParams()); + await flush(); + + const job = service.getJob(jobId); + expect(job?.status).toBe('completed'); + expect(job?.progress).toEqual({ current: 2, total: 2 }); + expect(job?.result?.localesProcessed).toEqual(['en', 'fr']); + expect(job?.result?.keysPerLocale).toEqual({ en: 5, fr: 5 }); + expect(job?.result?.warnings).toEqual(['careful']); + expect(job?.result?.typeDistFile).toBe('dist/i18n-types/main.ts'); + expect(job?.result?.typesKeysCount).toBe(5); + expect(job?.result?.filesGenerated).toHaveLength(3); + expect(job?.error).toBeUndefined(); + expect(() => new Date(job?.startedAt ?? '').toISOString()).not.toThrow(); + expect(() => new Date(job?.completedAt ?? '').toISOString()).not.toThrow(); + }); + + it('marks the job failed with the error message and logs it', async () => { + mockGenerateBundle.mockRejectedValue(new Error('disk full')); + + const jobId = service.startJob(makeParams()); + await flush(); + + const job = service.getJob(jobId); + expect(job?.status).toBe('failed'); + expect(job?.error).toBe('disk full'); + expect(job?.result).toBeUndefined(); + expect(job?.completedAt).toEqual(expect.any(String)); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('disk full')); + }); + + it('uses a fallback message when the rejection is not an Error', async () => { + mockGenerateBundle.mockRejectedValue('boom'); + + const jobId = service.startJob(makeParams()); + await flush(); + + expect(service.getJob(jobId)?.error).toBe('An unexpected error occurred'); + }); + + it('runs jobs one at a time, in order', async () => { + let resolveFirst: ((value: GenerateBundleResult) => void) | undefined; + mockGenerateBundle + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(makeResult({ bundleKey: 'second' })); + + const firstId = service.startJob(makeParams('first')); + const secondId = service.startJob(makeParams('second')); + await flush(); + + expect(mockGenerateBundle).toHaveBeenCalledTimes(1); + expect(service.getJob(firstId)?.status).toBe('running'); + expect(service.getJob(secondId)?.status).toBe('pending'); + + resolveFirst?.(makeResult()); + await flush(); + + expect(mockGenerateBundle).toHaveBeenCalledTimes(2); + expect(service.getJob(firstId)?.status).toBe('completed'); + expect(service.getJob(secondId)?.status).toBe('completed'); + }); + + it('keeps running the queue after a failed job', async () => { + mockGenerateBundle.mockRejectedValueOnce(new Error('first failed')).mockResolvedValueOnce(makeResult()); + + const firstId = service.startJob(makeParams('first')); + const secondId = service.startJob(makeParams('second')); + await flush(); + + expect(service.getJob(firstId)?.status).toBe('failed'); + expect(service.getJob(secondId)?.status).toBe('completed'); + }); + + describe('eviction', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('evicts finished jobs older than the retention window on the next startJob', async () => { + mockGenerateBundle.mockResolvedValue(makeResult()); + + const oldId = service.startJob(makeParams()); + await jest.advanceTimersByTimeAsync(0); + expect(service.getJob(oldId)?.status).toBe('completed'); + + jest.advanceTimersByTime(JOB_RETENTION_MS + 1000); + + const newId = service.startJob(makeParams()); + + expect(service.getJob(oldId)).toBeUndefined(); + expect(service.getJob(newId)).toBeDefined(); + }); + + it('keeps recently finished jobs', async () => { + mockGenerateBundle.mockResolvedValue(makeResult()); + + const recentId = service.startJob(makeParams()); + await jest.advanceTimersByTimeAsync(0); + + jest.advanceTimersByTime(JOB_RETENTION_MS - 1000); + service.startJob(makeParams()); + + expect(service.getJob(recentId)?.status).toBe('completed'); + }); + + it('never evicts a job that has not finished', async () => { + mockGenerateBundle.mockReturnValue(new Promise(() => {})); + + const stuckId = service.startJob(makeParams()); + await jest.advanceTimersByTimeAsync(0); + + jest.advanceTimersByTime(JOB_RETENTION_MS * 2); + service.startJob(makeParams()); + + expect(service.getJob(stuckId)?.status).toBe('running'); + }); + + it('caps retained jobs by evicting the oldest finished ones first', async () => { + mockGenerateBundle.mockResolvedValue(makeResult()); + + const ids: string[] = []; + for (let index = 0; index < MAX_RETAINED_JOBS; index++) { + ids.push(service.startJob(makeParams())); + await jest.advanceTimersByTimeAsync(1); + } + + expect(ids.every((id) => service.getJob(id)?.status === 'completed')).toBe(true); + + const extraId = service.startJob(makeParams()); + + expect(service.getJob(ids[0])).toBeUndefined(); + expect(service.getJob(ids[1])).toBeDefined(); + expect(service.getJob(extraId)).toBeDefined(); + }); + }); +}); diff --git a/apps/api/src/app/bundles/bundle-job.service.ts b/apps/api/src/app/bundles/bundle-job.service.ts new file mode 100644 index 00000000..8bfb75c1 --- /dev/null +++ b/apps/api/src/app/bundles/bundle-job.service.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable, Logger } from '@nestjs/common'; +import type { BundleDefinition, BundleProgressEvent, LingoTrackerConfig } from '@simoncodes-ca/core'; +import { generateBundle } from '@simoncodes-ca/core'; +import type { + BundleGenerateJobDto, + BundleGenerateJobProgressDto, + BundleGenerateJobResultDto, + BundleGenerateJobStatus, +} from '@simoncodes-ca/data-transfer'; +import { mapGenerateBundleResultToJobResult } from '../mappers/bundle.mapper'; + +export interface StartBundleJobParams { + readonly bundleName: string; + readonly bundleDefinition: BundleDefinition; + readonly config: LingoTrackerConfig; + readonly locales?: string[]; +} + +interface BundleJob { + jobId: string; + bundleName: string; + status: BundleGenerateJobStatus; + progress: BundleGenerateJobProgressDto; + result?: BundleGenerateJobResultDto; + error?: string; + startedAt?: Date; + completedAt?: Date; +} + +/** Finished jobs older than this are evicted on the next `startJob`. */ +export const JOB_RETENTION_MS = 30 * 60 * 1000; +/** Upper bound on retained jobs; the oldest finished ones are evicted first. */ +export const MAX_RETAINED_JOBS = 100; + +/** + * Runs bundle generation as fire-and-forget jobs that the caller polls. + * + * Jobs are queued and run one at a time: two bundles may write the same output + * folder, and type generation reads the config from disk, so overlapping runs + * could interleave writes. + */ +@Injectable() +export class BundleJobService { + readonly #logger: Logger; + readonly #jobs = new Map(); + #queue: Promise = Promise.resolve(); + + constructor(logger: Logger) { + this.#logger = logger; + } + + /** Registers a job, schedules it behind any running job, and returns its ID. */ + startJob(params: StartBundleJobParams): string { + this.#evictFinishedJobs(); + + const jobId = randomUUID(); + this.#jobs.set(jobId, { + jobId, + bundleName: params.bundleName, + status: 'pending', + progress: { current: 0, total: 0 }, + }); + + this.#queue = this.#queue.then(() => this.#runJob(jobId, params)); + + return jobId; + } + + /** Returns the current snapshot of a job, or undefined if the job ID is unknown. */ + getJob(jobId: string): BundleGenerateJobDto | undefined { + const job = this.#jobs.get(jobId); + return job ? this.#toDto(job) : undefined; + } + + async #runJob(jobId: string, params: StartBundleJobParams): Promise { + const job = this.#jobs.get(jobId); + if (!job) return; + + job.status = 'running'; + job.startedAt = new Date(); + + const onProgress = (event: BundleProgressEvent): void => { + const running = this.#jobs.get(jobId); + if (!running) return; + running.progress = { current: event.index, total: event.total, currentFile: event.file }; + }; + + try { + const result = await generateBundle({ + bundleKey: params.bundleName, + bundleDefinition: params.bundleDefinition, + config: params.config, + ...(params.locales && { locales: params.locales }), + onProgress, + }); + + const completed = this.#jobs.get(jobId); + if (!completed) return; + + completed.status = 'completed'; + completed.completedAt = new Date(); + completed.progress = { current: completed.progress.total, total: completed.progress.total }; + completed.result = mapGenerateBundleResultToJobResult(result, params.bundleDefinition); + } catch (error: unknown) { + const failed = this.#jobs.get(jobId); + if (!failed) return; + + failed.status = 'failed'; + failed.completedAt = new Date(); + failed.error = error instanceof Error ? error.message : 'An unexpected error occurred'; + + this.#logger.error(`Bundle job ${jobId} (${params.bundleName}) failed: ${failed.error}`); + } + } + + #evictFinishedJobs(): void { + const now = Date.now(); + const finished = [...this.#jobs.values()] + .filter((job) => job.completedAt !== undefined) + .sort((a, b) => (a.completedAt?.getTime() ?? 0) - (b.completedAt?.getTime() ?? 0)); + + for (const job of finished) { + const completedAt = job.completedAt?.getTime() ?? now; + if (now - completedAt > JOB_RETENTION_MS) { + this.#jobs.delete(job.jobId); + } + } + + // `startJob` adds one more after this, so leave room for it. + let excess = this.#jobs.size - (MAX_RETAINED_JOBS - 1); + for (const job of finished) { + if (excess <= 0) break; + if (this.#jobs.delete(job.jobId)) excess--; + } + } + + #toDto(job: BundleJob): BundleGenerateJobDto { + return { + jobId: job.jobId, + bundleName: job.bundleName, + status: job.status, + progress: { ...job.progress }, + ...(job.result && { result: job.result }), + ...(job.error !== undefined && { error: job.error }), + ...(job.startedAt && { startedAt: job.startedAt.toISOString() }), + ...(job.completedAt && { completedAt: job.completedAt.toISOString() }), + }; + } +} diff --git a/apps/api/src/app/bundles/bundles.controller.spec.ts b/apps/api/src/app/bundles/bundles.controller.spec.ts new file mode 100644 index 00000000..60198bde --- /dev/null +++ b/apps/api/src/app/bundles/bundles.controller.spec.ts @@ -0,0 +1,345 @@ +import { ConflictException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import type { BundleDefinition, BundlePlan, LingoTrackerConfig } from '@simoncodes-ca/core'; +import * as core from '@simoncodes-ca/core'; +import type { BundleDefinitionDto } from '@simoncodes-ca/data-transfer'; +import type { Response } from 'express'; +import { ConfigService } from '../config/config.service'; +import { BundleJobService } from './bundle-job.service'; +import { BundlesController } from './bundles.controller'; + +jest.mock('@simoncodes-ca/core', () => ({ + addBundleDefinition: jest.fn(), + updateBundleDefinition: jest.fn(), + deleteBundleDefinition: jest.fn(), + planBundle: jest.fn(), + validateBundleKey: jest.fn(() => []), + validateBundleDefinition: jest.fn(() => []), + getBundleOutputPath: jest.fn((definition: BundleDefinition, locale: string) => `${definition.dist}/${locale}.json`), +})); + +const existingDefinition: BundleDefinition = { + bundleName: '{locale}', + dist: './apps/tracker/src/assets/i18n', + collections: [{ name: 'trackerResources', entriesSelectionRules: 'All' }], + typeDistFile: './apps/tracker/src/i18n-types/tracker-resources.ts', +}; + +const config: LingoTrackerConfig = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr-ca'], + collections: { trackerResources: { translationsFolder: 'apps/tracker/src/i18n' } }, + bundles: { tracker: existingDefinition, other: { ...existingDefinition, dist: './dist/other' } }, +}; + +const requestDefinition: BundleDefinitionDto = { + bundleName: 'main.{locale}', + dist: './dist/i18n', + collections: 'All', +}; + +const plan: BundlePlan = { + bundleKey: 'preview', + locales: ['en', 'fr-ca'], + files: [ + { + path: 'dist/i18n/main.en.json', + absolutePath: '/w/dist/i18n/main.en.json', + kind: 'bundle', + locale: 'en', + exists: false, + keysCount: 2, + }, + ], + keysPerLocale: { en: 2, 'fr-ca': 2 }, + conflictsCount: 0, + conflictKeys: [], + hierarchicalConflicts: [], + warnings: [], +}; + +const statusOf = (fn: () => unknown): number => { + try { + fn(); + } catch (error: unknown) { + if (error instanceof HttpException) return error.getStatus(); + throw error; + } + throw new Error('expected an HttpException'); +}; + +const makeResponse = (): { response: Response; status: jest.Mock; json: jest.Mock } => { + const json = jest.fn(); + const status = jest.fn(() => ({ json })); + return { response: { status } as unknown as Response, status, json }; +}; + +describe('BundlesController', () => { + let moduleRef: TestingModule; + let controller: BundlesController; + + const configService = { getConfig: jest.fn() }; + const jobService = { startJob: jest.fn(), getJob: jest.fn() }; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + controllers: [BundlesController], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: BundleJobService, useValue: jobService }, + ], + }).compile(); + + controller = moduleRef.get(BundlesController); + }); + + beforeEach(() => { + jest.clearAllMocks(); + configService.getConfig.mockReturnValue(config); + (core.validateBundleKey as jest.Mock).mockReturnValue([]); + (core.validateBundleDefinition as jest.Mock).mockReturnValue([]); + }); + + describe('POST /bundles', () => { + it('validates then adds the mapped definition', () => { + (core.addBundleDefinition as jest.Mock).mockReturnValue({ message: 'Bundle "main" added successfully' }); + + const result = controller.createBundle({ + name: ' main ', + bundle: { ...requestDefinition, dist: ' ./dist/i18n ' }, + }); + + expect(result).toEqual({ message: 'Bundle "main" added successfully' }); + expect(core.validateBundleKey).toHaveBeenCalledWith('main'); + expect(core.validateBundleDefinition).toHaveBeenCalledWith( + { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, + config, + ); + expect(core.addBundleDefinition).toHaveBeenCalledWith( + 'main', + { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, + { cwd: process.cwd() }, + ); + }); + + it('returns 400 with every validation message and does not write', () => { + (core.validateBundleKey as jest.Mock).mockReturnValue(['Bundle name is required.']); + (core.validateBundleDefinition as jest.Mock).mockReturnValue(['dist (output folder) is required.']); + + try { + controller.createBundle({ name: 'x', bundle: requestDefinition }); + throw new Error('expected an HttpException'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect((error as HttpException).getResponse()).toEqual({ + message: 'Invalid bundle definition', + errors: ['Bundle name is required.', 'dist (output folder) is required.'], + }); + } + expect(core.addBundleDefinition).not.toHaveBeenCalled(); + }); + + it('returns 400 when the body carries no definition', () => { + expect(statusOf(() => controller.createBundle({ name: 'main' } as never))).toBe(HttpStatus.BAD_REQUEST); + expect(statusOf(() => controller.createBundle({ bundle: requestDefinition } as never))).toBe( + HttpStatus.BAD_REQUEST, + ); + }); + + it('returns 409 when core reports the bundle already exists', () => { + (core.addBundleDefinition as jest.Mock).mockImplementation(() => { + throw new Error('Bundle "main" already exists'); + }); + + expect(() => controller.createBundle({ name: 'main', bundle: requestDefinition })).toThrow(ConflictException); + }); + }); + + describe('PUT /bundles/:name', () => { + it('returns 404 when the bundle does not exist', () => { + expect(() => controller.updateBundle('missing', { bundle: requestDefinition })).toThrow(NotFoundException); + expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + }); + + it('updates in place when no rename is requested', () => { + (core.updateBundleDefinition as jest.Mock).mockReturnValue({ message: 'updated' }); + + const result = controller.updateBundle('tracker', { bundle: requestDefinition }); + + expect(result).toEqual({ message: 'updated' }); + expect(core.updateBundleDefinition).toHaveBeenCalledWith( + 'tracker', + { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, + { cwd: process.cwd() }, + ); + }); + + it('URI-decodes the name and renames via body.name', () => { + (core.updateBundleDefinition as jest.Mock).mockReturnValue({ message: 'renamed' }); + + controller.updateBundle('tracker', { name: 'tracker-v2', bundle: requestDefinition }); + + expect(core.validateBundleKey).toHaveBeenCalledWith('tracker-v2'); + expect(core.updateBundleDefinition).toHaveBeenCalledWith('tracker', expect.any(Object), { + cwd: process.cwd(), + newKey: 'tracker-v2', + }); + }); + + it('returns 409 when renaming onto an existing bundle', () => { + expect(() => controller.updateBundle('tracker', { name: 'other', bundle: requestDefinition })).toThrow( + ConflictException, + ); + expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + }); + + it('returns 400 when the definition is invalid', () => { + (core.validateBundleDefinition as jest.Mock).mockReturnValue(['bundleName is required.']); + + expect(statusOf(() => controller.updateBundle('tracker', { bundle: requestDefinition }))).toBe( + HttpStatus.BAD_REQUEST, + ); + expect(core.updateBundleDefinition).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /bundles/:name', () => { + it('deletes an existing bundle', () => { + (core.deleteBundleDefinition as jest.Mock).mockReturnValue({ message: 'deleted' }); + + expect(controller.deleteBundle('tracker')).toEqual({ message: 'deleted' }); + expect(core.deleteBundleDefinition).toHaveBeenCalledWith('tracker', { cwd: process.cwd() }); + }); + + it('returns 404 for an unknown bundle', () => { + expect(() => controller.deleteBundle('missing')).toThrow(NotFoundException); + expect(core.deleteBundleDefinition).not.toHaveBeenCalled(); + }); + + it('maps a core not-found error to 404', () => { + (core.deleteBundleDefinition as jest.Mock).mockImplementation(() => { + throw new Error('Bundle "tracker" not found'); + }); + + expect(() => controller.deleteBundle('tracker')).toThrow(NotFoundException); + }); + }); + + describe('POST /bundles/dry-run', () => { + it('plans the definition from the request body, not the saved one', () => { + (core.planBundle as jest.Mock).mockReturnValue(plan); + + const result = controller.dryRun({ name: 'preview', bundle: requestDefinition, locales: ['en'] }); + + expect(core.planBundle).toHaveBeenCalledWith({ + bundleKey: 'preview', + bundleDefinition: { bundleName: 'main.{locale}', dist: './dist/i18n', collections: 'All' }, + config, + locales: ['en'], + cwd: process.cwd(), + }); + expect(result.name).toBe('preview'); + expect(result.files).toEqual([ + { path: 'dist/i18n/main.en.json', kind: 'bundle', locale: 'en', exists: false, keysCount: 2 }, + ]); + }); + + it('omits locales from the plan when the request has none', () => { + (core.planBundle as jest.Mock).mockReturnValue(plan); + + controller.dryRun({ name: 'preview', bundle: requestDefinition }); + + expect('locales' in (core.planBundle as jest.Mock).mock.calls[0][0]).toBe(false); + }); + + it('returns 400 for an invalid definition without planning', () => { + (core.validateBundleDefinition as jest.Mock).mockReturnValue(['dist (output folder) is required.']); + + expect(statusOf(() => controller.dryRun({ name: 'preview', bundle: requestDefinition }))).toBe( + HttpStatus.BAD_REQUEST, + ); + expect(core.planBundle).not.toHaveBeenCalled(); + }); + + it('returns 400 for a locale outside the project locales', () => { + expect(statusOf(() => controller.dryRun({ name: 'preview', bundle: requestDefinition, locales: ['xx'] }))).toBe( + HttpStatus.BAD_REQUEST, + ); + expect(core.planBundle).not.toHaveBeenCalled(); + }); + }); + + describe('POST /bundles/:name/generate', () => { + it('starts a job and answers 202 with its snapshot', () => { + const snapshot = { jobId: 'job-1', bundleName: 'tracker', status: 'pending', progress: { current: 0, total: 0 } }; + jobService.startJob.mockReturnValue('job-1'); + jobService.getJob.mockReturnValue(snapshot); + const { response, status, json } = makeResponse(); + + controller.generateBundle('tracker', { locales: ['fr-ca'] }, response); + + expect(jobService.startJob).toHaveBeenCalledWith({ + bundleName: 'tracker', + bundleDefinition: existingDefinition, + config, + locales: ['fr-ca'], + }); + expect(status).toHaveBeenCalledWith(HttpStatus.ACCEPTED); + expect(json).toHaveBeenCalledWith(snapshot); + }); + + it('tolerates an empty body', () => { + jobService.startJob.mockReturnValue('job-2'); + jobService.getJob.mockReturnValue({}); + const { response, status } = makeResponse(); + + controller.generateBundle('tracker', undefined as never, response); + + expect(jobService.startJob).toHaveBeenCalledWith({ + bundleName: 'tracker', + bundleDefinition: existingDefinition, + config, + }); + expect(status).toHaveBeenCalledWith(HttpStatus.ACCEPTED); + }); + + it('returns 404 for an unknown bundle', () => { + const { response } = makeResponse(); + + expect(() => controller.generateBundle('missing', {}, response)).toThrow(NotFoundException); + expect(jobService.startJob).not.toHaveBeenCalled(); + }); + + it('returns 400 for a locale outside the project locales', () => { + const { response } = makeResponse(); + + expect(statusOf(() => controller.generateBundle('tracker', { locales: ['en', 'xx'] }, response))).toBe( + HttpStatus.BAD_REQUEST, + ); + expect(jobService.startJob).not.toHaveBeenCalled(); + }); + }); + + describe('GET /bundles/jobs/:jobId', () => { + it('returns the job snapshot', () => { + const snapshot = { + jobId: 'job-1', + bundleName: 'tracker', + status: 'completed', + progress: { current: 2, total: 2 }, + }; + jobService.getJob.mockReturnValue(snapshot); + + expect(controller.getJob('job-1')).toBe(snapshot); + }); + + it('returns 404 for an unknown job', () => { + jobService.getJob.mockReturnValue(undefined); + + expect(() => controller.getJob('nope')).toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/app/bundles/bundles.controller.ts b/apps/api/src/app/bundles/bundles.controller.ts new file mode 100644 index 00000000..08a54170 --- /dev/null +++ b/apps/api/src/app/bundles/bundles.controller.ts @@ -0,0 +1,234 @@ +import { + Body, + ConflictException, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + NotFoundException, + Param, + Post, + Put, + Res, +} from '@nestjs/common'; +import type { BundleDefinition, LingoTrackerConfig } from '@simoncodes-ca/core'; +import { + addBundleDefinition, + deleteBundleDefinition, + planBundle, + updateBundleDefinition, + validateBundleDefinition, + validateBundleKey, +} from '@simoncodes-ca/core'; +import type { + BundleDefinitionDto, + BundleDryRunRequestDto, + BundleDryRunResultDto, + BundleGenerateJobDto, + CreateBundleDto, + GenerateBundleRequestDto, + UpdateBundleDto, +} from '@simoncodes-ca/data-transfer'; +import type { Response } from 'express'; +import { ConfigService } from '../config/config.service'; +import { mapBundlePlanToDto, mapDtoToBundleDefinition } from '../mappers/bundle.mapper'; +import { BundleJobService } from './bundle-job.service'; + +const INVALID_DEFINITION_MESSAGE = 'Invalid bundle definition'; + +@Controller('bundles') +export class BundlesController { + readonly #configService: ConfigService; + readonly #jobService: BundleJobService; + + constructor(configService: ConfigService, jobService: BundleJobService) { + this.#configService = configService; + this.#jobService = jobService; + } + + /** Plans a bundle from the request body. The definition need not be saved. */ + @Post('dry-run') + dryRun(@Body() body: BundleDryRunRequestDto): BundleDryRunResultDto { + try { + const config = this.#configService.getConfig(); + const name = requireName(body?.name); + const definition = this.#validatedDefinition(name, body?.bundle, config); + const locales = this.#validatedLocales(body?.locales, config); + + const plan = planBundle({ + bundleKey: name, + bundleDefinition: definition, + config, + ...(locales && { locales }), + cwd: process.cwd(), + }); + + return mapBundlePlanToDto(plan); + } catch (error: unknown) { + throw this.#toHttpException(error, 'Error planning bundle'); + } + } + + @Get('jobs/:jobId') + getJob(@Param('jobId') jobId: string): BundleGenerateJobDto { + const job = this.#jobService.getJob(jobId); + + if (job === undefined) { + throw new NotFoundException(`Bundle job "${jobId}" not found`); + } + + return job; + } + + @Post() + createBundle(@Body() body: CreateBundleDto): { message: string } { + try { + const config = this.#configService.getConfig(); + const name = requireName(body?.name); + const definition = this.#validatedDefinition(name, body?.bundle, config); + + return addBundleDefinition(name, definition, { cwd: process.cwd() }); + } catch (error: unknown) { + throw this.#toHttpException(error, 'Error creating bundle'); + } + } + + @Put(':name') + updateBundle(@Param('name') name: string, @Body() body: UpdateBundleDto): { message: string } { + try { + const decodedName = decodeURIComponent(name); + const config = this.#configService.getConfig(); + this.#requireExistingBundle(decodedName, config); + + const newName = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined; + const targetName = newName ?? decodedName; + const definition = this.#validatedDefinition(targetName, body?.bundle, config); + + if (newName !== undefined && newName !== decodedName && config.bundles?.[newName]) { + throw new ConflictException(`Bundle "${newName}" already exists`); + } + + return updateBundleDefinition(decodedName, definition, { + cwd: process.cwd(), + ...(newName !== undefined && newName !== decodedName && { newKey: newName }), + }); + } catch (error: unknown) { + throw this.#toHttpException(error, 'Error updating bundle'); + } + } + + @Delete(':name') + deleteBundle(@Param('name') name: string): { message: string } { + try { + const decodedName = decodeURIComponent(name); + const config = this.#configService.getConfig(); + this.#requireExistingBundle(decodedName, config); + + return deleteBundleDefinition(decodedName, { cwd: process.cwd() }); + } catch (error: unknown) { + throw this.#toHttpException(error, 'Error deleting bundle'); + } + } + + /** Starts a generation job for a saved bundle and answers 202 with the job snapshot. */ + @Post(':name/generate') + generateBundle(@Param('name') name: string, @Body() body: GenerateBundleRequestDto, @Res() response: Response): void { + const decodedName = decodeURIComponent(name); + const config = this.#configService.getConfig(); + const bundleDefinition = this.#requireExistingBundle(decodedName, config); + const locales = this.#validatedLocales(body?.locales, config); + + const jobId = this.#jobService.startJob({ + bundleName: decodedName, + bundleDefinition, + config, + ...(locales && { locales }), + }); + + const job = this.#jobService.getJob(jobId); + response.status(HttpStatus.ACCEPTED).json(job); + } + + #requireExistingBundle(name: string, config: LingoTrackerConfig): BundleDefinition { + const definition = config.bundles?.[name]; + + if (!definition) { + throw new NotFoundException(`Bundle "${name}" not found`); + } + + return definition; + } + + /** Maps and validates a definition body; throws 400 with every message when invalid. */ + #validatedDefinition( + name: string, + dto: BundleDefinitionDto | undefined, + config: LingoTrackerConfig, + ): BundleDefinition { + if (!dto || typeof dto !== 'object') { + throw new HttpException( + { message: INVALID_DEFINITION_MESSAGE, errors: ['bundle definition is required.'] }, + HttpStatus.BAD_REQUEST, + ); + } + + const definition = mapDtoToBundleDefinition(dto); + const errors = [...validateBundleKey(name), ...validateBundleDefinition(definition, config)]; + + if (errors.length > 0) { + throw new HttpException({ message: INVALID_DEFINITION_MESSAGE, errors }, HttpStatus.BAD_REQUEST); + } + + return definition; + } + + /** Accepts an optional locale subset; every entry must be a configured locale. */ + #validatedLocales(locales: readonly string[] | undefined, config: LingoTrackerConfig): string[] | undefined { + if (locales === undefined) { + return undefined; + } + + if (!Array.isArray(locales) || locales.some((locale) => typeof locale !== 'string')) { + throw new HttpException('locales must be an array of strings', HttpStatus.BAD_REQUEST); + } + + const unknown = locales.filter((locale) => !config.locales.includes(locale)); + if (unknown.length > 0) { + throw new HttpException( + `Unknown locale${unknown.length > 1 ? 's' : ''} ${unknown.map((l) => `"${l}"`).join(', ')}: must be defined in the project locales`, + HttpStatus.BAD_REQUEST, + ); + } + + return [...locales]; + } + + #toHttpException(error: unknown, fallback: string): HttpException { + if (error instanceof HttpException) { + return error; + } + + const message = error instanceof Error ? error.message : fallback; + + if (message.includes('not found')) { + return new NotFoundException(message); + } + + if (message.includes('already exists')) { + return new ConflictException(message); + } + + return new HttpException(message, HttpStatus.BAD_REQUEST); + } +} + +function requireName(name: unknown): string { + if (typeof name !== 'string' || name.trim().length === 0) { + throw new HttpException( + { message: INVALID_DEFINITION_MESSAGE, errors: ['Bundle name is required.'] }, + HttpStatus.BAD_REQUEST, + ); + } + return name.trim(); +} diff --git a/apps/api/src/app/config/config.controller.spec.ts b/apps/api/src/app/config/config.controller.spec.ts index c0f11f19..4a395234 100644 --- a/apps/api/src/app/config/config.controller.spec.ts +++ b/apps/api/src/app/config/config.controller.spec.ts @@ -1,9 +1,10 @@ -import { Test, type TestingModule } from '@nestjs/testing'; +import { basename } from 'node:path'; import { HttpException } from '@nestjs/common'; -import { ConfigController } from './config.controller'; -import { ConfigService } from './config.service'; +import { Test, type TestingModule } from '@nestjs/testing'; import { resolveProtectedTermsForConfig, setGlobalProtectedTerms } from '@simoncodes-ca/core'; import * as mapper from '../mappers/config.mapper'; +import { ConfigController } from './config.controller'; +import { ConfigService } from './config.service'; jest.mock('@simoncodes-ca/core', () => ({ setGlobalProtectedTerms: jest.fn(), @@ -59,7 +60,7 @@ describe('ConfigController', () => { const mapSpy = jest.spyOn(mapper, 'mapConfigToDto'); controller.getConfig(); - expect(mapSpy).toHaveBeenCalledWith(baseConfig, resolved); + expect(mapSpy).toHaveBeenCalledWith(baseConfig, resolved, basename(process.cwd())); }); it('exposes the resolved terms and their file path on the DTO', () => { @@ -74,6 +75,12 @@ describe('ConfigController', () => { expect(dto.protectedTerms).toEqual(['iPhone']); expect(dto.protectedTermsFilePath).toBe('/project/.lingo-tracker-protected-terms.json'); }); + + it('exposes the served workspace folder name as projectName', () => { + const dto = controller.getConfig(); + + expect(dto.projectName).toBe(basename(process.cwd())); + }); }); describe('updateConfig', () => { diff --git a/apps/api/src/app/config/config.controller.ts b/apps/api/src/app/config/config.controller.ts index 475b396c..bc8830af 100644 --- a/apps/api/src/app/config/config.controller.ts +++ b/apps/api/src/app/config/config.controller.ts @@ -1,3 +1,4 @@ +import { basename } from 'node:path'; import { Body, Controller, Get, HttpException, HttpStatus, Put } from '@nestjs/common'; import { resolveProtectedTermsForConfig, setGlobalProtectedTerms } from '@simoncodes-ca/core'; import type { LingoTrackerConfigDto, UpdateConfigDto } from '@simoncodes-ca/data-transfer'; @@ -11,7 +12,7 @@ export class ConfigController { @Get() getConfig(): LingoTrackerConfigDto { const config = this.configService.getConfig(); - return mapConfigToDto(config, resolveProtectedTermsForConfig(config)); + return mapConfigToDto(config, resolveProtectedTermsForConfig(config), basename(process.cwd())); } /** diff --git a/apps/api/src/app/mappers/bundle.mapper.spec.ts b/apps/api/src/app/mappers/bundle.mapper.spec.ts new file mode 100644 index 00000000..aacd6452 --- /dev/null +++ b/apps/api/src/app/mappers/bundle.mapper.spec.ts @@ -0,0 +1,255 @@ +import type { BundleDefinition, BundlePlan, GenerateBundleResult } from '@simoncodes-ca/core'; +import type { BundleDefinitionDto } from '@simoncodes-ca/data-transfer'; +import { + MAX_CONFLICT_KEYS, + mapBundleDefinitionToDto, + mapBundlePlanToDto, + mapDtoToBundleDefinition, + mapGenerateBundleResultToJobResult, +} from './bundle.mapper'; + +describe('bundle.mapper', () => { + const fullDefinition: BundleDefinition = { + bundleName: 'main.{locale}', + dist: './dist/i18n', + collections: [ + { + name: 'app', + bundledKeyPrefix: 'app', + entriesSelectionRules: [{ matchingPattern: 'apps.*', matchingTags: ['ui'], matchingTagOperator: 'All' }], + mergeStrategy: 'override', + }, + ], + typeDistFile: './dist/i18n-types/main.ts', + tokenCasing: 'camelCase', + tokenConstantName: 'MAIN_KEYS', + transformICUToTransloco: false, + }; + + describe('mapBundleDefinitionToDto', () => { + it('maps every field and copies nested arrays', () => { + const dto = mapBundleDefinitionToDto(fullDefinition); + + expect(dto).toEqual(fullDefinition); + expect(dto.collections).not.toBe(fullDefinition.collections); + const [collection] = dto.collections as Exclude; + const [sourceCollection] = fullDefinition.collections as Exclude; + expect(collection.entriesSelectionRules).not.toBe(sourceCollection.entriesSelectionRules); + }); + + it("passes 'All' through and omits absent optionals", () => { + const dto = mapBundleDefinitionToDto({ bundleName: '{locale}', dist: './dist', collections: 'All' }); + + expect(dto).toEqual({ bundleName: '{locale}', dist: './dist', collections: 'All' }); + expect('typeDistFile' in dto).toBe(false); + expect('transformICUToTransloco' in dto).toBe(false); + }); + }); + + describe('mapDtoToBundleDefinition', () => { + it('trims strings and drops empty optionals', () => { + const dto: BundleDefinitionDto = { + bundleName: ' main.{locale} ', + dist: ' ./dist ', + collections: [ + { + name: ' app ', + bundledKeyPrefix: ' ', + entriesSelectionRules: [{ matchingPattern: ' apps.* ', matchingTags: [' ui ', ''] }], + }, + ], + typeDistFile: '', + tokenConstantName: ' ', + }; + + const definition = mapDtoToBundleDefinition(dto); + + expect(definition).toEqual({ + bundleName: 'main.{locale}', + dist: './dist', + collections: [{ name: 'app', entriesSelectionRules: [{ matchingPattern: 'apps.*', matchingTags: ['ui'] }] }], + }); + expect('typeDistFile' in definition).toBe(false); + expect('tokenConstantName' in definition).toBe(false); + }); + + it('keeps explicit false for transformICUToTransloco and passes All through', () => { + const definition = mapDtoToBundleDefinition({ + bundleName: '{locale}', + dist: './dist', + collections: 'All', + transformICUToTransloco: false, + tokenCasing: 'upperCase', + }); + + expect(definition.transformICUToTransloco).toBe(false); + expect(definition.tokenCasing).toBe('upperCase'); + expect(definition.collections).toBe('All'); + }); + + it('does not alias arrays from the DTO', () => { + const tags = ['ui']; + const dto: BundleDefinitionDto = { + bundleName: '{locale}', + dist: './dist', + collections: [{ name: 'app', entriesSelectionRules: [{ matchingPattern: '*', matchingTags: tags }] }], + }; + + const definition = mapDtoToBundleDefinition(dto); + const [collection] = definition.collections as Exclude; + const [rule] = collection.entriesSelectionRules as Exclude; + + expect(rule.matchingTags).toEqual(['ui']); + expect(rule.matchingTags).not.toBe(tags); + }); + + it('round-trips a full definition', () => { + expect(mapDtoToBundleDefinition(mapBundleDefinitionToDto(fullDefinition))).toEqual(fullDefinition); + }); + }); + + describe('mapBundlePlanToDto', () => { + const plan: BundlePlan = { + bundleKey: 'main', + locales: ['en', 'fr'], + files: [ + { + path: 'dist/en.json', + absolutePath: '/w/dist/en.json', + kind: 'bundle', + locale: 'en', + exists: true, + keysCount: 3, + }, + { path: 'dist/main.ts', absolutePath: '/w/dist/main.ts', kind: 'types', exists: false, keysCount: 3 }, + ], + keysPerLocale: { en: 3, fr: 2 }, + conflictsCount: 60, + conflictKeys: Array.from({ length: 60 }, (_, index) => `key.${index}`), + hierarchicalConflicts: Array.from({ length: 60 }, (_, index) => `parent.${index}`), + exampleKey: { collectionName: 'app', sourceKey: 'a.b', bundledKey: 'a.b', tokenPath: 'MAIN_TOKENS.A.B' }, + warnings: ['w'], + }; + + it('drops absolutePath and keeps locale only on bundle files', () => { + const dto = mapBundlePlanToDto(plan); + + expect(dto.files[0]).toEqual({ path: 'dist/en.json', kind: 'bundle', locale: 'en', exists: true, keysCount: 3 }); + expect(dto.files[1]).toEqual({ path: 'dist/main.ts', kind: 'types', exists: false, keysCount: 3 }); + expect('absolutePath' in dto.files[0]).toBe(false); + expect('locale' in dto.files[1]).toBe(false); + }); + + it('caps conflictKeys but reports the full conflictsCount', () => { + const dto = mapBundlePlanToDto(plan); + + expect(dto.conflictKeys).toHaveLength(MAX_CONFLICT_KEYS); + expect(dto.conflictsCount).toBe(60); + }); + + it('caps hierarchicalConflicts the same way as conflictKeys', () => { + const dto = mapBundlePlanToDto(plan); + + expect(dto.hierarchicalConflicts).toHaveLength(MAX_CONFLICT_KEYS); + expect(dto.hierarchicalConflicts[0]).toBe('parent.0'); + }); + + it('maps name, locales, exampleKey and warnings', () => { + const dto = mapBundlePlanToDto(plan); + + expect(dto.name).toBe('main'); + expect(dto.locales).toEqual(['en', 'fr']); + expect(dto.exampleKey).toEqual(plan.exampleKey); + expect(dto.warnings).toEqual(['w']); + expect(dto.keysPerLocale).toEqual({ en: 3, fr: 2 }); + }); + + it('omits exampleKey when the plan has none', () => { + const dto = mapBundlePlanToDto({ ...plan, exampleKey: undefined }); + + expect('exampleKey' in dto).toBe(false); + }); + }); + + describe('mapGenerateBundleResultToJobResult', () => { + const definition: BundleDefinition = { + bundleName: 'main.{locale}', + dist: './dist/i18n', + collections: 'All', + typeDistFile: './dist/types/main.ts', + }; + + const result: GenerateBundleResult = { + bundleKey: 'main', + filesGenerated: 2, + warnings: ['Bundle empty for de'], + localesProcessed: ['en', 'fr'], + keysPerLocale: { en: 4, fr: 4 }, + typeGenerationResult: { + bundleKey: 'main', + typeDistFile: './dist/types/main.ts', + keysCount: 4, + fileGenerated: true, + }, + }; + + it('rebuilds written paths from processed locales and appends the types file', () => { + const dto = mapGenerateBundleResultToJobResult(result, definition, '/workspace'); + + expect(dto.filesGenerated).toEqual(['dist/i18n/main.en.json', 'dist/i18n/main.fr.json', 'dist/types/main.ts']); + expect(dto.typeDistFile).toBe('dist/types/main.ts'); + expect(dto.typesKeysCount).toBe(4); + expect(dto.localesProcessed).toEqual(['en', 'fr']); + expect(dto.keysPerLocale).toEqual({ en: 4, fr: 4 }); + expect(dto.warnings).toEqual(['Bundle empty for de']); + }); + + it('makes an absolute types path relative to cwd', () => { + const dto = mapGenerateBundleResultToJobResult( + { + ...result, + typeGenerationResult: { + ...result.typeGenerationResult, + typeDistFile: '/workspace/dist/types/main.ts', + } as never, + }, + definition, + '/workspace', + ); + + expect(dto.typeDistFile).toBe('dist/types/main.ts'); + expect(dto.filesGenerated).toContain('dist/types/main.ts'); + }); + + it('leaves paths outside cwd untouched', () => { + const dto = mapGenerateBundleResultToJobResult( + { + ...result, + typeGenerationResult: { ...result.typeGenerationResult, typeDistFile: '/elsewhere/types/main.ts' } as never, + }, + definition, + '/workspace', + ); + + expect(dto.typeDistFile).toBe('/elsewhere/types/main.ts'); + }); + + it('omits type fields when no types file was written', () => { + const dto = mapGenerateBundleResultToJobResult( + { ...result, typeGenerationResult: { ...result.typeGenerationResult, fileGenerated: false } as never }, + definition, + ); + + expect(dto.filesGenerated).toHaveLength(2); + expect('typeDistFile' in dto).toBe(false); + expect('typesKeysCount' in dto).toBe(false); + }); + + it('omits type fields when type generation did not run', () => { + const dto = mapGenerateBundleResultToJobResult({ ...result, typeGenerationResult: undefined }, definition); + + expect(dto.filesGenerated).toHaveLength(2); + expect(dto.typeDistFile).toBeUndefined(); + }); + }); +}); diff --git a/apps/api/src/app/mappers/bundle.mapper.ts b/apps/api/src/app/mappers/bundle.mapper.ts new file mode 100644 index 00000000..0c948358 --- /dev/null +++ b/apps/api/src/app/mappers/bundle.mapper.ts @@ -0,0 +1,193 @@ +import * as path from 'node:path'; +import type { + BundleDefinition, + BundlePlan, + CollectionBundleDefinition, + EntrySelectionRule, + GenerateBundleResult, +} from '@simoncodes-ca/core'; +import { getBundleOutputPath } from '@simoncodes-ca/core'; +import type { + BundleDefinitionDto, + BundleDryRunResultDto, + BundleGenerateJobResultDto, + CollectionBundleDefinitionDto, + EntrySelectionRuleDto, +} from '@simoncodes-ca/data-transfer'; + +/** Upper bound on conflict keys returned by a dry run so huge bundles stay cheap to serialise. */ +export const MAX_CONFLICT_KEYS = 50; + +export function mapBundleDefinitionToDto(definition: BundleDefinition): BundleDefinitionDto { + const dto: BundleDefinitionDto = { + bundleName: definition.bundleName, + dist: definition.dist, + collections: + definition.collections === 'All' + ? 'All' + : definition.collections.map((collection) => mapCollectionDefinitionToDto(collection)), + }; + + if (definition.typeDistFile !== undefined) dto.typeDistFile = definition.typeDistFile; + if (definition.tokenCasing !== undefined) dto.tokenCasing = definition.tokenCasing; + if (definition.tokenConstantName !== undefined) dto.tokenConstantName = definition.tokenConstantName; + if (definition.transformICUToTransloco !== undefined) { + dto.transformICUToTransloco = definition.transformICUToTransloco; + } + + return dto; +} + +function mapCollectionDefinitionToDto(collection: CollectionBundleDefinition): CollectionBundleDefinitionDto { + const dto: CollectionBundleDefinitionDto = { + name: collection.name, + entriesSelectionRules: + collection.entriesSelectionRules === 'All' + ? 'All' + : collection.entriesSelectionRules.map((rule) => mapRuleToDto(rule)), + }; + + if (collection.bundledKeyPrefix !== undefined) dto.bundledKeyPrefix = collection.bundledKeyPrefix; + if (collection.mergeStrategy !== undefined) dto.mergeStrategy = collection.mergeStrategy; + + return dto; +} + +function mapRuleToDto(rule: EntrySelectionRule): EntrySelectionRuleDto { + const dto: EntrySelectionRuleDto = { matchingPattern: rule.matchingPattern }; + + if (rule.matchingTags !== undefined) dto.matchingTags = [...rule.matchingTags]; + if (rule.matchingTagOperator !== undefined) dto.matchingTagOperator = rule.matchingTagOperator; + + return dto; +} + +/** + * Maps an incoming DTO to a core definition. Strings are trimmed and empty or + * undefined optionals are dropped so nothing spurious lands in the config file. + * Structural validation (required fields, unknown collections, …) is left to + * `validateBundleDefinition`; this mapper only normalises what it is given. + */ +export function mapDtoToBundleDefinition(dto: BundleDefinitionDto): BundleDefinition { + const definition: BundleDefinition = { + bundleName: trimString(dto.bundleName), + dist: trimString(dto.dist), + collections: Array.isArray(dto.collections) + ? dto.collections.map((collection) => mapDtoToCollectionDefinition(collection)) + : dto.collections, + }; + + const typeDistFile = optionalString(dto.typeDistFile); + if (typeDistFile !== undefined) definition.typeDistFile = typeDistFile; + + if (dto.tokenCasing !== undefined) definition.tokenCasing = dto.tokenCasing; + + const tokenConstantName = optionalString(dto.tokenConstantName); + if (tokenConstantName !== undefined) definition.tokenConstantName = tokenConstantName; + + if (typeof dto.transformICUToTransloco === 'boolean') { + definition.transformICUToTransloco = dto.transformICUToTransloco; + } + + return definition; +} + +function mapDtoToCollectionDefinition(dto: CollectionBundleDefinitionDto): CollectionBundleDefinition { + const collection: CollectionBundleDefinition = { + name: trimString(dto.name), + entriesSelectionRules: Array.isArray(dto.entriesSelectionRules) + ? dto.entriesSelectionRules.map((rule) => mapDtoToRule(rule)) + : dto.entriesSelectionRules, + }; + + const bundledKeyPrefix = optionalString(dto.bundledKeyPrefix); + if (bundledKeyPrefix !== undefined) collection.bundledKeyPrefix = bundledKeyPrefix; + + if (dto.mergeStrategy !== undefined) collection.mergeStrategy = dto.mergeStrategy; + + return collection; +} + +function mapDtoToRule(dto: EntrySelectionRuleDto): EntrySelectionRule { + const rule: EntrySelectionRule = { matchingPattern: trimString(dto.matchingPattern) }; + + if (Array.isArray(dto.matchingTags)) { + const tags = dto.matchingTags.map((tag) => trimString(tag)).filter((tag) => tag.length > 0); + if (tags.length > 0) rule.matchingTags = tags; + } + + if (dto.matchingTagOperator !== undefined) rule.matchingTagOperator = dto.matchingTagOperator; + + return rule; +} + +function trimString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function optionalString(value: unknown): string | undefined { + const trimmed = trimString(value); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Maps a dry-run plan to its DTO, dropping absolute paths and capping the conflict lists. */ +export function mapBundlePlanToDto(plan: BundlePlan): BundleDryRunResultDto { + return { + name: plan.bundleKey, + locales: [...plan.locales], + files: plan.files.map((file) => ({ + path: file.path, + kind: file.kind, + ...(file.locale !== undefined && { locale: file.locale }), + exists: file.exists, + keysCount: file.keysCount, + })), + keysPerLocale: { ...plan.keysPerLocale }, + conflictsCount: plan.conflictsCount, + conflictKeys: plan.conflictKeys.slice(0, MAX_CONFLICT_KEYS), + hierarchicalConflicts: plan.hierarchicalConflicts.slice(0, MAX_CONFLICT_KEYS), + ...(plan.exampleKey && { exampleKey: { ...plan.exampleKey } }), + warnings: [...plan.warnings], + }; +} + +/** + * Maps a finished `generateBundle` run to the job result DTO. Core reports a + * file count; the DTO carries paths, rebuilt from the processed locales and the + * bundle definition so the UI can list what was written. Core resolves the + * types file to an absolute path, so every path is normalised relative to `cwd`. + */ +export function mapGenerateBundleResultToJobResult( + result: GenerateBundleResult, + bundleDefinition: BundleDefinition, + cwd: string = process.cwd(), +): BundleGenerateJobResultDto { + const filesGenerated = result.localesProcessed.map((locale) => + toProjectRelative(getBundleOutputPath(bundleDefinition, locale), cwd), + ); + const types = result.typeGenerationResult; + const typeDistFile = + types?.fileGenerated && types.typeDistFile ? toProjectRelative(types.typeDistFile, cwd) : undefined; + + if (typeDistFile !== undefined) { + filesGenerated.push(typeDistFile); + } + + return { + filesGenerated, + keysPerLocale: { ...result.keysPerLocale }, + warnings: [...result.warnings], + localesProcessed: [...result.localesProcessed], + ...(typeDistFile !== undefined && { typeDistFile }), + ...(types?.fileGenerated && { typesKeysCount: types.keysCount }), + }; +} + +/** Returns `filePath` relative to `cwd` (posix separators) when it lives inside `cwd`; otherwise unchanged. */ +function toProjectRelative(filePath: string, cwd: string): string { + const normalized = path.isAbsolute(filePath) ? path.relative(cwd, filePath) : path.normalize(filePath); + if (normalized.startsWith('..')) { + return filePath; + } + return normalized.split(path.sep).join('/'); +} diff --git a/apps/api/src/app/mappers/config.mapper.spec.ts b/apps/api/src/app/mappers/config.mapper.spec.ts index b177cae3..00e49483 100644 --- a/apps/api/src/app/mappers/config.mapper.spec.ts +++ b/apps/api/src/app/mappers/config.mapper.spec.ts @@ -1,6 +1,6 @@ import type { LingoTrackerConfig, ResolvedProtectedTerms } from '@simoncodes-ca/core'; -import { mapConfigToDto, mapDtoToConfigUpdate } from './config.mapper'; import { mapCollectionToDto, mapDtoToCollection } from './collection.mapper'; +import { mapConfigToDto, mapDtoToConfigUpdate } from './config.mapper'; describe('config.mapper', () => { describe('mapConfigToDto', () => { @@ -66,6 +66,30 @@ describe('config.mapper', () => { expect(dto.collections.app.protectedTerms).toBeUndefined(); expect(dto.protectedTermsFilePath).toBe('/project/.lingo-tracker-protected-terms.json'); }); + + it('maps bundle definitions by name', () => { + const dto = mapConfigToDto({ + ...config, + bundles: { + main: { bundleName: '{locale}', dist: './dist/i18n', collections: 'All', typeDistFile: './dist/main.ts' }, + }, + }); + + expect(dto.bundles).toEqual({ + main: { bundleName: '{locale}', dist: './dist/i18n', collections: 'All', typeDistFile: './dist/main.ts' }, + }); + }); + + it('omits bundles when the config has none', () => { + const dto = mapConfigToDto(config); + + expect('bundles' in dto).toBe(false); + }); + + it('exposes projectName only when provided', () => { + expect(mapConfigToDto(config, undefined, 'lingo-tracker').projectName).toBe('lingo-tracker'); + expect('projectName' in mapConfigToDto(config)).toBe(false); + }); }); describe('mapDtoToConfigUpdate', () => { diff --git a/apps/api/src/app/mappers/config.mapper.ts b/apps/api/src/app/mappers/config.mapper.ts index 50da457a..3119866b 100644 --- a/apps/api/src/app/mappers/config.mapper.ts +++ b/apps/api/src/app/mappers/config.mapper.ts @@ -1,5 +1,16 @@ -import type { LingoTrackerConfig, LingoTrackerCollection, ResolvedProtectedTerms } from '@simoncodes-ca/core'; -import type { LingoTrackerConfigDto, LingoTrackerCollectionDto, UpdateConfigDto } from '@simoncodes-ca/data-transfer'; +import type { + BundleDefinition, + LingoTrackerCollection, + LingoTrackerConfig, + ResolvedProtectedTerms, +} from '@simoncodes-ca/core'; +import type { + BundleDefinitionDto, + LingoTrackerCollectionDto, + LingoTrackerConfigDto, + UpdateConfigDto, +} from '@simoncodes-ca/data-transfer'; +import { mapBundleDefinitionToDto } from './bundle.mapper'; import { mapCollectionToDto } from './collection.mapper'; function mapConfigCollections( @@ -11,20 +22,33 @@ function mapConfigCollections( ); } +function mapConfigBundles(bundles: Record): Record { + return Object.fromEntries(Object.entries(bundles).map(([name, bundle]) => [name, mapBundleDefinitionToDto(bundle)])); +} + /** * Maps config to its DTO. `resolved` carries the protected terms already read from disk - * by the caller — the mapper itself stays free of file I/O. + * by the caller — the mapper itself stays free of file I/O. `projectName` is the served + * workspace folder name, supplied by the controller for the same reason. */ -export function mapConfigToDto(config: LingoTrackerConfig, resolved?: ResolvedProtectedTerms): LingoTrackerConfigDto { +export function mapConfigToDto( + config: LingoTrackerConfig, + resolved?: ResolvedProtectedTerms, + projectName?: string, +): LingoTrackerConfigDto { return { exportFolder: config.exportFolder, importFolder: config.importFolder, baseLocale: config.baseLocale, locales: [...config.locales], collections: mapConfigCollections(config.collections, resolved), + ...(config.bundles && { bundles: mapConfigBundles(config.bundles) }), + ...(config.tokenCasing && { tokenCasing: config.tokenCasing }), + ...(config.transformICUToTransloco !== undefined && { transformICUToTransloco: config.transformICUToTransloco }), translation: config.translation, protectedTerms: resolved?.globalTerms.length ? [...resolved.globalTerms] : undefined, protectedTermsFilePath: resolved?.globalFilePath, + ...(projectName && { projectName }), }; } diff --git a/apps/tracker/.impeccable/design.json b/apps/tracker/.impeccable/design.json new file mode 100644 index 00000000..af529ddb --- /dev/null +++ b/apps/tracker/.impeccable/design.json @@ -0,0 +1,697 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-09-16T05:03:32Z", + "title": "Design System: LingoTracker", + "extensions": { + "colorMeta": { + "primary": { + "role": "primary", + "displayName": "Coral / Vermillion", + "canonical": "#d4726a", + "dark": "#e8847c", + "tonalRamp": ["#b04a42", "#c25d55", "#d4726a", "#e8847c", "#f0a09a", "#f5bdb8", "#fadcd9", "#fdf2f1"] + }, + "primary-text": { + "role": "primary", + "displayName": "Coral Text", + "canonical": "#a1443d", + "dark": "#e8847c", + "tonalRamp": ["#7a2f2a", "#a1443d", "#b04a42", "#c25d55", "#d4726a", "#e8847c", "#f0a09a", "#fdf2f1"] + }, + "secondary": { + "role": "secondary", + "displayName": "Deep Sky Blue", + "canonical": "#4a7fb5", + "dark": "#7bafde", + "tonalRamp": ["#1f3f61", "#2d5888", "#3a6a9e", "#4a7fb5", "#5a8db8", "#7bafde", "#9dc4e8", "#eef5fb"] + }, + "focus-ring": { + "role": "secondary", + "displayName": "Focus Sky", + "canonical": "#4a7fb5", + "dark": "#7bafde", + "tonalRamp": ["#1f3f61", "#2d5888", "#3a6a9e", "#4a7fb5", "#5a8db8", "#7bafde", "#9dc4e8", "#eef5fb"] + }, + "warning": { + "role": "tertiary", + "displayName": "Amber Lock", + "canonical": "#d9a441", + "dark": "#e4b454", + "tonalRamp": ["#5c3d10", "#8a5a00", "#a87716", "#c7a86b", "#d9a441", "#e4b454", "#f0bc5e", "#fde8a0"] + }, + "warning-text": { + "role": "tertiary", + "displayName": "Amber Text", + "canonical": "#8a5a00", + "dark": "#e4b454", + "tonalRamp": ["#5c3d10", "#745415", "#8a5a00", "#a87716", "#d9a441", "#e4b454", "#f0bc5e", "#fde8a0"] + }, + "error": { + "role": "tertiary", + "displayName": "Warm Red", + "canonical": "#c95e5e", + "dark": "#d97878", + "tonalRamp": ["#7a3535", "#9a4747", "#c95e5e", "#d97878", "#e39a9a", "#eebcbc", "#f6dcdc", "#fbf0f0"] + }, + "success": { + "role": "tertiary", + "displayName": "Sage Success", + "canonical": "#6da67e", + "dark": "#7dbb8f", + "tonalRamp": ["#2f4f39", "#447353", "#4e8a63", "#6da67e", "#7dbb8f", "#8db580", "#a3c898", "#e9eae1"] + }, + "info": { + "role": "tertiary", + "displayName": "Info Sky", + "canonical": "#5a8db8", + "dark": "#7bafde", + "tonalRamp": ["#1a2d40", "#2d5888", "#3f7aa8", "#5a8db8", "#7bafde", "#9dc4e8", "#c5dcef", "#e6f0f8"] + }, + "parchment": { + "role": "neutral", + "displayName": "Parchment", + "canonical": "#faf8f5", + "dark": "#141210", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "parchment-subtle": { + "role": "neutral", + "displayName": "Parchment Subtle", + "canonical": "#f5f1eb", + "dark": "#1c1814", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "parchment-muted": { + "role": "neutral", + "displayName": "Parchment Muted", + "canonical": "#ede8e0", + "dark": "#2e2923", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "ink": { + "role": "neutral", + "displayName": "Warm Ink", + "canonical": "#2e2923", + "dark": "#f3efe9", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "ink-secondary": { + "role": "neutral", + "displayName": "Ink Secondary", + "canonical": "#5a534a", + "dark": "#d4cdc3", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "ink-tertiary": { + "role": "neutral", + "displayName": "Ink Tertiary (decorative only)", + "canonical": "#a89f93", + "dark": "#7d7569", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "ink-placeholder": { + "role": "neutral", + "displayName": "Placeholder Ink", + "canonical": "#736b61", + "dark": "#8a8175", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#736b61", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#f3efe9", + "#faf8f5" + ] + }, + "ink-on-primary": { + "role": "neutral", + "displayName": "Ink on Coral", + "canonical": "#2e2923", + "dark": "#2e2923", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "border": { + "role": "neutral", + "displayName": "Hairline", + "canonical": "#e5dfd7", + "dark": "#433d36", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f0ebe4", + "#faf8f5" + ] + }, + "border-subtle": { + "role": "neutral", + "displayName": "Hairline Subtle", + "canonical": "#f0ebe4", + "dark": "#2e2923", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f0ebe4", + "#faf8f5" + ] + }, + "border-strong": { + "role": "neutral", + "displayName": "Hairline Strong", + "canonical": "#d4cdc3", + "dark": "#5a534a", + "tonalRamp": [ + "#1c1814", + "#2e2923", + "#433d36", + "#5a534a", + "#7d7569", + "#a89f93", + "#d4cdc3", + "#e5dfd7", + "#f3efe9", + "#faf8f5" + ] + }, + "highlight": { + "role": "tertiary", + "displayName": "Search Highlight", + "canonical": "#fde8a0", + "dark": "#5c3d10", + "tonalRamp": ["#5c3d10", "#745415", "#8a5a00", "#a87716", "#c7a86b", "#e0cfae", "#f0bc5e", "#fde8a0"] + }, + "status-new": { + "role": "tertiary", + "displayName": "Status: New", + "canonical": "#a87716", + "dark": "#e4b454", + "tonalRamp": ["#5b4927", "#745415", "#997a3c", "#a87716", "#c7a86b", "#e0cfae", "#e4b454", "#fde8a0"] + }, + "status-stale": { + "role": "tertiary", + "displayName": "Status: Stale", + "canonical": "#b85c26", + "dark": "#e08a4e", + "tonalRamp": ["#493120", "#865533", "#884720", "#b85c26", "#d29a79", "#e08a4e", "#e6c9b7", "#fdf2f1"] + }, + "status-translated": { + "role": "tertiary", + "displayName": "Status: Translated", + "canonical": "#3f7aa8", + "dark": "#7bafde", + "tonalRamp": ["#1e2225", "#374756", "#3a6d95", "#3f7aa8", "#7bafde", "#bbcbd6", "#e8e9e6", "#eef5fb"] + }, + "status-verified": { + "role": "tertiary", + "displayName": "Status: Verified", + "canonical": "#4e8a63", + "dark": "#7dbb8f", + "tonalRamp": ["#1e231d", "#384b3b", "#447353", "#4e8a63", "#7dbb8f", "#c0d0bf", "#e9eae1", "#faf8f5"] + }, + "wc-coral": { + "role": "tertiary", + "displayName": "Watercolor Coral", + "canonical": "#e8847c", + "dark": "#f0a09a", + "tonalRamp": ["#b04a42", "#c25d55", "#d4726a", "#e8847c", "#f0a09a", "#f5bdb8", "#fadcd9", "#fdf2f1"] + }, + "wc-amber": { + "role": "tertiary", + "displayName": "Watercolor Amber", + "canonical": "#e8a838", + "dark": "#f0bc5e", + "tonalRamp": ["#5c3d10", "#8a5a00", "#a87716", "#d9a441", "#e8a838", "#f0bc5e", "#f6d68f", "#fde8a0"] + }, + "wc-sky": { + "role": "tertiary", + "displayName": "Watercolor Sky", + "canonical": "#7bafde", + "dark": "#9dc4e8", + "tonalRamp": ["#1f3f61", "#2d5888", "#3a6a9e", "#4a7fb5", "#5a8db8", "#7bafde", "#9dc4e8", "#eef5fb"] + }, + "wc-lavender": { + "role": "tertiary", + "displayName": "Watercolor Lavender", + "canonical": "#b892cc", + "dark": "#cbaade", + "tonalRamp": ["#4a3660", "#6b4f87", "#8c6ba8", "#a57fbd", "#b892cc", "#cbaade", "#ddc6ea", "#f2eaf7"] + }, + "wc-sage": { + "role": "tertiary", + "displayName": "Watercolor Sage", + "canonical": "#8db580", + "dark": "#a3c898", + "tonalRamp": ["#2f4f39", "#447353", "#4e8a63", "#6da67e", "#8db580", "#a3c898", "#c0d0bf", "#e9eae1"] + } + }, + "typographyMeta": { + "brand": { + "displayName": "Brand wordmark", + "purpose": "The LingoTracker wordmark in the app header only. Never used for headings or UI." + }, + "display": { + "displayName": "Display", + "purpose": "Page titles such as Collections." + }, + "headline": { + "displayName": "Headline", + "purpose": "Dialog titles, shared by the translation editor and collection form." + }, + "title": { + "displayName": "Title", + "purpose": "Collection card names." + }, + "group-title": { + "displayName": "Group title", + "purpose": "The h3 opening a labelled form group; always followed by a caption hint." + }, + "body": { + "displayName": "Body", + "purpose": "Field values and standard paragraphs." + }, + "body-small": { + "displayName": "Body small", + "purpose": "Dialog subtitles." + }, + "label": { + "displayName": "Label", + "purpose": "Field labels above inputs, disclosure titles, button text." + }, + "caption": { + "displayName": "Caption", + "purpose": "Group hints, field hints, errors, count summaries, switch descriptions." + }, + "tag": { + "displayName": "Chip tag", + "purpose": "The uppercase BASE tag inside a locale chip; the only uppercase text in the system." + }, + "mono-code": { + "displayName": "Mono code", + "purpose": "Locale codes in chips and mono input values." + }, + "mono-path": { + "displayName": "Mono path", + "purpose": "Folder paths on cards, two lines reserved." + } + }, + "shadows": [ + { + "name": "shadow-sm", + "value": "0 1px 3px 0 rgb(44 36 28 / 0.06)", + "purpose": "Card at rest." + }, + { + "name": "shadow-md", + "value": "0 4px 8px -1px rgb(44 36 28 / 0.08), 0 2px 4px -2px rgb(44 36 28 / 0.06)", + "purpose": "Card hover with 2px lift." + }, + { + "name": "shadow-lg", + "value": "0 10px 20px -3px rgb(44 36 28 / 0.1), 0 4px 8px -4px rgb(44 36 28 / 0.08)", + "purpose": "Dialog panels." + }, + { + "name": "shadow-xl", + "value": "0 20px 30px -5px rgb(44 36 28 / 0.12), 0 8px 12px -6px rgb(44 36 28 / 0.08)", + "purpose": "Defined in tokens; unused in shipped surfaces." + }, + { + "name": "focus-glow", + "value": "0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent)", + "purpose": "Focused text input and chip editor, with border-color: var(--color-secondary)." + }, + { + "name": "error-glow", + "value": "0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent)", + "purpose": "Invalid text input and chip editor." + }, + { + "name": "accent-glow", + "value": "0 6px 16px -4px color-mix(in srgb, var(--color-primary) 45%, transparent)", + "purpose": "Hover on the page-level primary Add button." + } + ], + "motion": [ + { + "name": "transition-fast", + "value": "150ms cubic-bezier(0.4, 0, 0.2, 1)", + "purpose": "Colour, border, and shadow state changes on controls." + }, + { + "name": "transition-normal", + "value": "200ms cubic-bezier(0.4, 0, 0.2, 1)", + "purpose": "Row ground changes, chevron rotation, disclosure reveal, card hover lift." + }, + { + "name": "transition-slow", + "value": "300ms cubic-bezier(0.4, 0, 0.2, 1)", + "purpose": "Determinate progress-bar width on a running bundle result strip." + }, + { + "name": "card-enter", + "value": "0.45s cubic-bezier(0.16, 1, 0.3, 1), 50ms stagger per card", + "purpose": "Collection cards entering the grid; disabled under prefers-reduced-motion." + }, + { + "name": "disclosure-reveal", + "value": "opacity 0->1, translateY(-4px)->0 over transition-normal", + "purpose": "Disclosure panel opening; disabled under prefers-reduced-motion." + }, + { + "name": "spin", + "value": "0.9s linear infinite", + "purpose": "Busy-button spinner ring; disabled under prefers-reduced-motion." + }, + { + "name": "details-reveal", + "value": "opacity 0->1, translateY(-4px)->0 over transition-normal", + "purpose": "Result-strip Details panel opening; disabled under prefers-reduced-motion." + }, + { + "name": "ring-pulse", + "value": "3s ease-in-out infinite, 0.5s stagger per ring", + "purpose": "Watercolor rings behind the empty Collections state; disabled under prefers-reduced-motion." + } + ], + "breakpoints": [ + { + "name": "sm", + "value": "640px" + }, + { + "name": "md", + "value": "768px" + }, + { + "name": "lg", + "value": "1024px" + }, + { + "name": "xl", + "value": "1280px" + }, + { + "name": "xxl", + "value": "1536px" + }, + { + "name": "bundle-rail-stack", + "value": "799px" + }, + { + "name": "split-stack", + "value": "1100px" + }, + { + "name": "bundle-preview-collapse", + "value": "1199px" + } + ], + "darkTheme": { + "selector": "[data-theme='dark'], or :root without data-theme under prefers-color-scheme: dark", + "note": "The neutral scale inverts (neutral-50 becomes #1c1814). Coral primary brightens to #e8847c and primary-text aliases it. Sky brightens to #7bafde. Backgrounds: #141210 page, #1c1814 subtle, #2e2923 muted. Dialog panel lifts to background-subtle while the field well stays on the page tone." + } + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "Hand-rolled filled coral button; reserved for the one action on a page that has nothing else on it (the empty state, the error retry). Label is 700 and the glow is tightened to 0 4px 12px -5px at 34% coral — at 600 under a wide 45% halo the type dissolved into its own button. Replaces mat-flat-button, whose Material palette does not resolve to --color-primary.", + "html": "", + "css": ".ds-btn-primary { display: inline-flex; align-items: center; gap: 0.25rem; font-family: 'Nunito', sans-serif; font-size: 0.875rem; font-weight: 700; letter-spacing: 0.01em; height: 40px; padding: 0 1.25rem; border: 1px solid var(--color-primary, #d4726a); border-radius: var(--border-radius-lg, 0.5rem); background: var(--color-primary, #d4726a); color: var(--color-text-on-primary, #2e2923); box-shadow: 0 4px 12px -5px color-mix(in srgb, var(--color-primary, #d4726a) 34%, transparent); cursor: pointer; transition: background 150ms cubic-bezier(0.4,0,0.2,1), border-color 150ms cubic-bezier(0.4,0,0.2,1), transform 200ms cubic-bezier(0.4,0,0.2,1); } .ds-btn-primary:hover { background: var(--color-primary-hover, #c25d55); border-color: var(--color-primary-hover, #c25d55); transform: translateY(-1px); } .ds-btn-primary:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; } .ds-btn-primary:disabled { opacity: 0.55; box-shadow: none; transform: none; cursor: default; }" + }, + { + "name": "Text Button", + "kind": "button", + "refersTo": "button-text", + "description": "Material text button for Cancel and secondary actions; secondary ink that darkens on hover.", + "html": "", + "css": ".ds-btn-text { font-family: 'Nunito', sans-serif; font-size: 0.875rem; font-weight: 500; line-height: 1; height: 36px; padding: 0 0.75rem; border: 0; border-radius: var(--border-radius-sm, 0.25rem); background: transparent; color: var(--color-text-secondary, #5a534a); cursor: pointer; transition: color 150ms cubic-bezier(0.4,0,0.2,1), background 150ms cubic-bezier(0.4,0,0.2,1); } .ds-btn-text:hover { color: var(--color-text-primary, #2e2923); background: color-mix(in srgb, var(--color-text-primary, #2e2923) 6%, transparent); } .ds-btn-text:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; }" + }, + { + "name": "Labelled Text Input", + "kind": "input", + "refersTo": "text-input", + "description": "Label above a 40px well on the dialog ground; sky border and 22% glow on focus. Mono variant for paths.", + "html": "

Relative to the project root, next to .lingo-tracker.json.

", + "css": ".ds-field { display: flex; flex-direction: column; gap: 0.25rem; font-family: 'Nunito', sans-serif; } .ds-field-label { font-size: 0.875rem; font-weight: 600; line-height: 1.5; color: var(--color-text-primary, #2e2923); } .ds-text-input { display: flex; align-items: center; gap: 0.5rem; height: 40px; padding: 0 0.75rem; border: 1px solid var(--color-border-strong, #d4cdc3); border-radius: var(--border-radius-md, 0.375rem); background: var(--color-background, #faf8f5); color: var(--color-text-primary, #2e2923); transition: border-color 150ms cubic-bezier(0.4,0,0.2,1), box-shadow 150ms cubic-bezier(0.4,0,0.2,1); } .ds-text-input svg { flex: none; color: var(--color-text-tertiary, #a89f93); } .ds-text-input input { flex: 1; min-width: 0; height: 100%; border: 0; padding: 0; background: transparent; color: inherit; font: inherit; font-size: 1rem; outline: none; } .ds-text-input input::placeholder { color: var(--color-text-placeholder, #736b61); } .ds-text-input:focus-within { border-color: var(--color-secondary, #4a7fb5); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary, #4a7fb5) 22%, transparent); } .ds-text-input--mono input { font-family: 'IBM Plex Mono', monospace; font-size: 0.875rem; } .ds-text-input--invalid, .ds-text-input--invalid:focus-within { border-color: var(--color-error, #c95e5e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error, #c95e5e) 18%, transparent); } .ds-field-hint { margin: 0; font-size: 0.75rem; line-height: 1.5; color: var(--color-text-secondary, #5a534a); } .ds-field-hint code { font-family: 'IBM Plex Mono', monospace; font-size: 0.95em; color: var(--color-text-primary, #2e2923); }" + }, + { + "name": "Locale Chip Editor", + "kind": "chip", + "refersTo": "chip-editor", + "description": "Wrapping well holding locale pills (mono code, sky-tinted BASE chip) and an inline add input; same stroke and focus glow as a text input.", + "html": "
", + "css": ".ds-chip-editor { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.5rem; border: 1px solid var(--color-border-strong, #d4cdc3); border-radius: var(--border-radius-md, 0.375rem); background: var(--color-background, #faf8f5); font-family: 'Nunito', sans-serif; transition: border-color 150ms cubic-bezier(0.4,0,0.2,1), box-shadow 150ms cubic-bezier(0.4,0,0.2,1); } .ds-chip-editor:focus-within { border-color: var(--color-secondary, #4a7fb5); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary, #4a7fb5) 22%, transparent); } .ds-chip-editor--empty { border-style: dashed; } .ds-chip-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; } .ds-locale-chip { display: inline-flex; align-items: center; border-radius: 9999px; background: var(--color-background-muted, #ede8e0); border: 1px solid transparent; color: var(--color-text-secondary, #5a534a); transition: background 150ms, border-color 150ms, color 150ms; } .ds-locale-chip--base { background: color-mix(in srgb, var(--color-secondary, #4a7fb5) 16%, transparent); border-color: color-mix(in srgb, var(--color-secondary, #4a7fb5) 35%, transparent); color: color-mix(in srgb, var(--color-secondary, #4a7fb5) 45%, var(--color-text-primary, #2e2923)); } .ds-locale-chip-body { display: inline-flex; align-items: center; gap: 0.25rem; padding: 3px 0.5rem 3px 0.75rem; border: 0; border-radius: inherit; background: transparent; color: inherit; font: inherit; line-height: 1.5; cursor: pointer; } .ds-locale-chip-body:hover { color: var(--color-text-primary, #2e2923); } .ds-locale-chip:not(.ds-locale-chip--base) .ds-locale-chip-body:hover { background: color-mix(in srgb, var(--color-secondary, #4a7fb5) 10%, transparent); } .ds-locale-chip-body:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 1px; } .ds-locale-chip-code { font-family: 'IBM Plex Mono', monospace; font-size: 0.875rem; font-weight: 600; } .ds-locale-chip-tag { font-size: 0.625rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.9; } .ds-locale-chip-remove { display: grid; place-items: center; width: 24px; height: 24px; margin: 0 3px 0 -4px; padding: 0; border: 0; border-radius: 9999px; background: transparent; color: var(--color-text-tertiary, #a89f93); cursor: pointer; transition: color 150ms, background 150ms; } .ds-locale-chip-remove:hover { color: var(--color-error, #c95e5e); background: color-mix(in srgb, var(--color-error, #c95e5e) 12%, transparent); } .ds-locale-chip-remove:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 1px; } .ds-chip-add { display: flex; align-items: center; gap: 0.25rem; flex: 1 1 180px; min-width: 180px; min-height: 30px; padding: 0 0.25rem; color: var(--color-text-tertiary, #a89f93); } .ds-chip-add-input { flex: 1; min-width: 120px; height: 30px; border: 0; padding: 0 0.25rem; background: transparent; color: var(--color-text-primary, #2e2923); font: inherit; font-size: 0.875rem; outline: none; } .ds-chip-add-input::placeholder { color: var(--color-text-placeholder, #736b61); }" + }, + { + "name": "Text Chip", + "kind": "chip", + "refersTo": "chip-text", + "description": "The same pill in its word voice: Nunito on the subtle tone with a hairline, for tags and protected terms.", + "html": "tracker", + "css": ".ds-text-chip { display: inline-flex; align-items: center; border-radius: 9999px; background: var(--color-background-subtle, #f5f1eb); border: 1px solid var(--color-border, #e5dfd7); color: var(--color-text-primary, #2e2923); font-family: 'Nunito', sans-serif; } .ds-text-chip-body { padding: 3px 0.5rem 3px 0.75rem; font-size: 0.875rem; font-weight: 500; line-height: 1.5; } .ds-text-chip-remove { display: grid; place-items: center; width: 24px; height: 24px; margin: 0 3px 0 -4px; padding: 0; border: 0; border-radius: 9999px; background: transparent; color: var(--color-text-tertiary, #a89f93); cursor: pointer; transition: color 150ms, background 150ms; } .ds-text-chip-remove:hover { color: var(--color-error, #c95e5e); background: color-mix(in srgb, var(--color-error, #c95e5e) 12%, transparent); } .ds-text-chip-remove:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 1px; }" + }, + { + "name": "Read-only Switch Row", + "kind": "custom", + "refersTo": "switch-row", + "description": "Subtle panel with an amber lock tile, bold label over a caption, and a toggle; the ground warms when on.", + "html": "", + "css": ".ds-switch-row { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.75rem 1rem; border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-lg, 0.5rem); background: var(--color-background-subtle, #f5f1eb); font-family: 'Nunito', sans-serif; cursor: pointer; transition: border-color 200ms cubic-bezier(0.4,0,0.2,1), background 200ms cubic-bezier(0.4,0,0.2,1); } .ds-switch-row--on { border-color: color-mix(in srgb, var(--color-warning, #d9a441) 35%, var(--color-border, #e5dfd7)); background: color-mix(in srgb, var(--color-warning, #d9a441) 6%, var(--color-background-subtle, #f5f1eb)); } .ds-switch-tile { display: grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: var(--border-radius-md, 0.375rem); background: color-mix(in srgb, var(--color-warning, #d9a441) 14%, transparent); color: var(--color-warning-text, #8a5a00); } .ds-switch-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } .ds-switch-label { font-size: 0.875rem; font-weight: 700; line-height: 1.375; color: var(--color-text-primary, #2e2923); } .ds-switch-description { font-size: 0.75rem; line-height: 1.375; color: var(--color-text-secondary, #5a534a); } .ds-switch-control { flex: none; margin-top: 2px; width: 36px; height: 20px; accent-color: var(--color-primary, #d4726a); } .ds-switch-control:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; }" + }, + { + "name": "Disclosure Row", + "kind": "custom", + "refersTo": "disclosure", + "description": "Hairline container with a full-width toggle: leading glyph, title, right-aligned count summary, rotating chevron.", + "html": "
", + "css": ".ds-disclosure { border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-lg, 0.5rem); overflow: hidden; font-family: 'Nunito', sans-serif; } .ds-disclosure-toggle { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem 0.5rem 1rem; border: 0; background: transparent; color: var(--color-text-primary, #2e2923); font: inherit; text-align: left; cursor: pointer; transition: background 150ms cubic-bezier(0.4,0,0.2,1); } .ds-disclosure-toggle:hover { background: var(--color-background-subtle, #f5f1eb); } .ds-disclosure-toggle:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: -2px; } .ds-disclosure-icon { flex: none; color: var(--color-text-tertiary, #a89f93); } .ds-disclosure-title { font-size: 0.875rem; font-weight: 600; line-height: 1.5; } .ds-disclosure-summary { margin-left: auto; font-size: 0.75rem; color: var(--color-text-secondary, #5a534a); white-space: nowrap; } .ds-disclosure-chevron { flex: none; color: var(--color-text-tertiary, #a89f93); transition: transform 200ms cubic-bezier(0.4,0,0.2,1); } .ds-disclosure--open .ds-disclosure-chevron { transform: rotate(180deg); }" + }, + { + "name": "Dialog Header", + "kind": "custom", + "refersTo": "dialog-header-tile", + "description": "The shared dialog anatomy: 3px coral top rule, 38px coral tile, headline title, one-line subtitle with inline code, close button, hairline below.", + "html": "

Edit Collection

Changes are saved to .lingo-tracker.json in your working tree.

", + "css": ".ds-dialog { width: 100%; max-width: 540px; border-top: 3px solid var(--color-primary, #d4726a); border-radius: var(--border-radius-lg, 0.5rem); background: var(--color-background, #faf8f5); box-shadow: 0 10px 20px -3px rgb(44 36 28 / 0.1), 0 4px 8px -4px rgb(44 36 28 / 0.08); font-family: 'Nunito', sans-serif; overflow: hidden; } .ds-dialog-header { position: relative; display: flex; align-items: flex-start; gap: 0.75rem; padding: 1.25rem 1.5rem 1rem; border-bottom: 1px solid var(--color-border, #e5dfd7); } .ds-dialog-header-tile { display: grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: var(--border-radius-lg, 0.5rem); background: color-mix(in srgb, var(--color-primary, #d4726a) 12%, transparent); border: 1px solid color-mix(in srgb, var(--color-primary, #d4726a) 25%, transparent); color: var(--color-primary, #d4726a); } .ds-dialog-header-text { flex: 1; min-width: 0; padding-right: 2rem; } .ds-dialog-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; line-height: 1.25; letter-spacing: -0.01em; color: var(--color-text-primary, #2e2923); } .ds-dialog-subtitle { margin: 0; font-size: 0.875rem; line-height: 1.375; color: var(--color-text-secondary, #5a534a); } .ds-dialog-subtitle code { font-family: 'IBM Plex Mono', monospace; font-size: 0.95em; color: var(--color-text-primary, #2e2923); } .ds-dialog-close { position: absolute; top: 0.75rem; right: 0.75rem; display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 9999px; background: transparent; color: var(--color-text-secondary, #5a534a); cursor: pointer; transition: color 150ms cubic-bezier(0.4,0,0.2,1); } .ds-dialog-close:hover { color: var(--color-text-primary, #2e2923); } .ds-dialog-close:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; } .ds-dialog-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem 1.5rem 1.25rem; border-top: 1px solid var(--color-border, #e5dfd7); } .ds-dialog-footer-cancel { height: 36px; padding: 0 0.75rem; border: 0; border-radius: 0.25rem; background: transparent; color: var(--color-text-secondary, #5a534a); font: inherit; font-size: 0.875rem; font-weight: 500; cursor: pointer; } .ds-dialog-footer-cancel:hover { color: var(--color-text-primary, #2e2923); } .ds-dialog-footer-primary { height: 36px; padding: 0 1rem; border: 0; border-radius: 0.25rem; background: var(--color-primary, #d4726a); color: var(--color-text-on-primary, #2e2923); font: inherit; font-size: 0.875rem; font-weight: 500; cursor: pointer; } .ds-dialog-footer-primary:hover { background: var(--color-primary-hover, #c25d55); }" + }, + { + "name": "Collection Card", + "kind": "card", + "refersTo": "card", + "description": "12px-radius parchment card with coral icon tile, bold name, two-line mono path, hairline footer of locale pills; lifts 2px on hover.", + "html": "

trackerResources

apps/tracker/src/i18n

  • en Base
  • es
  • fr-ca
  • +3
", + "css": ".ds-card { display: flex; flex-direction: column; width: 300px; padding: 1.25rem; border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-xl, 0.75rem); background: var(--color-background, #faf8f5); box-shadow: 0 1px 3px 0 rgb(44 36 28 / 0.06); font-family: 'Nunito', sans-serif; cursor: pointer; transition: transform 200ms cubic-bezier(0.4,0,0.2,1), box-shadow 200ms cubic-bezier(0.4,0,0.2,1), border-color 200ms cubic-bezier(0.4,0,0.2,1), background 200ms cubic-bezier(0.4,0,0.2,1); } .ds-card:hover { background: color-mix(in srgb, var(--color-primary, #d4726a) 3%, var(--color-background, #faf8f5)); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 40%, var(--color-border, #e5dfd7)); box-shadow: 0 4px 8px -1px rgb(44 36 28 / 0.08), 0 2px 4px -2px rgb(44 36 28 / 0.06); transform: translateY(-2px); } .ds-card:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 3px; } .ds-card-header { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; } .ds-card-icon { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--border-radius-lg, 0.5rem); background: color-mix(in srgb, var(--color-primary, #d4726a) 12%, transparent); border: 1px solid color-mix(in srgb, var(--color-primary, #d4726a) 25%, transparent); color: var(--color-primary, #d4726a); } .ds-card-name { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -0.01em; line-height: 1.25; color: var(--color-text-primary, #2e2923); overflow-wrap: anywhere; } .ds-card-path { margin: 0.25rem 0 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; line-height: 1.45; min-height: calc(2 * 1.45 * 0.75rem); color: var(--color-text-secondary, #5a534a); overflow-wrap: anywhere; } .ds-card-footer { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border-subtle, #f0ebe4); } .ds-card-chips { display: flex; gap: 0.25rem; margin: 0; padding: 0; list-style: none; } .ds-card-chip { display: inline-flex; align-items: baseline; gap: 0.25rem; padding: 1px 0.5rem; border-radius: 9999px; border: 1px solid transparent; background: var(--color-background-muted, #ede8e0); color: var(--color-text-secondary, #5a534a); font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; font-weight: 600; line-height: 1.5; } .ds-card-chip--base { background: color-mix(in srgb, var(--color-secondary, #4a7fb5) 16%, transparent); border-color: color-mix(in srgb, var(--color-secondary, #4a7fb5) 35%, transparent); color: color-mix(in srgb, var(--color-secondary, #4a7fb5) 45%, var(--color-text-primary, #2e2923)); } .ds-card-chip--more { background: none; border-color: var(--color-border-strong, #d4cdc3); border-style: dashed; } .ds-card-chip-tag { font-family: 'Nunito', sans-serif; font-size: 0.625rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.85; }" + }, + { + "name": "Outline Button", + "kind": "button", + "refersTo": "button-outline", + "description": "Hairline button on the card surface; the card-level and repeat action (Generate, Retry). Shown here in the --sm step.", + "html": "", + "css": ".ds-btn-outline { display: inline-flex; align-items: center; gap: 0.25rem; font-family: 'Nunito', sans-serif; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.01em; height: 40px; padding: 0 1.25rem; border: 1px solid var(--color-border-strong, #d4cdc3); border-radius: var(--border-radius-lg, 0.5rem); background: var(--color-background, #faf8f5); color: var(--color-text-primary, #2e2923); cursor: pointer; transition: background 150ms cubic-bezier(0.4,0,0.2,1), border-color 150ms cubic-bezier(0.4,0,0.2,1), color 150ms cubic-bezier(0.4,0,0.2,1); } .ds-btn-outline--sm { height: 34px; padding: 0 0.75rem; font-size: 0.75rem; border-radius: var(--border-radius-md, 0.375rem); } .ds-btn-outline:hover { background: var(--color-background-subtle, #f5f1eb); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 45%, var(--color-border-strong, #d4cdc3)); } .ds-btn-outline:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; }" + }, + { + "name": "Quiet Button", + "kind": "button", + "refersTo": "button-quiet", + "description": "Third rung of the button ladder, for actions a visitor takes rarely (Add collection, Add bundle). No ground at rest, a plain border hairline, secondary ink and a coral leading glyph, so the repeated Generate buttons outrank it without either of them shouting. Frequency sets prominence, not a control's place in the hierarchy of nouns.", + "html": "", + "css": ".ds-btn-quiet { display: inline-flex; align-items: center; gap: 0.25rem; font-family: 'Nunito', sans-serif; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.01em; height: 34px; padding: 0 0.75rem; border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-md, 0.375rem); background: transparent; color: var(--color-text-secondary, #5a534a); cursor: pointer; transition: background 150ms cubic-bezier(0.4,0,0.2,1), border-color 150ms cubic-bezier(0.4,0,0.2,1), color 150ms cubic-bezier(0.4,0,0.2,1); } .ds-btn-quiet__glyph { color: var(--color-primary, #d4726a); font-size: 16px; line-height: 1; } .ds-btn-quiet:hover { background: var(--color-background-subtle, #f5f1eb); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 45%, var(--color-border-strong, #d4cdc3)); color: var(--color-text-primary, #2e2923); } .ds-btn-quiet:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; } .ds-btn-quiet:disabled { opacity: 0.55; cursor: default; }" + }, + { + "name": "Segmented Control", + "kind": "custom", + "refersTo": "segmented-control", + "description": "Hand-rolled exclusive choice of 2-4 options inside a dialog; the approved alternative to Material button toggles. Selected pill fills parchment-muted; the well takes the sky focus glow.", + "html": "
", + "css": ".ds-segmented { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--color-border-strong, #d4cdc3); border-radius: var(--border-radius-md, 0.375rem); background: var(--color-background, #faf8f5); transition: border-color 150ms cubic-bezier(0.4,0,0.2,1), box-shadow 150ms cubic-bezier(0.4,0,0.2,1); } .ds-segmented:focus-within { border-color: var(--color-secondary, #4a7fb5); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary, #4a7fb5) 22%, transparent); } .ds-segment { display: inline-flex; align-items: center; gap: 0.25rem; padding: 5px 0.75rem; border: 0; border-radius: var(--border-radius-sm, 0.25rem); background: transparent; color: var(--color-text-secondary, #5a534a); font-family: 'Nunito', sans-serif; font-size: 0.875rem; font-weight: 500; line-height: 1.4; white-space: nowrap; cursor: pointer; transition: background 150ms cubic-bezier(0.4,0,0.2,1), color 150ms cubic-bezier(0.4,0,0.2,1); } .ds-segment:hover { color: var(--color-text-primary, #2e2923); } .ds-segment:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: -2px; } .ds-segment--on { background: var(--color-background-muted, #ede8e0); color: var(--color-text-primary, #2e2923); font-weight: 600; }" + }, + { + "name": "Bundle Card", + "kind": "card", + "refersTo": "card-compact", + "description": "Compact card in the home page's bundles column: coral tile, mono output pattern with {locale} promoted to ink, labelled meta rows, and a success result strip above the footer.", + "html": "

App Shell

./src/assets/i18n/{locale}.json

Collections
  • common
  • apps
Typessrc/app/i18n-tokens.ts
Generated·4 files·312 keys per locale·no warnings
4 locales
", + "css": ".ds-bundle-card { display: flex; flex-direction: column; padding: 1rem; border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-xl, 0.75rem); background: var(--color-background, #faf8f5); box-shadow: var(--shadow-sm, 0 1px 3px 0 rgb(44 36 28 / 0.06)); font-family: 'Nunito', sans-serif; color: var(--color-text-primary, #2e2923); transition: transform 200ms cubic-bezier(0.4,0,0.2,1), box-shadow 200ms cubic-bezier(0.4,0,0.2,1), border-color 200ms cubic-bezier(0.4,0,0.2,1), background 200ms cubic-bezier(0.4,0,0.2,1); } .ds-bundle-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md, 0 4px 8px -1px rgb(44 36 28 / 0.08), 0 2px 4px -2px rgb(44 36 28 / 0.06)); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 40%, var(--color-border, #e5dfd7)); background: color-mix(in srgb, var(--color-primary, #d4726a) 3%, var(--color-background, #faf8f5)); } .ds-bundle-card:hover .ds-bc-tile { background: color-mix(in srgb, var(--color-primary, #d4726a) 20%, transparent); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 45%, transparent); } .ds-bc-head { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 0.75rem; align-items: start; } .ds-bc-tile { display: grid; place-items: center; width: 38px; height: 38px; border-radius: var(--border-radius-lg, 0.5rem); background: color-mix(in srgb, var(--color-primary, #d4726a) 12%, transparent); border: 1px solid color-mix(in srgb, var(--color-primary, #d4726a) 25%, transparent); color: var(--color-primary, #d4726a); transition: background 200ms cubic-bezier(0.4,0,0.2,1), border-color 200ms cubic-bezier(0.4,0,0.2,1); } .ds-bc-name { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -0.01em; line-height: 1.25; } .ds-bc-path { margin: 0.25rem 0 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; line-height: 1.45; color: var(--color-text-secondary, #5a534a); overflow-wrap: anywhere; } .ds-bc-var { color: var(--color-text-primary, #2e2923); font-weight: 600; } .ds-bc-meta { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.75rem; } .ds-bc-row { display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .ds-bc-label { flex: none; width: 66px; font-size: 0.75rem; color: var(--color-text-secondary, #5a534a); } .ds-bc-mono { font-family: 'IBM Plex Mono', monospace; font-size: 0.75rem; overflow-wrap: anywhere; } .ds-bc-chips { display: flex; flex-wrap: wrap; gap: 0.5rem 0.25rem; margin: 0; padding: 0; list-style: none; } .ds-bc-chip { display: inline-flex; align-items: center; padding: 1px 0.5rem 1px 6px; border: 1px solid var(--color-border, #e5dfd7); border-radius: var(--border-radius-full, 9999px); background: var(--color-background-subtle, #f5f1eb); font-size: 0.75rem; font-weight: 500; line-height: 1.5; white-space: nowrap; } .ds-bc-chip--linked { background: color-mix(in srgb, var(--color-primary, #d4726a) 8%, transparent); border-color: color-mix(in srgb, var(--color-primary, #d4726a) 45%, var(--color-border, #e5dfd7)); color: var(--color-primary-text, #a1443d); } .ds-bc-result { display: flex; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 0.5rem 0.75rem; border: 1px solid color-mix(in srgb, var(--color-success, #6da67e) 30%, transparent); border-radius: var(--border-radius-md, 0.375rem); background: color-mix(in srgb, var(--color-success, #6da67e) 10%, transparent); color: color-mix(in srgb, var(--color-success, #6da67e) 55%, var(--color-text-primary, #2e2923)); font-size: 0.75rem; line-height: 1.45; } .ds-bc-result-text { flex: 1; min-width: 0; } .ds-bc-result b { font-weight: 700; } .ds-bc-sep { margin: 0 5px; opacity: 0.6; } .ds-bc-details { border: 0; padding: 0 2px; background: none; color: inherit; font: inherit; font-weight: 600; cursor: pointer; } .ds-bc-details:focus-visible { outline: 2px solid var(--focus-ring-color, #4a7fb5); outline-offset: 2px; } .ds-bc-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border-subtle, #f0ebe4); } .ds-bc-count { font-size: 0.75rem; color: var(--color-text-secondary, #5a534a); }" + } + ], + "narrative": { + "northStar": "The Watercolor Ledger", + "overview": "LingoTracker is a workbench for translation files that live in a developer's own repository, and its surfaces look like warm paper with careful ink on it. The page is parchment with a faint fractal-noise grain fixed over everything; panels and cards sit one tonal step off the page rather than floating on heavy shadows. Two pigments carry all the meaning: a coral/vermillion primary for the brand mark, the primary action, and the single top rule on every dialog, and a deep sky blue that is reserved for the keyboard focus ring, the input focus glow, and the \"base locale\" tint. Everything else is a warm neutral.\n\nDensity is operational, not editorial. Labels sit above their fields, groups are introduced by a short bold heading and a one-line hint, hints are 12px secondary ink, and anything that is a code or a path (locale codes, folder paths, file names) is set in IBM Plex Mono so it reads as data. The world is built over Angular Material (M2 theme, density -1), but the visible surfaces override Material chrome: dialogs drop their container padding, inputs are hand-drawn wells rather than Material outline fields, chips are the project's own pills.\n\nConfirmed rejections from the shipped code: no hard offset shadows, no decorative gradients except the four-pigment header rule, no all-caps section headings, no Material outline fields inside the dialog family.", + "keyCharacteristics": [ + "Parchment ground with a fixed paper-grain overlay in both themes.", + "One accent (coral) for identity and the primary action; one accent (sky) for focus and \"base\".", + "3px coral top rule on every dialog panel; hairline (1px --color-border) separations everywhere else.", + "Labels above fields; group heading + one-line hint introduces each section.", + "Codes and paths in IBM Plex Mono at the surrounding size; prose in Nunito.", + "Soft, warm-tinted shadows used only for lifted panels and hover; tonal steps convey depth at rest.", + "Light and dark themes share one token set; the dark scale is the light neutral scale inverted." + ], + "rules": [ + { + "name": "The Two-Pigment Rule", + "body": "Coral means identity and the primary action; sky means focus and \"base\". A surface that uses coral for focus, or sky for a button, is off-world.", + "section": "colors" + }, + { + "name": "The Text-Weight Rule", + "body": "Fill tones (primary, warning, ink-tertiary) never set type. Copy uses primary-text, warning-text, or ink-secondary and clears 4.5:1 on its own surface.", + "section": "colors" + }, + { + "name": "The Status-Spine Rule", + "body": "A status colour comes from the status-* tokens and never from a local hex; new and stale are never conflated.", + "section": "colors" + }, + { + "name": "The Mono-Is-Data Rule", + "body": "Anything the file system or the config would spell out verbatim (locale code, folder path, file name, key) is set in IBM Plex Mono. Inside prose it is an inline at 0.95em in ink, with no background, so the line height does not jump.", + "section": "typography" + }, + { + "name": "The Label-Above Rule", + "body": "Form labels sit above their controls as 14px semibold Nunito. No floating labels, no placeholder-as-label.", + "section": "typography" + }, + { + "name": "The Tonal-First Rule", + "body": "A surface at rest shows its depth by tone and hairline, not shadow. Shadows answer to state (hover, lifted overlay, focus).", + "section": "elevation" + }, + { + "name": "The Top-Rule Rule", + "body": "Every dialog panel carries exactly one 3px solid --color-primary top border and zero container padding; the rule is the dialog's only decoration.", + "section": "elevation" + }, + { + "name": "The Separator-Break Rule", + "body": "A long mono identifier the user has to read back (an output pattern, a generated token path, a file tree entry) is split in the component after each `.`, `_`, `-`, or `/` and rejoined in the template with ``, so it wraps between segments under `overflow-wrap: break-word`. `overflow-wrap: anywhere` stays the fallback for strings a template only ever receives whole (card paths, warning prose).", + "section": "typography" + }, + { + "name": "The Hover-Link Rule", + "body": "Hovering or focusing a bundle card states the relationship instead of describing it: the bundle takes the card hover treatment (lift, `md`, 40% coral border, hover ground, tile to 20%/45% coral), each collection it consumes takes the same treatment via `.linked`, that bundle's own collection chips flip to the coral voice (8% coral ground, 45% coral border, `primary-text`), and every unrelated collection drops to `opacity: 0.5`. The dim is opacity only; nothing moves, and nothing recolours to say \"off\".", + "section": "components" + } + ], + "dos": [ + "Do open every dialog with the 3px coral top rule, zero container padding, lg shadow, and the header / hairline / body / hairline / footer column.", + "Do put labels above controls at 14px/600 and hints below at 12px ink-secondary.", + "Do set locale codes, paths, and file names in IBM Plex Mono; inline them in prose as at 0.95em in ink with no background.", + "Do use sky blue for focus (2px outline, or border plus 22% glow on wells) and for the base-locale tint; use coral for the primary action and identity.", + "Do mark locked and read-only states with the amber lock tile (14% amber fill, warning-text ink) so they are unmistakable.", + "Do reuse the locale pill for any locale, the text chip for any word-like tag, and the chip editor for any editable list.", + "Do place surfaces one neutral step off their parent and separate them with 1px hairlines before reaching for a shadow.", + "Do honour prefers-reduced-motion by disabling reveal animations and hover lifts.", + "Do build page- and section-level actions from the hand-rolled `.btn` recipes (`--primary` filled coral, `--outline` hairline, `--sm` for card-level repeats); Material's palette does not resolve to `--color-primary`.", + "Do split long mono identifiers after `.`, `_`, `-`, and `/` and emit `` between the parts when the component owns the string.", + "Do keep an affirmative footer glyph tied to real form validity: `success` when valid, `ink-tertiary` while not.", + "Do state a relationship with the existing hover treatment plus a 0.5 dim on the unrelated, rather than inventing a highlight colour." + ], + "donts": [ + "Don't set type in primary, warning, or ink-tertiary; use the text-weight tokens.", + "Don't use Material outline form fields inside the dialog family; the label-above well is the field.", + "Don't use uppercase or letterspaced text outside the BASE tag inside a chip.", + "Don't use Grechen Fuemen anywhere but the wordmark.", + "Don't add hard offset shadows, gradients on controls, or a second accent rule on a dialog.", + "Don't hard-code a status hue; read the status-* tokens.", + "Don't use Material button toggles for an exclusive choice inside a dialog; the segmented control is the form.", + "Don't let an in-card result strip grow without a ceiling: clamp prose warnings and put the full text behind `title`, not on the page." + ] + } +} diff --git a/apps/tracker/.impeccable/surfaces/app-collections-collections-manager-html-dafe3602.md b/apps/tracker/.impeccable/surfaces/app-collections-collections-manager-html-dafe3602.md new file mode 100644 index 00000000..ac58bf06 --- /dev/null +++ b/apps/tracker/.impeccable/surfaces/app-collections-collections-manager-html-dafe3602.md @@ -0,0 +1,37 @@ +--- +version: 1 +slug: "app-collections-collections-manager-html-dafe3602" +primary_target: "apps/tracker/src/app/collections/collections-manager.html" +related_targets: ["apps/tracker/src/app/collections/collections-manager.ts","apps/tracker/src/app/collections/collections-manager.scss"] +--- + +# Surface: Tracker home (collections page, route `/collections`) + +Scope: the app landing page rendered by `CollectionsManager`. Visitor mode: **Operate**. + +Audience and job: a developer mid-feature who wants to open a collection and get to a key; the i18n owner who, before a release, regenerates bundles and confirms they wrote cleanly. Task frequency: collections daily, bundles at release time. + +Content: the project's collections (identity cards: tile, name, mono folder path, read-only lock, locale chips with BASE tag and +N overflow, Edit/Delete menu) and its bundle definitions from `.lingo-tracker.json` (name, `dist` + `bundleName` output pattern, collections consumed or "All collections", `typeDistFile`, locale count). Actions: Add collection; Add bundle; Edit/Delete bundle; Generate per bundle; Generate all. States: populated; collections but no bundles; a bundle mid-generation; generation succeeded; generation failed. + +Constraints: identity-only collection cards (no key counts or status rollups this round). Bundle editor dialog, API/DTO/store code, header nav as a system pattern, and the empty-project onboarding state are out of scope and get their own passes. URL stays `/collections` for now. Every string goes through Transloco. + +## Direction contract + +THESIS: Bundles are the output of the collections beside them, so they live in a persistent right-hand column instead of a second stacked section or a separate tab. The page refuses the "list of cards, then another list of cards" arrangement and the tabbed dashboard. + +OWN-WORLD: The Watercolor Ledger, unchanged: parchment-subtle page with paper grain, parchment cards one tonal step up with hairline borders, coral tiles and the single filled coral action per section, sky reserved for BASE chips and focus, IBM Plex Mono for every path, file, locale code, and the `{locale}` placeholder (ink, weight 600, never sky). Bundle cards share the collection card anatomy; a bundle's tile glyph is `inventory_2`, a collection's is `folder`. Generation results are quiet strips inside the card (success tint, amber warning-text for warnings, neutral with a 2px coral progress bar while running). + +STORY: The visitor lands, sees the project by name, its collections on the left and its bundles on the right, and understands which files feed which outputs by hovering a bundle. They open a collection to work, or press Generate and read the result without leaving the page. + +FIRST VIEWPORT: Toolbar as today. Page header: display title = the served project's folder name (`lingo-tracker`), subtitle `.lingo-tracker.json · 4 collections · 2 bundles · base en` (mono for file and locale), watercolor rule. Below, a two-column grid: left `minmax(0,1fr)` Collections section (h2 + count, filled coral "Add collection" right-aligned, auto-fill card grid at `minmax(300px,1fr)`); right 420px sticky Bundles column (h2 + count, small outline "Generate all" and small filled "Add bundle", caption "Hover a bundle to see the collections it includes.", stacked compact bundle cards). Each bundle card: tile, name, mono output path, Collections row of text chips, Types row with mono path, optional result strip, footer with locale count and an outline Generate button. Under 1100px the columns stack, collections first, bundles as a one- or two-column grid. Empty bundles: a dashed card in the column with a one-sentence explanation and a filled coral "Create bundle"; header actions hidden. + +Signature interaction: hovering or focusing a bundle card lifts it (hover border/shadow), tints the chips of the collections it consumes coral, highlights those collection cards with the card hover treatment, and dims the others to 50% opacity; leaving restores. "All collections" highlights every card. Generate: the button becomes a disabled "Generating…" with spinner, the strip shows the file being written and n of N locales with a determinate bar, then swaps to the result strip (files written, keys per locale, warnings) with a Details disclosure and relative time; failure uses the error tint with a Retry action. Reduced motion disables lift and bar animation. + +FORM: Direction C — Split view, user-chosen from three brief-pinned, hand-built HTML comps (A stacked ledger, B segmented tabs, C split view). No concept-seed roll was run: the structures were pinned in the brief. Approved comp: `.impeccable/mocks/home/c-split-populated-light-1440.png` (source `c-split.html`, sidecar `c-split.json` approved: true). Heading chosen from four rendered variants (`title-*.png`). + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance. + +## Unresolved for the build round +- API must expose the project root folder name and include `bundles` in the config DTO (the mapper currently drops it); a generate endpoint is needed for the run state. +- Result strip copy for warnings and failures is only specified in words; comp shows success and running. +- Whether the browser header's back link says the project name or "Collections". diff --git a/apps/tracker/.impeccable/surfaces/n-form-dialog-collection-form-dialog-html-71ddd2b8.md b/apps/tracker/.impeccable/surfaces/n-form-dialog-collection-form-dialog-html-71ddd2b8.md new file mode 100644 index 00000000..57133bd4 --- /dev/null +++ b/apps/tracker/.impeccable/surfaces/n-form-dialog-collection-form-dialog-html-71ddd2b8.md @@ -0,0 +1,32 @@ +--- +version: 1 +slug: "n-form-dialog-collection-form-dialog-html-71ddd2b8" +primary_target: "apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.html" +related_targets: ["apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.ts","apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.scss"] +--- + +# Surface: Collection form dialog (create / edit) + +Scope: `apps/tracker/src/app/collections/collection-form-dialog/*`, opened from the Collections manager. Mode: Operate. Refinement of an established surface; the watercolor world and Angular Material component base are inherited, not reopened. + +Audience and job: a developer registering a folder of translation files as a collection, or adjusting an existing one. Occasional task, done with the folder path already in mind. Translators and the i18n owner edit locales and protected terms less often. + +Constraints: every string via Transloco resources; read-only state must be unmistakable; base locale is immutable after creation; protected terms editable only when a terms file is configured; delete stays in the card menu. + +## Direction contract + +THESIS: One dialog, three labelled groups (Source, Locales, Options) that read top to bottom in the order a developer thinks. It refuses the flat stack of equally weighted outlined fields and the radio-list of locales. + +OWN-WORLD: Parchment panel with the 3px coral top rule and header pattern of the translation editor. Nunito labels above fields, IBM Plex Mono for paths and locale codes. Locales are pill chips identical to the Collections card chips, base chip in the sky-blue tint with a BASE tag. Read-only is a switch row on a subtle panel with a warm lock tile. Optional metadata sits in a single disclosure row with counts. + +STORY: The visitor sees where the files live, which locales are tracked and which one is the base, and whether the collection is locked, then creates or saves without scrolling past things that do not apply. + +FIRST VIEWPORT: 540px panel. Header: folder tile, title, one-sentence purpose, close. Body: Source (name, folder with hint), Locales (chip row with inline add input, one hint line), Options (read-only switch row, disclosure "Tags & protected terms"). Footer right-aligned: Cancel, primary "Create collection" / "Save changes". + +FORM: Direction A "Grouped ledger", chosen by the user from three hand-built HTML comps (`.impeccable/mocks/collection-dialog/a-ledger*.png`, approved sidecars). No concept-seed roll: this is a refinement inside an established surface, so no seed key exists. + +Signature interaction: clicking a locale chip in create mode makes it the base; the BASE tag slides to it. In edit mode the base chip carries a lock and its remove control is absent. + +Approved comp: `.impeccable/mocks/collection-dialog/a-ledger-create-light.png` and `a-ledger-edit-light.png`. + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance. diff --git a/apps/tracker/.impeccable/surfaces/undle-form-dialog-bundle-form-dialog-html-1bd13ed1.md b/apps/tracker/.impeccable/surfaces/undle-form-dialog-bundle-form-dialog-html-1bd13ed1.md new file mode 100644 index 00000000..4ba27e0f --- /dev/null +++ b/apps/tracker/.impeccable/surfaces/undle-form-dialog-bundle-form-dialog-html-1bd13ed1.md @@ -0,0 +1,38 @@ +--- +version: 1 +slug: "undle-form-dialog-bundle-form-dialog-html-1bd13ed1" +primary_target: "apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.html" +related_targets: ["apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts","apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.scss"] +--- + +# Surface: Bundle form dialog (create / edit) + +Scope: a new dialog component under `apps/tracker/src/app/collections/bundle-form-dialog/`, opened from the home page's "Add bundle" button and from a bundle card's Edit action. Mode: **Operate**. New surface inside the established world; the collection form dialog is its sibling and the anatomy is inherited. + +Audience and job: the i18n owner or a developer defining which collections and entries ship in which output files, occasionally, with the output path and the consuming app in mind. The task is unforgiving: a wrong rule silently drops keys from a release, so the dialog must show what it will write before saving. + +Content (from `BundleDefinition`): name (key of `bundles`, immutable in edit), `dist`, `bundleName` pattern with `{locale}`, `collections` = All or per-collection entries with optional `bundledKeyPrefix`, `entriesSelectionRules` All or rules of `matchingPattern` + `matchingTags` + `matchingTagOperator`, and `mergeStrategy`; `typeDistFile`, `tokenCasing`, `tokenConstantName`; `transformICUToTransloco`. + +Constraints: every string via Transloco resources. Name locked after creation. Casing and ICU options inherit project defaults and say so. Delete stays in the card menu. Needs a dry-run API (files to be written, keys per locale, conflicts, locales) that recomputes as the form changes; without it the preview column shows the file tree only and states that counts are unavailable. + +## Direction contract + +THESIS: A master-detail dialog: a rail of sections on the left, one section's form in the middle, and a live preview of what the bundle will write on the right. It refuses the single scrolling form (the collection dialog's shape, too long for per-collection rules) and the wizard. + +OWN-WORLD: The Watercolor Ledger dialog family: parchment panel, 3px coral top rule, coral header tile (`add_box` create, `inventory_2` edit), Headline title with a one-line subtitle, close at top right, hairline-separated header/body/footer. Rail on parchment-subtle with a right hairline; the active item lifts to parchment with `sm` shadow and a coral glyph. Labels above wells, IBM Plex Mono for every path, pattern, key, and tag. Segmented controls are parchment-muted pills inside a hairline well, never Material outline fields. Switch rows with coral-tinted 30px tiles introduce Types and Options. The preview column is parchment-subtle with a left hairline, mono file tree, "new" pills in coral-text on a 12% coral tint, a dry-run definition list, and one example key in a bordered well. + +STORY: The visitor sees the bundle's sections at a glance, works one section at a time with room for its rules, and watches the right column update with the files, counts, and an example key, so they know what saving will change before they press the primary button. + +FIRST VIEWPORT: 1160px panel, columns 240px rail / fluid pane / 320px preview, body min-height about 520px, panel capped at 90vh with the pane owning scroll. Rail items in order: Output (mono summary `dist/pattern.json`), Collections with a count and one child per collection showing "all" or "n rules", "Add a collection", hairline, Types (file name or "off"), Options ("ICU → Transloco"). Create opens on the first collection's pane; edit opens on Output. Collection pane: title + Remove text button, Key prefix and On key conflicts (First wins / This overrides) side by side, Entries segmented (All entries / Only matching rules), Rules table (Key pattern mono well, Tags well with chips and an operator between them, remove), "Add rule". Preview column: "Will write" tree, "Dry run" list (collections, keys per locale, conflicts, locales) with a caption that it recomputes from the resources on disk, "Example key" showing source key, prefixed key, and token path. Footer: validity summary at left ("2 collections · 6 files · types on"), Cancel, primary "Create bundle" / "Save changes". + +Signature interaction: editing any field re-runs the dry run and the preview column updates in place; files that do not exist yet carry a "new" pill; a rule change moves the example key. Selecting a rail item swaps the pane without closing the dialog; an invalid section shows an error dot in the rail. + +FORM: Direction D, a user-directed merge of comp C (sections rail) and comp B (output preview), with the third-column placement chosen over the in-rail placement after both were rendered. No concept-seed roll: the four structures were brief-pinned and hand-built. Approved comps: `.impeccable/mocks/bundle-dialog/d-rail-preview-column-create-light.png` and `d-rail-preview-column-edit-light.png` (sidecar `d-rail-preview.json`, approved: true). + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance. + +## Unresolved for the build round +- Dry-run endpoint contract and its latency; debounce and a stale indicator while recomputing. +- "Add a collection" picker: inline list in the pane or a menu from the rail. +- Behaviour below 1200px viewport: collapse the preview to a footer disclosure or hide the rail behind a section select. +- Validation copy for name collisions, non-`.ts` type file, and patterns without `{locale}`. diff --git a/apps/tracker/DESIGN.md b/apps/tracker/DESIGN.md new file mode 100644 index 00000000..87c9e4f3 --- /dev/null +++ b/apps/tracker/DESIGN.md @@ -0,0 +1,467 @@ +--- +name: LingoTracker +description: Warm watercolor parchment workbench for translation files; coral accent, sky-blue focus, mono for anything that is a code or a path. +colors: + primary: "#d4726a" + primary-hover: "#c25d55" + primary-active: "#b04a42" + primary-subtle: "#fdf2f1" + primary-text: "#a1443d" + secondary: "#4a7fb5" + secondary-hover: "#3a6a9e" + secondary-active: "#2d5888" + secondary-subtle: "#eef5fb" + parchment: "#faf8f5" + parchment-subtle: "#f5f1eb" + parchment-muted: "#ede8e0" + ink: "#2e2923" + ink-secondary: "#5a534a" + ink-tertiary: "#a89f93" + ink-placeholder: "#736b61" + ink-on-primary: "#2e2923" + border: "#e5dfd7" + border-subtle: "#f0ebe4" + border-strong: "#d4cdc3" + focus-ring: "#4a7fb5" + success: "#6da67e" + warning: "#d9a441" + warning-text: "#8a5a00" + error: "#c95e5e" + info: "#5a8db8" + highlight: "#fde8a0" + status-new: "#a87716" + status-stale: "#b85c26" + status-translated: "#3f7aa8" + status-verified: "#4e8a63" + wc-coral: "#e8847c" + wc-amber: "#e8a838" + wc-sky: "#7bafde" + wc-lavender: "#b892cc" + wc-sage: "#8db580" +typography: + brand: + fontFamily: "Grechen Fuemen, cursive" + display: + fontFamily: "Nunito, sans-serif" + fontSize: "clamp(1.75rem, 2.6vw, 2.25rem)" + fontWeight: 700 + lineHeight: 1.25 + letterSpacing: "-0.02em" + headline: + fontFamily: "Nunito, sans-serif" + fontSize: "1.5rem" + fontWeight: 600 + lineHeight: 1.25 + letterSpacing: "-0.01em" + title: + fontFamily: "Nunito, sans-serif" + fontSize: "1.125rem" + fontWeight: 700 + lineHeight: 1.25 + letterSpacing: "-0.01em" + group-title: + fontFamily: "Nunito, sans-serif" + fontSize: "1rem" + fontWeight: 700 + lineHeight: 1.375 + body: + fontFamily: "Nunito, sans-serif" + fontSize: "1rem" + fontWeight: 400 + lineHeight: 1.5 + body-small: + fontFamily: "Nunito, sans-serif" + fontSize: "0.875rem" + fontWeight: 400 + lineHeight: 1.375 + label: + fontFamily: "Nunito, sans-serif" + fontSize: "0.875rem" + fontWeight: 600 + lineHeight: 1.5 + caption: + fontFamily: "Nunito, sans-serif" + fontSize: "0.75rem" + fontWeight: 400 + lineHeight: 1.375 + tag: + fontFamily: "Nunito, sans-serif" + fontSize: "0.625rem" + fontWeight: 600 + letterSpacing: "0.05em" + mono-code: + fontFamily: "IBM Plex Mono, monospace" + fontSize: "0.875rem" + fontWeight: 600 + mono-path: + fontFamily: "IBM Plex Mono, monospace" + fontSize: "0.75rem" + fontWeight: 400 + lineHeight: 1.45 +rounded: + sm: "0.25rem" + md: "0.375rem" + lg: "0.5rem" + xl: "0.75rem" + full: "9999px" +spacing: + "1": "0.25rem" + "2": "0.5rem" + "3": "0.75rem" + "4": "1rem" + "5": "1.25rem" + "6": "1.5rem" + "8": "2rem" + "10": "2.5rem" + "12": "3rem" + "16": "4rem" +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.ink-on-primary}" + rounded: "{rounded.lg}" + padding: "0 {spacing.5}" + height: "40px" + typography: "{typography.label}" + button-primary-hover: + backgroundColor: "{colors.primary-hover}" + textColor: "{colors.ink-on-primary}" + button-outline: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + rounded: "{rounded.lg}" + padding: "0 {spacing.5}" + height: "40px" + typography: "{typography.label}" + button-outline-hover: + backgroundColor: "{colors.parchment-subtle}" + textColor: "{colors.ink}" + button-sm: + rounded: "{rounded.md}" + padding: "0 {spacing.3}" + height: "34px" + button-busy: + backgroundColor: "{colors.parchment-subtle}" + textColor: "{colors.ink-secondary}" + button-text: + backgroundColor: "transparent" + textColor: "{colors.ink-secondary}" + typography: "{typography.label}" + button-text-hover: + textColor: "{colors.ink}" + card: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + rounded: "{rounded.xl}" + padding: "{spacing.5}" + card-compact: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + rounded: "{rounded.xl}" + padding: "{spacing.4}" + segmented-control: + backgroundColor: "{colors.parchment}" + rounded: "{rounded.md}" + padding: "2px" + segment: + backgroundColor: "transparent" + textColor: "{colors.ink-secondary}" + rounded: "{rounded.sm}" + padding: "5px {spacing.3}" + typography: "{typography.body-small}" + segment-on: + backgroundColor: "{colors.parchment-muted}" + textColor: "{colors.ink}" + rail-item: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "{spacing.2} 10px" + typography: "{typography.label}" + rail-item-active: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + preview-pill: + textColor: "{colors.primary-text}" + rounded: "{rounded.full}" + padding: "0 6px" + dialog-header-tile: + textColor: "{colors.primary}" + rounded: "{rounded.lg}" + size: "38px" + text-input: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + padding: "0 {spacing.3}" + height: "40px" + typography: "{typography.body}" + text-input-mono: + backgroundColor: "{colors.parchment}" + textColor: "{colors.ink}" + rounded: "{rounded.md}" + height: "40px" + typography: "{typography.mono-code}" + chip-editor: + backgroundColor: "{colors.parchment}" + rounded: "{rounded.md}" + padding: "{spacing.2}" + chip-locale: + backgroundColor: "{colors.parchment-muted}" + textColor: "{colors.ink-secondary}" + rounded: "{rounded.full}" + padding: "3px {spacing.2} 3px {spacing.3}" + typography: "{typography.mono-code}" + chip-text: + backgroundColor: "{colors.parchment-subtle}" + textColor: "{colors.ink}" + rounded: "{rounded.full}" + padding: "3px {spacing.2} 3px {spacing.3}" + typography: "{typography.body-small}" + switch-row: + backgroundColor: "{colors.parchment-subtle}" + textColor: "{colors.ink}" + rounded: "{rounded.lg}" + padding: "{spacing.3} {spacing.4}" + switch-tile: + textColor: "{colors.warning-text}" + rounded: "{rounded.md}" + size: "30px" + disclosure: + backgroundColor: "transparent" + textColor: "{colors.ink}" + rounded: "{rounded.lg}" + padding: "{spacing.2} {spacing.3} {spacing.2} {spacing.4}" +--- + +# Design System: LingoTracker + +## Overview + +**Creative North Star: "The Watercolor Ledger"** + +LingoTracker is a workbench for translation files that live in a developer's own repository, and its surfaces look like warm paper with careful ink on it. The page is parchment with a faint fractal-noise grain fixed over everything; panels and cards sit one tonal step off the page rather than floating on heavy shadows. Two pigments carry all the meaning: a coral/vermillion primary for the brand mark, the primary action, and the single top rule on every dialog, and a deep sky blue that is reserved for the keyboard focus ring, the input focus glow, and the "base locale" tint. Everything else is a warm neutral. + +Density is operational, not editorial. Labels sit above their fields, groups are introduced by a short bold heading and a one-line hint, hints are 12px secondary ink, and anything that is a code or a path (locale codes, folder paths, file names) is set in IBM Plex Mono so it reads as data. The world is built over Angular Material (M2 theme, density -1), but the visible surfaces override Material chrome: dialogs drop their container padding, inputs are hand-drawn wells rather than Material outline fields, chips are the project's own pills. + +Confirmed rejections from the shipped code: no hard offset shadows, no decorative gradients except the four-pigment header rule, no all-caps section headings, no Material outline fields inside the dialog family. + +**Key Characteristics:** +- Parchment ground with a fixed paper-grain overlay in both themes. +- One accent (coral) for identity and the primary action; one accent (sky) for focus and "base". +- 3px coral top rule on every dialog panel; hairline (`1px --color-border`) separations everywhere else. +- Labels above fields; group heading + one-line hint introduces each section. +- Codes and paths in IBM Plex Mono at the surrounding size; prose in Nunito. +- Soft, warm-tinted shadows used only for lifted panels and hover; tonal steps convey depth at rest. +- Light and dark themes share one token set; the dark scale is the light neutral scale inverted. + +## Colors + +A warm parchment neutral scale carrying two pigments, coral and sky, with a quiet four-colour watercolor accent set used only for decoration. + +### Primary +- **Coral / Vermillion** (`{colors.primary}`): the brand pigment. Used for the dialog top rule, filled primary buttons, the folder tile tint on cards and dialog headers (12% fill, 25% border), selected/hover card borders, and the base-locale left rule in the translation editor. Hover and active steps darken (`primary-hover`, `primary-active`). +- **Coral Text** (`{colors.primary-text}`): the text-weight coral. The fill tone only reaches 2.68:1 on a card, so any copy that must read as "primary" uses this instead (5.05:1). In dark theme the brightened primary already clears 4.5:1 and `primary-text` aliases it. +- **Coral Subtle** (`{colors.primary-subtle}`): a near-white blush for selected rows and soft highlights. + +### Secondary +- **Deep Sky Blue** (`{colors.secondary}`): the attention pigment. It is the focus-ring colour (`{colors.focus-ring}` is the same value), the focused-input border and 22% glow, and the "base locale" chip tint (16% fill, 35% border, 45% mixed into the text). It is never a fill for a primary action. +- **Sky Subtle** (`{colors.secondary-subtle}`): info wells and soft secondary backgrounds. + +### Tertiary +- **Amber Warning** (`{colors.warning}` / `{colors.warning-text}`): the "locked" hue. Read-only tiles use a 14% amber fill with `warning-text` ink; the switched-on read-only row mixes 6% amber into its ground and 35% into its border. `warning` itself is fill/border only (about 2.1:1 on parchment) and must never set type. +- **Error** (`{colors.error}`): invalid borders, error hints, the remove-chip hover. +- **Success / Info** (`{colors.success}`, `{colors.info}`): status glyphs and banners. + +### Neutral +- **Parchment** (`{colors.parchment}`): the page and the card surface, the dialog panel in light theme, and the field well. +- **Parchment Subtle** (`{colors.parchment-subtle}`): the switch-row ground, text-chip fill, disclosure hover, and the dialog panel in dark theme (where it lifts one step above the page black). +- **Parchment Muted** (`{colors.parchment-muted}`): locale-chip fill and inline-code background. +- **Ink** (`{colors.ink}`): titles, labels, field values, inline `` in prose. +- **Ink Secondary** (`{colors.ink-secondary}`): subtitles, hints, resting chip text, close/menu icons. +- **Ink Tertiary** (`{colors.ink-tertiary}`): decorative glyphs only (leading icons in inputs, chevrons, remove buttons at rest); at 2.5:1 it is not for text. +- **Ink Placeholder** (`{colors.ink-placeholder}`): placeholder copy, darker than tertiary so it clears 4.5:1. +- **Border / Border Subtle / Border Strong** (`{colors.border}`, `{colors.border-subtle}`, `{colors.border-strong}`): hairlines. Panels and rows use `border`; inner dividers use `border-subtle`; input wells and dashed empty states use `border-strong`. + +### Status spine +- **New** (`{colors.status-new}`), **Stale** (`{colors.status-stale}`), **Translated** (`{colors.status-translated}`), **Verified** (`{colors.status-verified}`): every surface that reports a status reads these tokens, with `-on-dark` variants for the always-dark tooltip. `new` and `stale` keep saturated chip fills; `translated` and `verified` go near-neutral with the hue carried by ink and border only. + +### Watercolor accents +- **Coral, Amber, Sky, Lavender, Sage** (`{colors.wc-coral}` and siblings): decorative pigments for the header rule gradient, empty-state rings, and splashes. Never used for controls or text. + +### Named Rules +**The Two-Pigment Rule.** Coral means identity and the primary action; sky means focus and "base". A surface that uses coral for focus, or sky for a button, is off-world. + +**The Text-Weight Rule.** Fill tones (`primary`, `warning`, `ink-tertiary`) never set type. Copy uses `primary-text`, `warning-text`, or `ink-secondary` and clears 4.5:1 on its own surface. + +**The Status-Spine Rule.** A status colour comes from the `status-*` tokens and never from a local hex; `new` and `stale` are never conflated. + +## Typography + +**Brand Font:** Grechen Fuemen (with cursive fallback); the LingoTracker wordmark in the app header only. +**Body Font:** Nunito (with sans-serif fallback); every heading, label, control, and paragraph. +**Label/Mono Font:** IBM Plex Mono (with monospace fallback); locale codes, folder paths, file names, resource keys. + +**Character:** Nunito's rounded terminals keep a data-heavy tool friendly; Plex Mono's stiffness makes codes and paths read as facts. The pairing is a handwritten ledger with typed entries. Grechen Fuemen never appears outside the wordmark. + +### Hierarchy +- **Display** (700, `clamp(1.75rem, 2.6vw, 2.25rem)`, 1.25, -0.02em): page titles ("Collections"). +- **Headline** (600, 1.5rem, 1.25, -0.01em): dialog titles, shared by the translation editor and the collection form. +- **Title** (700, 1.125rem, 1.25, -0.01em): card names. +- **Group title** (700, 1rem, 1.375): the `h3` that opens a labelled group inside a form (Source, Locales, Options), always followed by a 12px hint. +- **Body** (400, 1rem, 1.5): field values, standard paragraphs. +- **Body small** (400, 0.875rem, 1.375): dialog subtitles, card path-adjacent copy. +- **Label** (600, 0.875rem, 1.5): field labels above inputs, disclosure titles, button text; switch labels step up to 700. +- **Caption** (400, 0.75rem, 1.375–1.5): group hints, field hints, errors, disclosure summaries, switch descriptions. +- **Tag** (600, 0.625rem, +0.05em, uppercase): the BASE tag inside a locale chip; the only uppercase text in the system, and it never leaves the chip. +- **Mono code** (600, 0.875rem): locale codes in chips and mono inputs. +- **Mono path** (400, 0.75rem, 1.45): folder paths on cards; two lines reserved so cards stay flush. + +### Named Rules +**The Mono-Is-Data Rule.** Anything the file system or the config would spell out verbatim (locale code, folder path, file name, key) is set in IBM Plex Mono. Inside prose it is an inline `` at 0.95em in `ink`, with no background, so the line height does not jump. + +**The Label-Above Rule.** Form labels sit above their controls as 14px semibold Nunito. No floating labels, no placeholder-as-label. + +**The Separator-Break Rule.** A long mono identifier the user has to read back (an output pattern, a generated token path, a file tree entry) is split in the component after each `.`, `_`, `-`, or `/` and rejoined in the template with ``, so it wraps between segments under `overflow-wrap: break-word`. `overflow-wrap: anywhere` stays the fallback for strings a template only ever receives whole (card paths, warning prose). + +## Layout + +Pages are centred columns capped at 1400px with `--spacing-8` vertical and `--spacing-6` horizontal padding, dropping to `--spacing-6`/`--spacing-4` under 600px. The Collections page is an auto-fill grid of cards at `minmax(min(100%, 340px), 1fr)` over equal `minmax(180px, 1fr)` rows, with a `--spacing-5` gap. + +Dialogs are fixed-width panels centred by the CDK overlay: the collection form is 540px (`max-width: min(540px, 100vw - 2 * --spacing-6)`, `max-height: min(90vh, 100dvh - 2 * --spacing-8)`); the translation editor is 700px and goes full-screen below 640px. Inside a dialog the column is header / scrolling body / footer, with the body owning the only scroll. Header padding is `--spacing-5 --spacing-6 --spacing-4`; body the same; footer `--spacing-4 --spacing-6 --spacing-5`. + +The Collections page is a two-column split: a fluid `minmax(0, 1fr)` column of collection cards beside a fixed 420px bundles column, `--spacing-10` apart, aligned to the top. The bundles column is sticky `--spacing-6` from the top, capped at `calc(100vh - 2 * --spacing-6)`, and scrolls inside that cap with contained overscroll and a thin `border-strong` scrollbar, so an expanded result strip can never push a card's Generate button below the fold. Under 1100px the split becomes one column at `--spacing-12`, the bundles column goes static and unclipped, and the bundle stack becomes an auto-fill grid at `minmax(min(100%, 380px), 1fr)`. + +**Section heads are two rows in both columns**: the `h2` and its count on the first, the controls right-aligned on the second (`flex-basis: 100%`), collapsing to a single stacked column under 600px. The 420px bundles column cannot hold its heading and two buttons on one line in any locale; letting only that head wrap would put the two `h2`s on different lines and start the two card stacks at different heights. Giving both heads the same shape makes the columns start flush by construction instead of by a reserved height that a longer translation would break. + +The bundle form dialog is the wide member of the dialog family: a 1160px panel (`max-width: calc(100vw - 3rem)`, same 90vh/100dvh ceiling and the same zero-padding, 3px-coral-rule container recipe as the 540px collection dialog) holding a master-detail grid of 240px rail / fluid pane / 320px preview over a 520px minimum body height, so switching sections never resizes the panel. Rail, pane, and preview each own their own scroll; the panel itself never does. Under 1200px the preview column is removed and its blocks reappear unchanged inside a disclosure pinned to the bottom of the pane; under 800px the rail becomes a wrapping horizontal strip above the pane, two-up field rows (`1fr 1fr`, `3fr 2fr`) collapse to one column, and the rules table drops its header row. + +Rhythm inside a form: groups stack at `--spacing-5`; within a group, heading-to-controls at `--spacing-3`; a field's label, control, and hint at `--spacing-1`; chips and inline controls at `--spacing-2`. Breakpoints follow the mixins in `styles/breakpoints.scss`: 640, 768, 1024, 1280, 1536px, mobile-first. + +## Elevation & Depth + +Depth is tonal first, shadowed second. Panels, wells, and rows are placed one neutral step off their parent (page → card → well in light theme; page black → subtle panel → page-black well in dark theme) and separated by hairlines. Shadows are warm-tinted (`rgb(44 36 28 / …)` in light, black in dark), diffuse, and appear only on lifted panels and hover. + +### Shadow Vocabulary +- **sm** (`0 1px 3px 0 rgb(44 36 28 / 0.06)`): card at rest; also the lift that marks the active item in the bundle dialog's rail. +- **md** (`0 4px 8px -1px rgb(44 36 28 / 0.08), 0 2px 4px -2px rgb(44 36 28 / 0.06)`): card hover with a 2px lift. +- **lg** (`0 10px 20px -3px rgb(44 36 28 / 0.1), 0 4px 8px -4px rgb(44 36 28 / 0.08)`): dialog panels. +- **xl**: defined, currently unused. +- **Focus glow** (`0 0 0 3px color-mix(in srgb, --color-secondary 22%, transparent)`): input and chip-editor focus; the error variant uses 18% of `error`. +- **Accent glow** (`0 6px 16px -4px color-mix(in srgb, --color-primary 45%, transparent)`): hover on the page's primary "Add" button only. + +### Named Rules +**The Tonal-First Rule.** A surface at rest shows its depth by tone and hairline, not shadow. Shadows answer to state (hover, lifted overlay, focus). + +**The Top-Rule Rule.** Every dialog panel carries exactly one `3px solid --color-primary` top border and zero container padding; the rule is the dialog's only decoration. + +## Shapes + +Soft, small radii scaled to the object: 4px for inline code, 6px for input wells, chip editors, and small tiles, 8px for cards' icon tiles, switch rows, disclosures, and dialog panels, 12px for collection cards, and full pills for every chip and the chip-add button. Borders are 1px hairlines; empty states (chip editor with no chips, the "+N" overflow chip, a locked value) switch to a dashed `border-strong` stroke rather than changing colour. Icon tiles are squares (38px header/card, 30px switch) with a translucent tint fill and a slightly stronger tint border in the same hue. + +## Components + +### Buttons +- **Shape:** the hand-rolled `.btn` is 40px tall with an 8px radius, `0 --spacing-5` padding, 14px/700 Nunito at +0.01em, an 18px leading glyph, and a 1px transparent border so filled and outlined variants share a box. The `--sm` step is 34px with a 6px radius, `0 --spacing-3`, 12px type, and a 16px glyph. Material button shape (4px, density -1) remains only where a Material control is still the host: icon-only buttons, menu triggers, dialog Cancel. +- **Primary:** `.btn--primary` — coral fill and coral border, `ink-on-primary` label, the accent glow at rest, hover to `primary-hover` with a 1px lift. *Amendment (this round):* the weight is 700 and the glow is `0 4px 12px -5px` at 34% coral, down from `0 6px 16px -4px` at 45%. A 14px label on a saturated coral fill loses too much of its stroke to a wide halo; the light surface survives, the type stops dissolving into it. Primary is now reserved for the one action on a page that has nothing else on it — the empty state and the error retry. +- **Outline:** `.btn--outline` — card surface, `border-strong` hairline, `ink` label; hover fills `parchment-subtle` and mixes 45% coral into the border. This is the card-level and repeat action (Generate, Generate all, Retry). +- **Quiet:** `.btn--quiet` — no ground at rest, a plain `border` hairline, `ink-secondary` label and a coral leading glyph; hover fills `parchment-subtle`, mixes 45% coral into the border and steps the label to `ink`. The third rung of the ladder, for the actions a visitor takes rarely (Add collection, Add bundle). **Frequency sets prominence, not position in the hierarchy of nouns:** a section's create action is not automatically its loudest, and on the Collections page the repeated Generate outranks both Adds. +- **Busy:** `parchment-subtle` ground, `border` hairline, `ink-secondary` label, no glow and no lift, with a 16px coral spinner ring (2px, 25% coral track) replacing the leading glyph and the label switching to the progressive form. +- **Disabled:** 0.55 opacity, no shadow, no transform. +- **Text:** `mat-button` in `ink-secondary`, hover to `ink`; used for Cancel and the close icon. +- **Hover / Focus:** colour steps via `--transition-fast` (150ms), lift and shadow via `--transition-normal`; focus is the global 2px sky outline at 2px offset. The lift is dropped under `prefers-reduced-motion`. +- **Footer convention:** right-aligned, Cancel then Primary, `--spacing-2` apart, above a `border` hairline. + +### Chips +- **Locale chip:** full pill, `parchment-muted` fill, transparent 1px border, `ink-secondary` text; code in mono 14px/600 with `3px --spacing-2 3px --spacing-3` padding. Card variant is smaller (12px mono, `1px --spacing-2`). +- **Base variant:** sky tint (16% fill, 35% border, text mixed 45% toward sky) with an uppercase BASE tag at 10px; in edit mode a 14px lock glyph leads the code and the remove control is absent. +- **Text chip (tags, protected terms):** same pill, `parchment-subtle` fill, `border` hairline, Nunito 14px/500 in `ink`. +- **Remove control:** 24px round ghost button in `ink-tertiary`, hover to `error` on a 12% error tint. +- **Interactive chip:** in create mode a locale chip is a button; hover tints non-base chips 10% sky; `aria-pressed` marks the base. + +### Cards / Containers +- **Corner Style:** 12px. +- **Background:** `parchment` (light) or 3% ink mixed into `parchment-subtle` (dark); hover mixes 3% coral into the light ground and 8% into the dark one. *Amendment (this round):* the dark card step is 3%, not 5% — at 5% the card and the page read as the same plane once the grain overlay sits on top. +- **Shadow Strategy:** `sm` at rest, `md` with a 2px lift on hover. +- **Border:** 1px `border`; hover mixes 40% coral into it. Read-only cards swap the icon tile to the amber tint. +- **Internal Padding:** `--spacing-5` (`--spacing-4` under 600px); footer separated by a `border-subtle` hairline holding the locale chips and a trailing arrow. The footer is pushed to the card's floor with `margin-top: auto` and the card carries a `--spacing-4` `row-gap`, so the hairlines and chip rows of a row of cards line up whatever sits above them. +- **Grid:** `repeat(auto-fill, minmax(min(100%, 340px), 1fr))` over `grid-auto-rows: minmax(180px, 1fr)`. A collection card is an identity, not a measurement; a longer name or a read-only note is no reason for one card to be a different object than the one beside it, so every row is the same height and the floor keeps a last row holding a single card in the same proportion as the full rows above it. + +### Inputs / Fields +- **Style:** label above (Label role), then a 40px well: 1px `border-strong`, 6px radius, `parchment` ground (`--dialog-well`), optional 18px leading glyph in `ink-tertiary`, `--spacing-2` gap, `0 --spacing-3` padding. Mono variant sets the value in Plex Mono 14px for paths. +- **Focus:** border to `secondary` plus the 22% sky glow, 150ms. +- **Error:** border to `error` with an 18% error glow; a 12px `error` hint with `role="alert"` replaces the normal hint. +- **Locked:** dashed border, transparent ground, `ink-secondary` value, leading lock glyph, and a hint explaining the lock. +- **Hint:** 12px `ink-secondary`; optional 14px leading glyph; file names inside it are inline ``. +- **Page filter field:** the Collections filter is a 260px well with a `border` hairline and an 8px radius, and it still focuses with a coral mix (55% border, 14% glow) instead of sky. Known drift, carried unrepaired; new wells use sky. + +### Chip editor +A wrapping well with the same stroke, radius, ground, and focus glow as a text input, `--spacing-2` padding and gap. Chips come first; the inline add row (18px plus glyph, borderless 30px input, optional 28px pill "Add" button that appears once text is typed) flexes to fill the remaining space at a 180px minimum. Empty editors show a dashed stroke. Tags and protected terms reuse it with text chips. + +### Labelled group +A `section` with an `h3` in Group-title role and an optional 12px `ink-secondary` hint stacked at 2px, then controls at `--spacing-3`. Groups stack at `--spacing-5`. This is how a form reads top to bottom without dividers. + +### Switch row +A `parchment-subtle` panel, 1px `border`, 8px radius, `--spacing-3 --spacing-4` padding: 30px amber lock tile, then a 14px/700 label over a 12px description, then a Material slide toggle. When on, the row mixes 6% amber into its ground and 35% into its border over 200ms. The tile is the lock's colour in every locked context (cards, dialogs). + +### Disclosure row +A 1px `border`, 8px-radius container with a full-width toggle: 18px leading glyph, 14px/600 title, a right-aligned 12px count summary ("2 tags · 0 terms" or "None yet"), and a chevron that rotates 180° over 200ms. The open panel is separated by a `border-subtle` hairline, padded `--spacing-2 --spacing-4 --spacing-4`, and fades in 4px over 200ms (disabled under reduced motion). + +### Bundle card +The compact member of the card family: same 12px radius, `sm`/`md` shadows, hairline border, 38px coral tile (`inventory_2`), Title-role name, and hairline footer as the collection card, at `--spacing-4` padding instead of `--spacing-5`. Under the name sits the output pattern in Mono-path, with the `{locale}` placeholder promoted to `ink` at 600 so the variable is the one thing that stands out. Below it, labelled meta rows: a 16px `ink-tertiary` glyph, a fixed 66px 12px `ink-secondary` label, then the value — collections as soft text chips (`parchment-subtle` ground, `border` hairline, Nunito 12px/500), the types path in mono `ink`. The footer pairs a glyphed locale count with the outline `--sm` Generate button. + +**The Connector Rule.** Hovering or focusing a bundle card *draws* the relationship rather than tinting around it. An absolutely positioned SVG spans the whole split at `z-index: 0`; cards sit at `1` and carry an opaque ground, so a line crossing the inner column of collection cards runs behind it and re-emerges in the gutter instead of scribbling over another card's content. From each consumed collection's right edge a 1.5px coral cubic bezier runs to a single port on the bundle card's left edge, so several inputs visibly converge on one output. Control handles are horizontal and capped at half the span — the inner column is barely 40px from the port, and an unbounded handle there sends the two control points past each other and kinks the curve back on itself. A solid coral dot marks the collection end and a hollow ring the bundle end, both standing 5px off the card edge that would otherwise slice them in half; the direction of the relationship stays legible even where the line itself runs behind a card. The line draws in over 340ms with `pathLength="1"` normalising every curve to one dash length regardless of distance, and under `prefers-reduced-motion` it fades instead. + +The bundle takes the card hover treatment (lift, `md`, 40% coral border, hover ground, tile to 20%/45% coral) and its own collection chips flip to the coral voice (8% coral ground, 45% coral border, `primary-text`). Each consumed collection takes a *quieter* `.linked` state than before — hover ground and a 55% coral border, **and no lift**: a card that moves drags the end of its own connector line with it, and the line is now what states the relation. Unrelated collections are left alone; the 0.5 dim is gone, because a drawn line does not need everything it excludes to be switched off to be read. Geometry is re-measured on the next frame after any change to the hover, the cards, or a bundle's run state, and on captured scroll and resize — the bundles column is sticky and scrolls independently of the page. + +Lines are only drawn while the columns sit side by side. Once the layout stacks under 1100px the bundle is below its collections with no gutter to route through, and the `.linked` tint carries the relation on its own. + +### Result strip +One 6px-radius strip pinned under the card body at `--spacing-4`, 12px/1.45, always the same shape — glyph, sentence with `·` separators, trailing controls — and only its tint changes. The sentence is a wrapping flex row of clauses, not an inline run: Angular strips the whitespace between clause elements, so inline layout gives the row no break opportunity and it overflows its controls instead of wrapping. Glyph and controls align to the first line (`flex-start` plus a 1px optical nudge); only the copy grows downward. +- **Running:** `parchment-subtle` ground, `border` hairline, `ink-secondary` copy with mono file names in `ink`, and a 2px determinate bar flush to the bottom edge (18% coral track, solid coral fill, width animated over `--transition-slow`). +- **Success:** 10% success fill, 30% success border, copy at 55% success mixed into `ink` (70% in dark). The clean run reads `Generated · n files · n keys per locale` and stops there — the tint and the check glyph already say there were no warnings, and a clause announcing the absence was the widest thing in the strip. +- **Warning:** the success strip re-tinted — 9% amber fill, 26% amber border, copy in `warning-text`, and a trailing `· n warnings` clause. A warning is still a completed run, so it keeps the success anatomy. Because the modifier rides on the success class, any themed override of the success ink must exclude it (`.result--ok:not(.result--warn)`) or a warning renders in success green. +- **Failure:** 10% error fill, 30% error border, copy at 55% error mixed into `ink`, and an outline `--sm` **Retry** trailing the message. + +**The Travelling-Separator Rule.** A `·` belongs to the clause that follows it, emitted as that clause's `::before`, with the clause itself unbreakable. A separator can then only ever lead a wrapped line, never dangle at the end of the one above it. Prose inside a strip (a failure message) opts out and breaks normally. + +A completed strip carries a **Details** disclosure — a bare `currentColor` toggle with a rotating chevron — and a dismiss `✕`, together in one trailing cluster pinned to the strip's first line. Details is the last labelled affordance; the dismiss sits outermost at 0.65 opacity, because it is the strip's own chrome and belongs against the edge it removes. Keeping them in one cluster is what stops them drifting apart when the copy wraps. The disclosure opens a panel separated by a 20% `currentColor` hairline and revealed with the standard 4px fade. Files written are a mono path against a tabular count; warnings are `warning-text` list items clamped to three lines at 62ch with the full text on `title`, because a generator warning is a paragraph and the card is a summary surface. + +### Segmented control +The house form for 2–4 exclusive options, and the approved alternative to Material's button toggles. A hairline `border-strong` well on the dialog's field ground with 2px padding and a 2px gap, holding borderless 4px-radius pills at `5px --spacing-3`, 14px/500 in `ink-secondary` with an optional 16px glyph. The selected pill fills `parchment-muted` and steps to `ink` at 600. The well takes the same sky focus treatment as a text input (`secondary` border plus the 22% glow) on `:focus-within`; individual segments show the 2px sky outline *inset* so it never overlaps the well's stroke. A `--mono` flavour sets the labels in Plex Mono 12px/600 for format and code choices, and the disabled well drops to 0.6 opacity. + +### Master-detail dialog +The wide member of the dialog family (see Layout for the grid and its fallbacks). It shares the collection dialog's panel recipe, header, wells, hints, and footer exactly; what is new is the three columns. +- **Rail:** `parchment-subtle` with a right hairline, 2px between items. An item is a 6px-radius row at `--spacing-2 10px`, 14px/600 `ink` with an 18px `ink-tertiary` glyph, an ellipsised label, and an optional Mono-path 11px sub-line (`ink-tertiary` when it has no value yet, so the row height never changes). Right-aligned trailing slot carries either a 12px count or a 6px error dot. Hover fills 60% of the active ground; the active item lifts to the panel surface with `sm` shadow and its glyph turns coral. Child items indent to `--spacing-6` and step to 700 when active; groups are separated by a 1px `border` rule. +- **Pane:** the form column. Section head is an 18px/700 title over a 12px hint, with an optional right-aligned text action. Fields use the existing label-above wells, in `1fr 1fr` and `3fr 2fr` rows; sub-fields that depend on a switch hang off it indented 14px behind a left `border` hairline. Switch rows here use the coral tile (12% coral fill, coral glyph) rather than the amber lock, because they gate output rather than mark a lock, and the on state tints the row 5% coral with a 35% coral border. +- **Preview:** `parchment-subtle` with a left hairline and `--spacing-5` blocks, each a 14px/700 heading with a 16px `ink-tertiary` glyph over its content: a mono file tree (`ink-secondary` directories, `ink` files, 14px glyphs) whose new entries carry a 10px coral pill in `primary-text` on a 12% coral tint; a dry-run definition list with `ink-secondary` terms against 600-weight `ink` values (`ink-tertiary` while the numbers are still a skeleton); and one example key in a bordered mono well on the field ground. The whole preview drops to 0.6 opacity while stale. +- **Footer:** the standard hairline, Cancel, Primary — plus a leading validity line whose 14px glyph is `success` only when the form is genuinely valid and `ink-tertiary` while it is not. The affirmative glyph is earned, never decorative. + +### Dialog anatomy +Panel (see Layout for widths) with zero Material padding, a 3px coral top rule, and `lg` shadow. Header: 38px coral tile with a 19px glyph, Headline title, one-line Body-small subtitle in `ink-secondary`, absolutely positioned close button at `--spacing-3`, hairline below. Body: the scrolling column of labelled groups. Footer: hairline above, right-aligned Cancel + Primary. The translation editor shares the top rule, title, subtitle, close, and hairlines; the collection form adds the header tile. + +## Do's and Don'ts + +### Do: +- **Do** open every dialog with the 3px coral top rule, zero container padding, `lg` shadow, and the header / hairline / body / hairline / footer column. +- **Do** put labels above controls at 14px/600 and hints below at 12px `ink-secondary`. +- **Do** set locale codes, paths, and file names in IBM Plex Mono; inline them in prose as `` at 0.95em in `ink` with no background. +- **Do** use sky blue for focus (2px outline, or border plus 22% glow on wells) and for the base-locale tint; use coral for the primary action and identity. +- **Do** mark locked and read-only states with the amber lock tile (14% amber fill, `warning-text` ink) so they are unmistakable. +- **Do** reuse the locale pill for any locale, the text chip for any word-like tag, and the chip editor for any editable list. +- **Do** place surfaces one neutral step off their parent and separate them with 1px hairlines before reaching for a shadow. +- **Do** build page- and section-level actions from the hand-rolled `.btn` recipes (`--primary` filled coral, `--outline` hairline, `--sm` for card-level repeats); Material's palette does not resolve to `--color-primary`. +- **Do** split long mono identifiers after `.`, `_`, `-`, and `/` and emit `` between the parts when the component owns the string. +- **Do** keep an affirmative footer glyph tied to real form validity: `success` when valid, `ink-tertiary` while not. +- **Do** state a relationship by drawing it — a coral connector from each input's edge into one port on the output — and leave everything it excludes alone. +- **Do** honour `prefers-reduced-motion` by disabling reveal animations and hover lifts. + +### Don't: +- **Don't** set type in `primary`, `warning`, or `ink-tertiary`; use the text-weight tokens. +- **Don't** use Material outline form fields inside the dialog family; the label-above well is the field. +- **Don't** use uppercase or letterspaced text outside the BASE tag inside a chip. +- **Don't** use Grechen Fuemen anywhere but the wordmark. +- **Don't** add hard offset shadows, gradients on controls, or a second accent rule on a dialog. +- **Don't** use Material button toggles for an exclusive choice inside a dialog; the segmented control is the form. +- **Don't** let an in-card result strip grow without a ceiling: clamp prose warnings and put the full text behind `title`, not on the page. +- **Don't** hard-code a status hue; read the `status-*` tokens. diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.spec.ts index 79df7709..fc452dd5 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.spec.ts @@ -1,15 +1,17 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import type { ComponentFixture } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NotificationService } from '../../../../shared/notification'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { FolderPicker } from './folder-picker'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import type { FolderNodeDto } from '@simoncodes-ca/data-transfer'; -import { BrowserStore } from '../../../store/browser.store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { NotificationService } from '../../../../shared/notification'; +import { BrowserStore } from '../../../store/browser.store'; +import { FolderPicker } from './folder-picker'; describe('FolderPicker', () => { let component: FolderPicker; let fixture: ComponentFixture; + let spectator: Spectator; const mockRootFolders: FolderNodeDto[] = [ { @@ -40,29 +42,26 @@ describe('FolderPicker', () => { selectedCollection: vi.fn(() => 'test-collection'), }; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FolderPicker, BrowserAnimationsModule, getTranslocoTestingModule()], - providers: [ - { - provide: NotificationService, - useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }, - }, - { - provide: BrowserStore, - useValue: mockStore, - }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(FolderPicker); - component = fixture.componentInstance; + const createComponent = createComponentFactory({ + component: FolderPicker, + imports: [BrowserAnimationsModule, getTranslocoTestingModule()], + providers: [ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: BrowserStore, useValue: mockStore }, + ], + detectChanges: true, + }); + + beforeEach(() => { + spectator = createComponent({ detectChanges: false }); + fixture = spectator.fixture; + component = spectator.component; // Set required inputs fixture.componentRef.setInput('currentPath', ''); fixture.componentRef.setInput('rootFolders', mockRootFolders); - fixture.detectChanges(); + spectator.detectChanges(); }); describe('Component Initialization', () => { @@ -213,13 +212,12 @@ describe('FolderPicker', () => { describe('initiallyExpanded', () => { it('should auto-expand tree and set selectedPath when initiallyExpanded is true with currentPath', () => { // Create a fresh component with initiallyExpanded - const expandedFixture = TestBed.createComponent(FolderPicker); - expandedFixture.componentRef.setInput('currentPath', 'common.buttons'); - expandedFixture.componentRef.setInput('rootFolders', mockRootFolders); - expandedFixture.componentRef.setInput('initiallyExpanded', true); - expandedFixture.detectChanges(); + const expanded = createComponent({ + props: { currentPath: 'common.buttons', rootFolders: mockRootFolders, initiallyExpanded: true }, + detectChanges: true, + }); - const expandedComponent = expandedFixture.componentInstance; + const expandedComponent = expanded.component; expect(expandedComponent.isExpanded()).toBe(true); expect(expandedComponent.selectedPath()).toBe('common.buttons'); expect(expandedComponent.expandedPaths().has('common')).toBe(true); @@ -227,13 +225,12 @@ describe('FolderPicker', () => { }); it('should auto-expand tree without setting selectedPath when currentPath is empty', () => { - const expandedFixture = TestBed.createComponent(FolderPicker); - expandedFixture.componentRef.setInput('currentPath', ''); - expandedFixture.componentRef.setInput('rootFolders', mockRootFolders); - expandedFixture.componentRef.setInput('initiallyExpanded', true); - expandedFixture.detectChanges(); + const expanded = createComponent({ + props: { currentPath: '', rootFolders: mockRootFolders, initiallyExpanded: true }, + detectChanges: true, + }); - const expandedComponent = expandedFixture.componentInstance; + const expandedComponent = expanded.component; expect(expandedComponent.isExpanded()).toBe(true); expect(expandedComponent.selectedPath()).toBeNull(); expect(expandedComponent.expandedPaths().size).toBe(0); @@ -245,13 +242,12 @@ describe('FolderPicker', () => { }); it('should keep the tree expanded after folder selection when initiallyExpanded is true', () => { - const expandedFixture = TestBed.createComponent(FolderPicker); - expandedFixture.componentRef.setInput('currentPath', 'common'); - expandedFixture.componentRef.setInput('rootFolders', mockRootFolders); - expandedFixture.componentRef.setInput('initiallyExpanded', true); - expandedFixture.detectChanges(); + const expanded = createComponent({ + props: { currentPath: 'common', rootFolders: mockRootFolders, initiallyExpanded: true }, + detectChanges: true, + }); - const expandedComponent = expandedFixture.componentInstance; + const expandedComponent = expanded.component; const emitSpy = vi.fn(); expandedComponent.folderConfirmed.subscribe(emitSpy); @@ -263,13 +259,12 @@ describe('FolderPicker', () => { }); it('should not collapse the tree when toggleExpanded is called and initiallyExpanded is true', () => { - const expandedFixture = TestBed.createComponent(FolderPicker); - expandedFixture.componentRef.setInput('currentPath', 'common'); - expandedFixture.componentRef.setInput('rootFolders', mockRootFolders); - expandedFixture.componentRef.setInput('initiallyExpanded', true); - expandedFixture.detectChanges(); + const expanded = createComponent({ + props: { currentPath: 'common', rootFolders: mockRootFolders, initiallyExpanded: true }, + detectChanges: true, + }); - const expandedComponent = expandedFixture.componentInstance; + const expandedComponent = expanded.component; expandedComponent.toggleExpanded(); expect(expandedComponent.isExpanded()).toBe(true); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index 3a24bf32..ae098615 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -1,24 +1,26 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import type { ComponentFixture } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; +import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { NotificationService } from '../../../shared/notification'; +import { BrowserApiService } from '../../services/browser-api.service'; import { TranslationEditorDialog, type TranslationEditorDialogData, type TranslationEditorResult, } from './translation-editor-dialog'; -import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; -import { of, throwError } from 'rxjs'; -import { BrowserApiService } from '../../services/browser-api.service'; -import { NotificationService } from '../../../shared/notification'; -import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; -import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; describe('TranslationEditorDialog', () => { let component: TranslationEditorDialog; let fixture: ComponentFixture; + let spectator: Spectator; let dialogRef: { close: Mock; afterOpened: Mock }; let mockDialog: { open: Mock }; let mockBrowserApi: { @@ -37,33 +39,31 @@ describe('TranslationEditorDialog', () => { baseLocale: 'en', }); - const setupTestBed = async (data: TranslationEditorDialogData) => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [TranslationEditorDialog, BrowserAnimationsModule, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: MatDialogRef, useValue: dialogRef }, - { provide: MatDialog, useValue: mockDialog }, - { provide: BrowserApiService, useValue: mockBrowserApi }, - { provide: NotificationService, useValue: mockNotifications }, - { - provide: MAT_DIALOG_DATA, - useValue: data, - }, - ], - }); - - // Override the provider after configure to ensure our mock takes precedence - // over the one provided by MatDialogModule - TestBed.overrideProvider(MatDialog, { useValue: mockDialog }); - - await TestBed.compileComponents(); + const dialogData = createMockData('create'); + + const createDialog = createComponentFactory({ + component: TranslationEditorDialog, + imports: [BrowserAnimationsModule, getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting()], + componentProviders: [ + { provide: MatDialogRef, useFactory: () => dialogRef }, + { provide: MatDialog, useFactory: () => mockDialog }, + { provide: BrowserApiService, useFactory: () => mockBrowserApi }, + { provide: NotificationService, useFactory: () => mockNotifications }, + { provide: MAT_DIALOG_DATA, useValue: dialogData }, + ], + detectChanges: false, + }); - fixture = TestBed.createComponent(TranslationEditorDialog); - component = fixture.componentInstance; - fixture.detectChanges(); + const renderDialog = (data: TranslationEditorDialogData): void => { + dialogData.resource = undefined; + dialogData.folderPath = undefined; + dialogData.readOnly = undefined; + Object.assign(dialogData, data); + spectator = createDialog(); + fixture = spectator.fixture; + component = spectator.component; + spectator.detectChanges(); }; beforeEach(async () => { @@ -89,7 +89,7 @@ describe('TranslationEditorDialog', () => { mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(createMockData('create')); + renderDialog(createMockData('create')); }); describe('Component Initialization', () => { @@ -108,7 +108,7 @@ describe('TranslationEditorDialog', () => { translations: { en: 'Test Value' }, status: {}, }); - await setupTestBed(editData); + renderDialog(editData); expect(component.dialogTitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITTITLE); expect(component.dialogSubtitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITSUBTITLE); @@ -283,7 +283,7 @@ describe('TranslationEditorDialog', () => { translations: { en: 'Existing Value' }, status: {}, }; - await setupTestBed(createMockData('edit', mockResource)); + renderDialog(createMockData('edit', mockResource)); component.form.controls.key.setValue('apps.common.ok'); @@ -392,7 +392,7 @@ describe('TranslationEditorDialog', () => { searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(dataWithoutFolder); + renderDialog(dataWithoutFolder); component.form.controls.key.setValue('test_key'); component.form.controls.baseValue.setValue('Test Value'); @@ -546,7 +546,7 @@ describe('TranslationEditorDialog', () => { searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); const translationsArray = component.form.controls.translations; const frControl = translationsArray.controls.find((c) => c.value.locale === 'fr'); @@ -574,7 +574,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - await setupTestBed(editData); + renderDialog(editData); expect(component.form.controls.key.value).toBe('existing_key'); expect(component.form.controls.baseValue.value).toBe('Existing Value'); @@ -589,7 +589,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - await setupTestBed(editData); + renderDialog(editData); expect(component.form.controls.baseValue.value).toBe(''); }); @@ -602,7 +602,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - await setupTestBed(editData); + renderDialog(editData); expect(component.form.controls.comment.value).toBe(''); }); @@ -615,7 +615,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - await setupTestBed(editData); + renderDialog(editData); expect(component.saveButtonLabel()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.UPDATEBUTTON); }); @@ -639,7 +639,7 @@ describe('TranslationEditorDialog', () => { }; const editData = createMockData('edit', mockResource); - await setupTestBed(editData); + renderDialog(editData); const translationsArray = component.form.controls.translations; const frControl = translationsArray.controls.find((c) => c.value.locale === 'fr'); @@ -672,7 +672,7 @@ describe('TranslationEditorDialog', () => { searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(createMockData('create')); + renderDialog(createMockData('create')); }); it('should save directly when comment is present', async () => { @@ -902,7 +902,7 @@ describe('TranslationEditorDialog', () => { open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); component.form.controls.comment.setValue('Updated comment'); @@ -940,7 +940,7 @@ describe('TranslationEditorDialog', () => { open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); component.form.controls.comment.setValue('Updated comment'); @@ -980,7 +980,7 @@ describe('TranslationEditorDialog', () => { }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); component.form.controls.comment.setValue('Updated comment'); @@ -1024,7 +1024,7 @@ describe('TranslationEditorDialog', () => { }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); const translationsArray = component.form.controls.translations; const frControl = translationsArray.controls.find((c) => c.value.locale === 'fr'); @@ -1077,7 +1077,7 @@ describe('TranslationEditorDialog', () => { }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); await component.onSubmit(); @@ -1112,7 +1112,7 @@ describe('TranslationEditorDialog', () => { }), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await setupTestBed(editData); + renderDialog(editData); component.form.controls.baseValue.setValue('Updated Value'); diff --git a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts index 73f3a261..abd27d6e 100644 --- a/apps/tracker/src/app/browser/services/browser-api.service.spec.ts +++ b/apps/tracker/src/app/browser/services/browser-api.service.spec.ts @@ -1,30 +1,33 @@ -import { TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { firstValueFrom } from 'rxjs'; -import { BrowserApiService } from './browser-api.service'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { createServiceFactory, type SpectatorService } from '@ngneat/spectator/vitest'; import type { - ResourceTreeDto, - SearchResultsDto, CreateResourceDto, CreateResourceResponseDto, + DeleteResourceResponseDto, + ResourceTreeDto, + SearchResultsDto, UpdateResourceDto, UpdateResourceResponseDto, - DeleteResourceResponseDto, } from '@simoncodes-ca/data-transfer'; +import { firstValueFrom } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserApiService } from './browser-api.service'; describe('BrowserApiService', () => { let service: BrowserApiService; + let spectator: SpectatorService; let httpMock: HttpTestingController; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [BrowserApiService, provideHttpClient(), provideHttpClientTesting()], - }); + const createService = createServiceFactory({ + service: BrowserApiService, + providers: [provideHttpClient(), provideHttpClientTesting()], + }); - service = TestBed.inject(BrowserApiService); - httpMock = TestBed.inject(HttpTestingController); + beforeEach(() => { + spectator = createService(); + service = spectator.service; + httpMock = spectator.inject(HttpTestingController); }); afterEach(() => { diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts index ca35fa7a..8eed4f45 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-node/folder-node.spec.ts @@ -1,14 +1,16 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { FolderNode } from './folder-node'; +import type { ComponentFixture } from '@angular/core/testing'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import type { FolderNodeDto } from '@simoncodes-ca/data-transfer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { FolderNode } from './folder-node'; describe('FolderNode', () => { let component: FolderNode; let fixture: ComponentFixture; + let spectator: Spectator; const folderWithChildren: FolderNodeDto = { name: 'common', @@ -21,14 +23,17 @@ describe('FolderNode', () => { }, }; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FolderNode, getTranslocoTestingModule()], - providers: [provideHttpClient(), provideHttpClientTesting()], - }).compileComponents(); + const createComponent = createComponentFactory({ + component: FolderNode, + imports: [getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting()], + detectChanges: false, + }); - fixture = TestBed.createComponent(FolderNode); - component = fixture.componentInstance; + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); it('should create', () => { diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.spec.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.spec.ts index 4af2a861..cc15cd0a 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.spec.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/folder-tree.spec.ts @@ -1,22 +1,22 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { FolderTree } from './folder-tree'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import type { ComponentFixture } from '@angular/core/testing'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { FolderTree } from './folder-tree'; describe('FolderTree', () => { let component: FolderTree; let fixture: ComponentFixture; + let spectator: Spectator; let httpMock: HttpTestingController; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FolderTree, getTranslocoTestingModule()], - providers: [provideHttpClient(), provideHttpClientTesting()], - }).compileComponents(); - - httpMock = TestBed.inject(HttpTestingController); + const createSpectator = createComponentFactory({ + component: FolderTree, + imports: [getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting()], + detectChanges: false, }); afterEach(() => { @@ -24,10 +24,12 @@ describe('FolderTree', () => { }); function createComponent(detectChanges = false): void { - fixture = TestBed.createComponent(FolderTree); - component = fixture.componentInstance; + spectator = createSpectator(); + fixture = spectator.fixture; + component = spectator.component; + httpMock = spectator.inject(HttpTestingController); if (detectChanges) { - fixture.detectChanges(); + spectator.detectComponentChanges(); } } diff --git a/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.spec.ts b/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.spec.ts index d78883be..0024bfe7 100644 --- a/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.spec.ts +++ b/apps/tracker/src/app/browser/sidebar/folder-tree/inline-folder-input/inline-folder-input.spec.ts @@ -1,22 +1,25 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { InlineFolderInput } from './inline-folder-input'; +import type { ComponentFixture } from '@angular/core/testing'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { InlineFolderInput } from './inline-folder-input'; describe('InlineFolderInput', () => { let component: InlineFolderInput; let fixture: ComponentFixture; + let spectator: Spectator; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InlineFolderInput, getTranslocoTestingModule()], - providers: [provideNoopAnimations()], - }).compileComponents(); + const createComponent = createComponentFactory({ + component: InlineFolderInput, + imports: [getTranslocoTestingModule()], + providers: [provideNoopAnimations()], + }); - fixture = TestBed.createComponent(InlineFolderInput); - component = fixture.componentInstance; - fixture.detectChanges(); + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); it('should create', () => { diff --git a/apps/tracker/src/app/browser/store/browser.store.spec.ts b/apps/tracker/src/app/browser/store/browser.store.spec.ts index d952152c..9d331317 100644 --- a/apps/tracker/src/app/browser/store/browser.store.spec.ts +++ b/apps/tracker/src/app/browser/store/browser.store.spec.ts @@ -1,11 +1,11 @@ -import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { of, throwError } from 'rxjs'; -import { BrowserStore } from './browser.store'; -import { BrowserApiService } from '../services/browser-api.service'; -import type { ResourceTreeDto, CacheStatusDto } from '@simoncodes-ca/data-transfer'; +import { createServiceFactory, type SpectatorService } from '@ngneat/spectator/vitest'; +import type { CacheStatusDto, ResourceTreeDto } from '@simoncodes-ca/data-transfer'; +import { NEVER, of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; +import { BrowserApiService } from '../services/browser-api.service'; +import { BrowserStore } from './browser.store'; /** * Helper to wait for async signal updates from rxMethod. @@ -17,6 +17,7 @@ const waitForSignals = () => new Promise((resolve) => setTimeout(resolve, describe('BrowserStore', () => { let store: InstanceType; + let spectator: SpectatorService; let apiService: BrowserApiService; const mockTreeRoot: ResourceTreeDto = { @@ -81,14 +82,16 @@ describe('BrowserStore', () => { }, }; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, getTranslocoTestingModule()], - providers: [BrowserStore, BrowserApiService], - }); + const createStore = createServiceFactory({ + service: BrowserStore, + imports: [HttpClientTestingModule, getTranslocoTestingModule()], + providers: [BrowserApiService], + }); - store = TestBed.inject(BrowserStore); - apiService = TestBed.inject(BrowserApiService); + beforeEach(() => { + spectator = createStore(); + store = spectator.service; + apiService = spectator.inject(BrowserApiService); }); describe('Initialization', () => { @@ -108,6 +111,18 @@ describe('BrowserStore', () => { }); describe('Collection Selection', () => { + it('should show indexing state while the initial cache status request is pending', () => { + vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(NEVER); + + store.setSelectedCollection({ + collectionName: 'app-translations', + locales: ['en', 'es'], + }); + + expect(store.cacheStatus()).toBe('not-started'); + expect(store.isCacheIndexing()).toBe(true); + }); + it('should set selected collection and load root folders', async () => { vi.spyOn(apiService, 'getCacheStatus').mockReturnValue(of(mockCacheReady)); vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); @@ -742,7 +757,7 @@ describe('BrowserStore', () => { densityMode: 'full' as const, selectedLocales: ['es', 'fr'], showNestedResources: true, - compactLocale: null, + compactLocale: undefined, compactLocaleManuallyChanged: false, sortField: 'key' as const, sortDirection: 'asc' as const, @@ -763,29 +778,52 @@ describe('BrowserStore', () => { expect(loaded).toEqual(prefs); }); - it('should automatically reduce selectedLocales to first when switching to compact', async () => { + it('should start compact on the base locale regardless of the full-density filter', async () => { vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); - // Setup available locales and initial selection store.setSelectedCollection({ collectionName: 'c1', locales: ['en', 'es', 'fr'], + baseLocale: 'en', }); await waitForSignals(); - // multi-select + store.setDensityMode('full'); store.setSelectedLocales(['es', 'fr']); expect(store.selectedLocales()).toEqual(['es', 'fr']); store.setDensityMode('compact'); - // Should reduce to the first selected locale - expect(store.selectedLocales().length).toBe(1); - expect(store.selectedLocales()[0]).toBe('es'); + // Compact shows one locale, and it is the base until the user picks another + expect(store.selectedLocales()).toEqual(['en']); + expect(store.compactDisplayLocale()).toBe('en'); expect(store.densityMode()).toBe('compact'); }); + it('should remember the compact locale as soon as it is picked', async () => { + vi.spyOn(apiService, 'getResourceTree').mockReturnValue(of(mockTreeRoot)); + + store.setSelectedCollection({ + collectionName: 'c1', + locales: ['en', 'es', 'fr'], + baseLocale: 'en', + }); + + await waitForSignals(); + + store.setDensityMode('compact'); + store.setSelectedLocales(['fr']); + + expect(store.compactDisplayLocale()).toBe('fr'); + expect(store.compactLocale()).toBe('fr'); + + // Round-trips through full density and back + store.setDensityMode('full'); + store.setDensityMode('compact'); + expect(store.compactDisplayLocale()).toBe('fr'); + }); + it('should load preferences when collection is selected', async () => { const coll = 'load-collection'; const prefs = { @@ -841,7 +879,7 @@ describe('BrowserStore', () => { densityMode: 'compact' as const, selectedLocales: [], showNestedResources: true, - compactLocale: null, + compactLocale: undefined, compactLocaleManuallyChanged: false, sortField: 'key' as const, sortDirection: 'asc' as const, @@ -1001,6 +1039,15 @@ describe('BrowserStore', () => { store.setSelectedLocales(['en', 'es']); expect(store.localeFilterText()).toBe('2 locales'); }); + + it('should name the displayed locale in compact mode, never "All locales"', () => { + store.setBaseLocale('en'); + store.setDensityMode('compact'); + expect(store.localeFilterText()).toBe('en'); + + store.setSelectedLocales(['fr']); + expect(store.localeFilterText()).toBe('fr'); + }); }); describe('Computed: filteredLocales', () => { diff --git a/apps/tracker/src/app/browser/store/browser.store.ts b/apps/tracker/src/app/browser/store/browser.store.ts index 37cdb5fc..c03e0c17 100644 --- a/apps/tracker/src/app/browser/store/browser.store.ts +++ b/apps/tracker/src/app/browser/store/browser.store.ts @@ -38,7 +38,7 @@ interface RootState { error: string | null; currentFolderPath: string; densityMode: DensityMode; - compactLocale: string | null; + compactLocale: string | undefined; compactLocaleManuallyChanged: boolean; nonCompactSelectedLocales: string[]; } @@ -52,7 +52,7 @@ const initialRootState: RootState = { error: null, currentFolderPath: '', densityMode: 'compact', - compactLocale: null, + compactLocale: undefined, compactLocaleManuallyChanged: false, nonCompactSelectedLocales: [], }; @@ -146,7 +146,7 @@ export const BrowserStore = signalStore( isReadOnly: params.readOnly ?? false, selectedLocales: loaded?.selectedLocales || [], baseLocale, - cacheStatus: null, + cacheStatus: 'not-started', cacheError: null, collectionStats: null, currentFolderPath: '', @@ -154,7 +154,7 @@ export const BrowserStore = signalStore( preFilterExpandedFolders: null, isRootExpanded: true, showNestedResources: loaded?.showNestedResources ?? true, - compactLocale: loaded?.compactLocale ?? null, + compactLocale: loaded?.compactLocale ?? undefined, compactLocaleManuallyChanged: loaded?.compactLocaleManuallyChanged ?? false, nonCompactSelectedLocales: [], sortField: loaded?.sortField ?? 'key', @@ -169,7 +169,7 @@ export const BrowserStore = signalStore( if (loaded?.densityMode) { const mode: DensityMode = (loaded.densityMode as string) === 'medium' ? 'compact' : loaded.densityMode; if (mode === 'compact') { - const savedCompactLocale = loaded?.compactLocale ?? null; + const savedCompactLocale = loaded?.compactLocale ?? undefined; const savedSelectedLocales = loaded?.selectedLocales ?? []; const newSelected = resolveCompactLocale({ savedCompactLocale, diff --git a/apps/tracker/src/app/browser/store/density-mode.utils.spec.ts b/apps/tracker/src/app/browser/store/density-mode.utils.spec.ts index 4a8a581f..05863d52 100644 --- a/apps/tracker/src/app/browser/store/density-mode.utils.spec.ts +++ b/apps/tracker/src/app/browser/store/density-mode.utils.spec.ts @@ -6,7 +6,7 @@ const baseCtx: DensityModeContext = { currentSelectedLocales: ['en', 'es', 'de'], availableLocales: ['en', 'es', 'de', 'fr'], baseLocale: 'en', - compactLocale: null, + compactLocale: undefined, compactLocaleManuallyChanged: false, nonCompactSelectedLocales: [], }; @@ -32,17 +32,19 @@ describe('computeDensityModeTransition', () => { it('ignores saved compact locale if not in availableLocales', () => { const ctx: DensityModeContext = { ...baseCtx, compactLocale: 'jp' }; const result = computeDensityModeTransition('compact', ctx); - expect(result.selectedLocales).toEqual(['en']); // first from currentSelectedLocales + expect(result.selectedLocales).toEqual(['en']); // base locale }); - it('falls back to first of current selection when no saved compact locale', () => { - const result = computeDensityModeTransition('compact', baseCtx); + it('starts on the base locale, not the full-density filter, when nothing is saved', () => { + // The full-density filter is a set to compare, not a choice of what to read alone. + const ctx: DensityModeContext = { ...baseCtx, currentSelectedLocales: ['de', 'es'] }; + const result = computeDensityModeTransition('compact', ctx); expect(result.selectedLocales).toEqual(['en']); expect(result.compactLocale).toBe('en'); }); it('falls back to base locale when no selection and no compact locale', () => { - const ctx: DensityModeContext = { ...baseCtx, currentSelectedLocales: [], compactLocale: null }; + const ctx: DensityModeContext = { ...baseCtx, currentSelectedLocales: [], compactLocale: undefined }; const result = computeDensityModeTransition('compact', ctx); expect(result.selectedLocales).toEqual(['en']); }); @@ -51,7 +53,7 @@ describe('computeDensityModeTransition', () => { const ctx: DensityModeContext = { ...baseCtx, currentSelectedLocales: [], - compactLocale: null, + compactLocale: undefined, baseLocale: '', availableLocales: ['fr', 'de'], }; @@ -127,7 +129,7 @@ describe('resolveCompactLocale', () => { it('falls back to base locale when no saved compact locale and no selection', () => { const result = resolveCompactLocale({ - savedCompactLocale: null, + savedCompactLocale: undefined, currentSelectedLocales: [], availableLocales, baseLocale: 'en', @@ -137,7 +139,7 @@ describe('resolveCompactLocale', () => { it('falls back to first available locale when no base locale and no selection', () => { const result = resolveCompactLocale({ - savedCompactLocale: null, + savedCompactLocale: undefined, currentSelectedLocales: [], availableLocales: ['de', 'fr'], baseLocale: '', @@ -145,19 +147,29 @@ describe('resolveCompactLocale', () => { expect(result).toEqual(['de']); }); - it('returns first of current selection when multi-selected', () => { + it('falls back to base locale when the saved selection is not a single locale', () => { const result = resolveCompactLocale({ - savedCompactLocale: null, + savedCompactLocale: undefined, currentSelectedLocales: ['de', 'en'], availableLocales, baseLocale: 'en', }); - expect(result).toEqual(['de']); + expect(result).toEqual(['en']); + }); + + it('falls back to base locale when the saved single selection is no longer available', () => { + const result = resolveCompactLocale({ + savedCompactLocale: undefined, + currentSelectedLocales: ['jp'], + availableLocales, + baseLocale: 'en', + }); + expect(result).toEqual(['en']); }); it('returns current single selection unchanged', () => { const result = resolveCompactLocale({ - savedCompactLocale: null, + savedCompactLocale: undefined, currentSelectedLocales: ['es'], availableLocales, baseLocale: 'en', diff --git a/apps/tracker/src/app/browser/store/density-mode.utils.ts b/apps/tracker/src/app/browser/store/density-mode.utils.ts index 4ce88013..99e0929d 100644 --- a/apps/tracker/src/app/browser/store/density-mode.utils.ts +++ b/apps/tracker/src/app/browser/store/density-mode.utils.ts @@ -2,7 +2,7 @@ import type { DensityMode } from '../types/density-mode'; export interface DensityModeTransition { readonly selectedLocales: string[]; - readonly compactLocale: string | null; + readonly compactLocale: string | undefined; readonly compactLocaleManuallyChanged: boolean; readonly nonCompactSelectedLocales: string[]; } @@ -12,7 +12,7 @@ export interface DensityModeContext { readonly currentSelectedLocales: string[]; readonly availableLocales: string[]; readonly baseLocale: string; - readonly compactLocale: string | null; + readonly compactLocale: string | undefined; readonly compactLocaleManuallyChanged: boolean; readonly nonCompactSelectedLocales: string[]; } @@ -41,16 +41,13 @@ export function computeDensityModeTransition( nonCompactSelectedLocales = context.currentSelectedLocales; compactLocaleManuallyChanged = false; + // Compact starts on the locale it was last showing, and otherwise on the + // base locale. The full-density filter is deliberately not consulted: it is a + // set of locales to compare, not a statement about which one to read alone. if (compactLocale && context.availableLocales.includes(compactLocale)) { selectedLocales = [compactLocale]; - } else if (context.currentSelectedLocales.length > 0) { - selectedLocales = [context.currentSelectedLocales[0]]; - compactLocale = context.currentSelectedLocales[0]; } else { - const fallback = - context.baseLocale && context.availableLocales.includes(context.baseLocale) - ? context.baseLocale - : (context.availableLocales[0] ?? null); + const fallback = defaultCompactLocale(context.availableLocales, context.baseLocale); if (fallback) { selectedLocales = [fallback]; @@ -72,12 +69,24 @@ export function computeDensityModeTransition( return { selectedLocales, compactLocale, compactLocaleManuallyChanged, nonCompactSelectedLocales }; } +/** + * The base locale when the collection has it, otherwise its first locale, or + * undefined when there is nothing to show at all. + */ +function defaultCompactLocale(availableLocales: string[], baseLocale: string): string | undefined { + if (baseLocale && availableLocales.includes(baseLocale)) return baseLocale; + return availableLocales.at(0); +} + /** * Resolves a single locale to display in compact mode from a saved preference and available options. * Used during collection initialization when restoring view preferences. + * + * Order: the remembered compact locale, then a saved single selection (older + * preferences persisted only that), then the base locale. */ export function resolveCompactLocale(options: { - readonly savedCompactLocale: string | null; + readonly savedCompactLocale: string | undefined; readonly currentSelectedLocales: string[]; readonly availableLocales: string[]; readonly baseLocale: string; @@ -88,15 +97,10 @@ export function resolveCompactLocale(options: { return [savedCompactLocale]; } - if (currentSelectedLocales.length === 0) { - if (baseLocale && availableLocales.includes(baseLocale)) return [baseLocale]; - if (availableLocales.length > 0) return [availableLocales[0]]; - return []; - } - - if (currentSelectedLocales.length > 1) { - return [currentSelectedLocales[0]]; + if (currentSelectedLocales.length === 1 && availableLocales.includes(currentSelectedLocales[0])) { + return currentSelectedLocales; } - return currentSelectedLocales; + const fallback = defaultCompactLocale(availableLocales, baseLocale); + return fallback ? [fallback] : []; } diff --git a/apps/tracker/src/app/browser/store/features/with-filter.feature.ts b/apps/tracker/src/app/browser/store/features/with-filter.feature.ts index 42fce044..814cf907 100644 --- a/apps/tracker/src/app/browser/store/features/with-filter.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-filter.feature.ts @@ -16,6 +16,18 @@ const initialFilterState: FilterState = { sortDirection: 'asc', }; +/** + * The one locale a compact row shows: the single selection when there is one, + * otherwise the base locale, otherwise the collection's first locale. Compact + * never shows a pair, so this is also what the locale filter must report. + */ +function resolveCompactDisplayLocale(selected: string[], available: string[], base: string): string { + const picked = selected.find((locale) => available.includes(locale)); + if (picked) return picked; + if (base && available.includes(base)) return base; + return available[0] ?? base; +} + export function withFilterFeature<_>() { return signalStoreFeature( { @@ -23,11 +35,12 @@ export function withFilterFeature<_>() { availableLocales: string[]; baseLocale: string; densityMode: string; + compactLocale: string | undefined; compactLocaleManuallyChanged: boolean; }>(), }, withState(initialFilterState), - withComputed(({ selectedLocales, selectedStatuses, availableLocales, baseLocale }) => ({ + withComputed(({ selectedLocales, selectedStatuses, availableLocales, baseLocale, densityMode }) => ({ isShowingAllLocales: computed(() => { const selected = selectedLocales(); const available = availableLocales(); @@ -38,6 +51,12 @@ export function withFilterFeature<_>() { const selected = selectedLocales(); const available = availableLocales(); + // Compact shows exactly one locale, so "All locales" would be a lie about + // what is on screen. Name the locale the rows are actually showing. + if (densityMode() === 'compact') { + return resolveCompactDisplayLocale(selected, available, baseLocale()); + } + if (selected.length === 0 || selected.length === available.length) return 'All locales'; if (selected.length === 1) return selected[0]; return `${selected.length} locales`; @@ -58,6 +77,19 @@ export function withFilterFeature<_>() { return result; }), + /** + * The single locale a compact row displays. + * + * Compact shows one value per row, never a pair. The selection is the + * single-select locale filter; when nothing is selected the row shows the + * base locale, because that is the string the developer wrote and arrived + * looking for. A collection with no base locale among its files falls back + * to its first locale so the row is never blank. + */ + compactDisplayLocale: computed(() => + resolveCompactDisplayLocale(selectedLocales(), availableLocales(), baseLocale()), + ), + filterableLocales: computed(() => { const available = availableLocales(); const base = baseLocale(); @@ -71,6 +103,10 @@ export function withFilterFeature<_>() { const isCompactMode = store.densityMode() === 'compact'; patchState(store, { selectedLocales: locales, + // In compact the selection *is* the compact locale, so it is remembered + // as soon as it changes — not only when the user leaves compact. That is + // what lets a reload land on the locale they were looking at. + compactLocale: isCompactMode ? locales.at(0) : store.compactLocale(), compactLocaleManuallyChanged: isCompactMode ? true : store.compactLocaleManuallyChanged(), }); }, @@ -82,6 +118,7 @@ export function withFilterFeature<_>() { patchState(store, { selectedLocales: newLocales, + compactLocale: isCompactMode ? newLocales.at(0) : store.compactLocale(), compactLocaleManuallyChanged: isCompactMode ? true : store.compactLocaleManuallyChanged(), }); }, diff --git a/apps/tracker/src/app/browser/store/features/with-view-preferences.feature.ts b/apps/tracker/src/app/browser/store/features/with-view-preferences.feature.ts index 741433ac..f55e5515 100644 --- a/apps/tracker/src/app/browser/store/features/with-view-preferences.feature.ts +++ b/apps/tracker/src/app/browser/store/features/with-view-preferences.feature.ts @@ -40,7 +40,7 @@ export function withViewPreferencesFeature<_>() { selectedStatuses: TranslationStatus[]; availableLocales: string[]; baseLocale: string; - compactLocale: string | null; + compactLocale: string | undefined; compactLocaleManuallyChanged: boolean; nonCompactSelectedLocales: string[]; }>(), diff --git a/apps/tracker/src/app/browser/store/view-preferences.types.ts b/apps/tracker/src/app/browser/store/view-preferences.types.ts index b89aac1e..2bfcaf1c 100644 --- a/apps/tracker/src/app/browser/store/view-preferences.types.ts +++ b/apps/tracker/src/app/browser/store/view-preferences.types.ts @@ -5,7 +5,7 @@ export interface ViewPreferences { densityMode: DensityMode; selectedLocales: string[]; showNestedResources: boolean; - compactLocale: string | null; + compactLocale: string | undefined; compactLocaleManuallyChanged: boolean; sortField: 'key' | 'status'; sortDirection: 'asc' | 'desc'; diff --git a/apps/tracker/src/app/browser/translation-browser.spec.ts b/apps/tracker/src/app/browser/translation-browser.spec.ts index 12747cf7..a2eab0a6 100644 --- a/apps/tracker/src/app/browser/translation-browser.spec.ts +++ b/apps/tracker/src/app/browser/translation-browser.spec.ts @@ -1,28 +1,33 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import type { ComponentFixture } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { describe, it, expect, beforeEach } from 'vitest'; -import { TranslationBrowser } from './translation-browser'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { getTranslocoTestingModule } from '../../testing/transloco-testing.module'; import { HeaderContextService } from '../shared/services/header-context.service'; +import { TranslationBrowser } from './translation-browser'; describe('TranslationBrowser - Integration', () => { let component: TranslationBrowser; let fixture: ComponentFixture; + let spectator: Spectator; let httpMock: HttpTestingController; let headerContext: HeaderContextService; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslationBrowser, getTranslocoTestingModule()], - providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])], - }).compileComponents(); + const createComponent = createComponentFactory({ + component: TranslationBrowser, + imports: [getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])], + detectChanges: false, + }); - fixture = TestBed.createComponent(TranslationBrowser); - component = fixture.componentInstance; - httpMock = TestBed.inject(HttpTestingController); - headerContext = TestBed.inject(HeaderContextService); + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; + httpMock = spectator.inject(HttpTestingController); + headerContext = spectator.inject(HeaderContextService); }); it('should create', () => { diff --git a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts index f40ff8ce..23d87591 100644 --- a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.spec.ts @@ -1,21 +1,30 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; import { signal } from '@angular/core'; -import { LocaleFilter } from './locale-filter'; -import { BrowserStore } from '../../../store/browser.store'; +import type { ComponentFixture } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { BrowserStore } from '../../../store/browser.store'; +import { LocaleFilter } from './locale-filter'; describe('LocaleFilter', () => { let component: LocaleFilter; let fixture: ComponentFixture; + let spectator: Spectator; let mockStore: any; - beforeEach(async () => { + const createComponent = createComponentFactory({ + component: LocaleFilter, + imports: [NoopAnimationsModule, getTranslocoTestingModule()], + providers: [{ provide: BrowserStore, useFactory: () => mockStore }], + }); + + beforeEach(() => { mockStore = { availableLocales: signal(['en', 'es', 'fr', 'de']), filterableLocales: signal(['es', 'fr', 'de']), // Excludes base locale 'en' selectedLocales: signal([]), + compactDisplayLocale: signal('en'), localeFilterText: signal('All locales'), isShowingAllLocales: signal(true), toggleLocale: vi.fn(), @@ -24,14 +33,9 @@ describe('LocaleFilter', () => { setSelectedLocales: vi.fn(), }; - await TestBed.configureTestingModule({ - imports: [LocaleFilter, NoopAnimationsModule, getTranslocoTestingModule()], - providers: [{ provide: BrowserStore, useValue: mockStore }], - }).compileComponents(); - - fixture = TestBed.createComponent(LocaleFilter); - component = fixture.componentInstance; - fixture.detectChanges(); + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); describe('Component Initialization', () => { @@ -75,6 +79,16 @@ describe('LocaleFilter', () => { expect(component.isLocaleSelected('fr')).toBe(false); }); + it('should check the displayed locale in single-select mode even with no selection', () => { + fixture.componentRef.setInput('multiSelect', false); + mockStore.selectedLocales.set([]); + mockStore.compactDisplayLocale.set('en'); + fixture.detectChanges(); + + expect(component.isLocaleSelected('en')).toBe(true); + expect(component.isLocaleSelected('es')).toBe(false); + }); + it('should toggle locale', () => { component.toggleLocale('en'); expect(mockStore.toggleLocale).toHaveBeenCalledWith('en'); diff --git a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.ts b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.ts index 62733e22..fb175088 100644 --- a/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.ts +++ b/apps/tracker/src/app/browser/translations/header/locale-filter/locale-filter.ts @@ -71,8 +71,16 @@ export class LocaleFilter { /** * Checks if a locale is currently selected. + * + * In single-select mode the checked radio is the locale the compact rows are + * showing, which is the base locale until the user picks one. Reading the raw + * selection would leave every radio unchecked on first load while the list + * plainly shows a locale. */ isLocaleSelected(locale: string): boolean { + if (!this.multiSelect()) { + return this.store.compactDisplayLocale() === locale; + } return this.store.selectedLocales().includes(locale); } diff --git a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts index 5dd023ba..78dc8cd1 100644 --- a/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/status-filter/status-filter.spec.ts @@ -1,23 +1,30 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { signal, computed } from '@angular/core'; +import { computed, signal } from '@angular/core'; +import type { ComponentFixture } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import type { TranslationStatus } from '@simoncodes-ca/data-transfer'; -import { StatusFilter } from './status-filter'; -import { BrowserStore } from '../../../store/browser.store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; +import { BrowserStore } from '../../../store/browser.store'; +import { StatusFilter } from './status-filter'; describe('StatusFilter', () => { let component: StatusFilter; let fixture: ComponentFixture; + let spectator: Spectator; let selectedStatuses: ReturnType>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockStore: any; const toggleFor = (id: string): HTMLButtonElement | null => fixture.nativeElement.querySelector(`[data-testid="status-toggle-${id}"]`); - beforeEach(async () => { + const createComponent = createComponentFactory({ + component: StatusFilter, + imports: [NoopAnimationsModule, getTranslocoTestingModule()], + providers: [{ provide: BrowserStore, useFactory: () => mockStore }], + }); + + beforeEach(() => { selectedStatuses = signal([]); mockStore = { @@ -30,14 +37,9 @@ describe('StatusFilter', () => { clearAllStatuses: vi.fn(), }; - await TestBed.configureTestingModule({ - imports: [StatusFilter, NoopAnimationsModule, getTranslocoTestingModule()], - providers: [{ provide: BrowserStore, useValue: mockStore }], - }).compileComponents(); - - fixture = TestBed.createComponent(StatusFilter); - component = fixture.componentInstance; - fixture.detectChanges(); + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); describe('Component Initialization', () => { diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts index 84002909..80201c16 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts @@ -1,19 +1,19 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { MatDialog } from '@angular/material/dialog'; -import { NotificationService } from '../../../shared/notification'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import { of } from 'rxjs'; -import { TranslationMainHeader } from './translation-main-header'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { NotificationService } from '../../../shared/notification'; import type { TranslationEditorResult } from '../../dialogs/translation-editor'; import { BrowserStore } from '../../store/browser.store'; -import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { TranslationMainHeader } from './translation-main-header'; describe('TranslationMainHeader', () => { let component: TranslationMainHeader; - let fixture: ComponentFixture; + let spectator: Spectator; let notificationsSpy: { success: ReturnType; info: ReturnType; @@ -23,26 +23,26 @@ describe('TranslationMainHeader', () => { let mockDialogRef: { afterClosed: ReturnType }; let mockDialog: { open: ReturnType }; - beforeEach(async () => { + const createComponent = createComponentFactory({ + component: TranslationMainHeader, + imports: [BrowserAnimationsModule, getTranslocoTestingModule()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: NotificationService, useFactory: () => notificationsSpy }, + { provide: MatDialog, useFactory: () => mockDialog }, + ], + }); + + beforeEach(() => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; mockDialogRef = { afterClosed: vi.fn() }; mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; - await TestBed.configureTestingModule({ - imports: [TranslationMainHeader, BrowserAnimationsModule, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - { provide: MatDialog, useValue: mockDialog }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationMainHeader); - component = fixture.componentInstance; - fixture.detectChanges(); - - // Enable fake timers AFTER TestBed async setup completes + spectator = createComponent(); + component = spectator.component; + + // Enable fake timers after Spectator setup completes. vi.useFakeTimers(); }); @@ -155,7 +155,7 @@ describe('TranslationMainHeader', () => { }); it('should reload the current folder before showing snackbars', async () => { - const store = TestBed.inject(BrowserStore); + const store = spectator.inject(BrowserStore); const selectFolderSpy = vi.spyOn(store, 'selectFolder'); const result: TranslationEditorResult = { @@ -182,7 +182,7 @@ describe('TranslationMainHeader', () => { }); it('should call store.setDensityMode with the opposite mode at the 125ms midpoint', async () => { - const store = TestBed.inject(BrowserStore); + const store = spectator.inject(BrowserStore); const setDensityModeSpy = vi.spyOn(store, 'setDensityMode'); // Initial density mode defaults to 'compact', so next mode should be 'full' @@ -204,7 +204,7 @@ describe('TranslationMainHeader', () => { }); it('should pass only the second call value to the store on rapid double-click', async () => { - const store = TestBed.inject(BrowserStore); + const store = spectator.inject(BrowserStore); const setDensityModeSpy = vi.spyOn(store, 'setDensityMode'); // First call (compact → full), cancelled before midpoint diff --git a/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts index db971835..e4c65c62 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-search/translation-search.spec.ts @@ -1,19 +1,27 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; import { signal } from '@angular/core'; +import type { ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TranslationSearch } from './translation-search'; -import { BrowserStore } from '../../../store/browser.store'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; import { SearchInput } from '../../../../shared/components/search-input'; +import { BrowserStore } from '../../../store/browser.store'; +import { TranslationSearch } from './translation-search'; describe('TranslationSearch', () => { let component: TranslationSearch; let fixture: ComponentFixture; + let spectator: Spectator; let mockStore: any; - beforeEach(async () => { + const createComponent = createComponentFactory({ + component: TranslationSearch, + imports: [NoopAnimationsModule, getTranslocoTestingModule()], + providers: [{ provide: BrowserStore, useFactory: () => mockStore }], + }); + + beforeEach(() => { mockStore = { searchQuery: signal(''), isSearchMode: signal(false), @@ -23,14 +31,9 @@ describe('TranslationSearch', () => { searchTranslations: vi.fn(), }; - await TestBed.configureTestingModule({ - imports: [TranslationSearch, NoopAnimationsModule, getTranslocoTestingModule()], - providers: [{ provide: BrowserStore, useValue: mockStore }], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationSearch); - component = fixture.componentInstance; - fixture.detectChanges(); + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); describe('Component Initialization', () => { diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.html b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.html index a5f8105f..98442012 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.html +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.html @@ -19,6 +19,7 @@ [class.full]="densityMode() === 'full'" [class.expanded]="isExpanded()" [class.locale-value--empty]="!base.value" + data-selectable-text dir="auto" [innerHTML]="(base.value || '—') | highlight : searchQuery()" > @@ -72,6 +73,7 @@ [class.full]="densityMode() === 'full'" [class.expanded]="isExpanded()" [class.locale-value--empty]="!lt.value" + data-selectable-text dir="auto" [innerHTML]="(lt.value || '—') | highlight : searchQuery()" > diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.scss b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.scss index 5c7b8316..cf0afb20 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.scss +++ b/apps/tracker/src/app/browser/translations/list/translation-item/item-locales.scss @@ -63,7 +63,7 @@ flex-shrink: 0; font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); - padding: calc(var(--spacing-1) / 2) var(--spacing-2); + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-2) calc(var(--spacing-1) / 2); } /* @@ -98,7 +98,10 @@ font-weight: var(--font-weight-semibold); gap: calc(var(--spacing-1) / 2); letter-spacing: 0.01em; - padding: calc(var(--spacing-1) / 2) var(--spacing-2); + /* The extra pixel on top is an optical correction: at this size the label's + x-height sits below the geometric centre, so equal padding reads as text + hugging the bottom edge. The compact chip carries the same asymmetry. */ + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-2) calc(var(--spacing-1) / 2); /* Holds the column steady across locales; the longest label sets the floor, and min-width lets RU and DE grow past it rather than truncate. */ min-width: 96px; @@ -152,7 +155,7 @@ font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); gap: calc(var(--spacing-1) / 2); - padding: calc(var(--spacing-1) / 2) var(--spacing-2); + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-2) calc(var(--spacing-1) / 2); /* Matches the status chip it sits beside, so the two read as one row of annotations rather than two things at slightly different heights. */ } @@ -175,6 +178,17 @@ font-size: var(--font-size-sm); line-height: var(--line-height-normal); + /* Plain text to copy out, not a surface that opens the editor. See the + `[data-selectable-text]` note in translation-item.scss. */ + cursor: text; + user-select: text; + + &::selection, + & *::selection { + background: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); + } + &.full { -webkit-box-orient: vertical; -webkit-line-clamp: 6; diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html index 5f04b865..ed5013f3 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.html @@ -25,9 +25,10 @@ @if (currentDensityMode() === 'compact') { - - + - - @switch (compactLayout()) { - @case ('paired') { - - {{ compactDisplay().locale }} - - @if (compactDisplay().status) { - - } - - @if (compactDisplay().isSameAsBase) { - - - {{ TOKENS.BROWSER.TRANSLATIONITEM.SAMEASSOURCETAG | transloco }} - - } - - - + @if (hasCompactAnnotation()) { + + @if (compactDisplay().needsAttention) { + - - } - - @case ('source-only') { - - - {{ compactDisplay().locale }} - {{ TOKENS.BROWSER.TRANSLATIONITEM.BASELOCALETAG | transloco }} - - } + } - @case ('no-base') { - - - {{ compactDisplay().locale }} - - @if (compactDisplay().status) { - - } - - - - } + > + + {{ TOKENS.BROWSER.TRANSLATIONITEM.SAMEASSOURCETAG | transloco }} + + } + } } @else { @@ -156,7 +109,7 @@ @if (translation().comment) {
-

+

{{ TOKENS.BROWSER.COMMENTPREFIX | transloco }}

diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss index 30846dee..7f875e1a 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.scss @@ -87,20 +87,19 @@ /* * Compact zones. The row is one line composed by the header in `inline` layout: - * drag handle · key · identity · annotation · rail. Everything here is about what - * gives way first when the line runs out of room. + * drag handle · key · value · annotation · rail. One locale's value, never a + * pair; the annotation appears only when the value needs attention. */ -/* Identity — the source string. Takes the larger share and truncates last, - because it is the only thing on the row a developer already recognises. */ -.compact-identity { +/* The value — whichever locale compact is showing. Takes the larger share and + truncates last: it is the row's content, everything else is a marker. */ +.compact-value { color: var(--color-text-primary); - // The zone that absorbs the squeeze. Everything in the annotation is a chip or - // a code that clips rather than reflows, so the identity is the only thing that - // can give way gracefully — and an ellipsis on a sentence is readable, where a - // status chip cut down to "Ne" is not. + // The zone that absorbs the squeeze. Everything in the annotation is a chip + // that clips rather than reflows, so the value is the only thing that can give + // way gracefully — and an ellipsis on a sentence is readable, where a status + // chip cut down to "Ne" is not. flex: 1 1 0; - min-width: 4rem; font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-snug); @@ -110,93 +109,50 @@ white-space: nowrap; } -/* No source text at all — a state to read, not content. */ -.compact-identity--empty { +/* + * The value is text to take away, not a control. A double-click here is the + * OS word-select gesture, so it must behave like any paragraph: text cursor, + * selectable, and a selection tinted from the palette instead of the browser's + * default blue. The editor is reached from the header, `E`, long-press or the + * menu. Shared with the full-density values in item-locales.scss. + */ +[data-selectable-text] { + cursor: text; + user-select: text; + + &::selection, + & *::selection { + background: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); + } +} + +/* No value for this locale — a state to read, not content. */ +.compact-value--empty { color: var(--color-text-secondary); font-style: italic; } -/* Annotation — the selected locale, measured against the identity beside it. */ +/* Annotation — only rendered when the row has work to report: a new or stale + status, or a translation that is the source text verbatim. */ .compact-annotation { align-items: center; color: var(--color-text-secondary); display: flex; - // Sizes to its own content and never shrinks below it: its parts are chips and - // codes with nothing to truncate. What it can no longer afford is dropped - // wholesale by the container queries at the end of this file, in priority - // order, rather than sliced in half. + // Sizes to its own content and never shrinks below it: its parts are chips + // with nothing to truncate. What it can no longer afford is dropped wholesale + // by the container queries at the end of this file, rather than sliced in half. flex: 0 0 auto; gap: var(--spacing-2); - max-width: 100%; min-width: 0; } -/* The tagged variants carry no value, so they claim only the width they need - and leave the rest to the identity. */ -.compact-annotation--tagged { - flex: 0 0 auto; -} - -.compact-translation { - flex: 1 1 auto; - font-size: var(--font-size-sm); - line-height: var(--line-height-snug); - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.compact-translation--empty { - color: var(--color-text-tertiary); - font-style: italic; -} - -.compact-locale-code { - color: var(--color-text-secondary); - flex-shrink: 0; - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - text-transform: uppercase; -} - -/* Marks a row that is showing base-locale source text, not a translation. */ -.compact-locale-tag { - background-color: var(--color-background-subtle); - border-radius: var(--border-radius-full); - color: var(--color-text-placeholder); - flex-shrink: 0; - font-size: var(--font-size-xs); - padding: 0 var(--spacing-1); -} - -/* - * The collection has no base locale, so the app chose which locale to show. That - * choice is arbitrary and must never be silent — this names it as a default and - * its tooltip points at the locale filter. A real button so the explanation is - * reachable from the keyboard, not only on hover. - */ -.default-locale-tag { - background-color: var(--color-background-subtle); - border: 1px dashed var(--color-border-strong); - border-radius: var(--border-radius-full); - color: var(--color-text-placeholder); - cursor: help; - flex-shrink: 0; - font-family: inherit; - font-size: var(--font-size-xs); - padding: 0 var(--spacing-2); - - &:focus-visible { - outline: 2px solid var(--focus-ring-color); - outline-offset: 2px; - } -} - /* * Compact status chip. Mirrors item-locales.scss so a status looks identical in * both densities — the toggle changes how much you see, never what a colour - * means. + * means. Only `new` and `stale` are ever rendered here: the quiet states are + * left to the rollup ring. The 2px of vertical padding matches the full-density + * chip; without it the text sat flush against the border and read as cramped. */ .compact-annotation .status-chip { align-items: center; @@ -211,7 +167,7 @@ gap: calc(var(--spacing-1) / 2); letter-spacing: 0.01em; line-height: var(--line-height-snug); - padding: 0 var(--spacing-2); + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-2) calc(var(--spacing-1) / 2); &.status-new { background-color: var(--color-status-new-surface); @@ -224,18 +180,6 @@ border-color: var(--color-status-stale-border); color: var(--color-status-stale-ink); } - - &.status-translated { - background-color: var(--color-status-translated-surface); - border-color: var(--color-status-translated-border); - color: var(--color-status-translated-ink); - } - - &.status-verified { - background-color: var(--color-status-verified-surface); - border-color: var(--color-status-verified-border); - color: var(--color-status-verified-ink); - } } .compact-annotation .status-chip__icon { @@ -258,7 +202,7 @@ font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); gap: calc(var(--spacing-1) / 2); - padding: 0 var(--spacing-2); + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-2) calc(var(--spacing-1) / 2); } .compact-annotation .same-as-source__icon { @@ -475,16 +419,7 @@ } .compact-annotation .same-as-source { - padding: 0 var(--spacing-1); - } -} - -@container (max-width: 40rem) { - /* Below this the translation is a word or two of noise. The status chip and - the marker still say what state the row is in, and the editor shows the - value in full. */ - .compact-annotation .compact-translation { - display: none; + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-1) calc(var(--spacing-1) / 2); } } @@ -495,6 +430,6 @@ } .compact-annotation .status-chip { - padding: 0 var(--spacing-1); + padding: calc(var(--spacing-1) / 2 + 1px) var(--spacing-1) calc(var(--spacing-1) / 2); } } diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts index 2eb31230..1c293c61 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.spec.ts @@ -1,32 +1,45 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import type { ComponentFixture } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TranslationItem } from './translation-item'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; import { getTranslocoTestingModule } from '../../../../../testing/transloco-testing.module'; import { BrowserStore } from '../../../store/browser.store'; import { TranslationListStore } from '../store/translation-list.store'; -import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; +import { TranslationItem } from './translation-item'; + +const createItem = createComponentFactory({ + component: TranslationItem, + imports: [getTranslocoTestingModule()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: MatDialog, useValue: { open: vi.fn() } }, + TranslationListStore, + ], + detectChanges: false, +}); -async function configureTranslationItemTestBed(): Promise<{ +function renderTranslationItem(): { fixture: ComponentFixture; component: TranslationItem; store: InstanceType; -}> { - await TestBed.configureTestingModule({ - imports: [TranslationItem, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: MatDialog, useValue: { open: vi.fn() } }, - TranslationListStore, - ], - }).compileComponents(); - - const fixture = TestBed.createComponent(TranslationItem); - return { fixture, component: fixture.componentInstance, store: TestBed.inject(BrowserStore) }; + spectator: Spectator; +} { + // View preferences persist per collection, so a compact locale picked by one + // test would otherwise be restored by the next. + localStorage.clear(); + + const spectator = createItem(); + return { + fixture: spectator.fixture, + component: spectator.component, + store: spectator.inject(BrowserStore), + spectator, + }; } const mockTranslation: ResourceSummaryDto = { @@ -47,8 +60,8 @@ describe('TranslationItem', () => { let fixture: ComponentFixture; let store: InstanceType; - beforeEach(async () => { - ({ fixture, component, store } = await configureTranslationItemTestBed()); + beforeEach(() => { + ({ fixture, component, store } = renderTranslationItem()); }); it('should create', () => { @@ -70,59 +83,91 @@ describe('TranslationItem', () => { status: { es: 'new' }, } as any; - // Compact mode with 'es' selected: filteredLocales() = ['es'], so primaryLocaleValue() = '' store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + store.setDensityMode('compact'); store.setSelectedLocales(['es']); fixture.componentRef.setInput('translation', t); fixture.detectChanges(); - // primaryLocaleValue shows the non-base locale's value ('es' which is empty) - expect(component.primaryLocaleValue()).toBe(''); + expect(component.compactDisplay().value).toBe(''); const html = fixture.nativeElement.innerHTML as string; expect(html).toContain('No translation'); }); - it('should flag the base locale as a fallback when no non-base locale is available', () => { - store.setSelectedCollection({ collectionName: 'test', locales: ['en'], baseLocale: 'en' }); - store.setDensityMode('full'); - store.clearAllLocales(); - fixture.componentRef.setInput('translation', mockTranslation); - fixture.detectChanges(); + describe('compact row composition', () => { + it('shows the base value alone by default', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); + store.setDensityMode('compact'); + fixture.componentRef.setInput('translation', mockTranslation); + fixture.detectChanges(); - expect(component.compactDisplay().locale).toBe(store.baseLocale()); - expect(component.compactDisplay().isBaseFallback).toBe(true); - const html = fixture.nativeElement.innerHTML as string; - expect(html).toContain('common.buttons.save'); - }); + const display = component.compactDisplay(); + expect(display.locale).toBe('en'); + expect(display.isBase).toBe(true); + expect(display.value).toBe('Save'); + // Source text carries no status and no marker + expect(display.status).toBeUndefined(); + expect(component.hasCompactAnnotation()).toBe(false); + + const html = fixture.nativeElement.innerHTML as string; + expect(html).toContain('Save'); + expect(html).not.toContain('Guardar'); + }); - describe('compact row composition', () => { - it('leads with the base value and annotates it with the selected locale', () => { + it('replaces the base value with the selected locale value, not beside it', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); store.setDensityMode('compact'); store.setSelectedLocales(['es']); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - expect(component.compactLayout()).toBe('paired'); - // The source string the developer wrote identifies the row... - expect(component.identityValue()).toBe('Save'); - // ...and the selected locale sits beside it as the comparison. expect(component.compactDisplay().locale).toBe('es'); expect(component.compactDisplay().value).toBe('Guardar'); + + const html = fixture.nativeElement.innerHTML as string; + expect(html).toContain('Guardar'); + expect(html).not.toContain('>Save<'); }); - it('shows source text alone when the selection resolves to the base locale', () => { - store.setSelectedCollection({ collectionName: 'test', locales: ['en'], baseLocale: 'en' }); + it('hides the status chip for translated and verified rows', () => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); store.setDensityMode('compact'); - store.clearAllLocales(); + store.setSelectedLocales(['es']); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - expect(component.compactLayout()).toBe('source-only'); - expect(component.identityValue()).toBe('Save'); + expect(component.compactDisplay().status).toBe('translated'); + expect(component.compactDisplay().needsAttention).toBe(false); + expect(fixture.nativeElement.querySelector('.compact-annotation .status-chip')).toBeNull(); + + store.setSelectedLocales(['fr']); + fixture.detectChanges(); + expect(component.compactDisplay().status).toBe('verified'); + expect(fixture.nativeElement.querySelector('.compact-annotation .status-chip')).toBeNull(); }); - it('falls back to the shown locale when the collection carries no base locale', () => { + it('shows the status chip only for new and stale rows', () => { + const needsWork: ResourceSummaryDto = { + key: 'common.buttons.save', + translations: { en: 'Save', es: 'Guardar viejo', fr: '' }, + status: { es: 'stale', fr: 'new' }, + } as ResourceSummaryDto; + + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.setSelectedLocales(['es']); + fixture.componentRef.setInput('translation', needsWork); + fixture.detectChanges(); + + expect(component.compactDisplay().needsAttention).toBe(true); + expect(fixture.nativeElement.querySelector('.compact-annotation .status-chip.status-stale')).not.toBeNull(); + + store.setSelectedLocales(['fr']); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.compact-annotation .status-chip.status-new')).not.toBeNull(); + }); + + it('falls back to the first locale when the collection carries no base locale', () => { // A vendored collection can ship translations with no base locale at all. const noBase: ResourceSummaryDto = { key: 'agGrid.addToLabels', @@ -132,15 +177,11 @@ describe('TranslationItem', () => { store.setSelectedCollection({ collectionName: 'ds', locales: ['ar', 'de'], baseLocale: 'en' }); store.setDensityMode('compact'); - store.setSelectedLocales(['ar']); fixture.componentRef.setInput('translation', noBase); fixture.detectChanges(); - expect(component.compactLayout()).toBe('no-base'); - // There is no source text to lead with, so the shown locale becomes identity. - expect(component.identityValue()).toBe('إضافة'); - // And the arbitrary pick is named rather than silent. - expect(fixture.nativeElement.innerHTML as string).toContain('default'); + expect(component.compactDisplay().locale).toBe('ar'); + expect(component.compactDisplay().value).toBe('إضافة'); }); it('marks a translation that is the source text verbatim', () => { @@ -159,6 +200,27 @@ describe('TranslationItem', () => { // Status says `translated`; a checksum cannot see that nobody touched it. expect(component.compactDisplay().status).toBe('translated'); expect(component.compactDisplay().isSameAsBase).toBe(true); + expect(component.hasCompactAnnotation()).toBe(true); + expect(fixture.nativeElement.querySelector('.compact-annotation .same-as-source')).not.toBeNull(); + }); + + it('shows one marker at most: the status chip wins over same-as-source', () => { + const untouchedNew: ResourceSummaryDto = { + key: 'common.buttons.add', + translations: { en: 'Add', 'fr-ca': 'Add' }, + status: { 'fr-ca': 'new' }, + } as ResourceSummaryDto; + + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'fr-ca'], baseLocale: 'en' }); + store.setDensityMode('compact'); + store.setSelectedLocales(['fr-ca']); + fixture.componentRef.setInput('translation', untouchedNew); + fixture.detectChanges(); + + expect(component.compactDisplay().needsAttention).toBe(true); + expect(component.compactDisplay().isSameAsBase).toBe(false); + expect(fixture.nativeElement.querySelector('.compact-annotation .status-chip.status-new')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.compact-annotation .same-as-source')).toBeNull(); }); }); @@ -202,40 +264,35 @@ describe('TranslationItem - Compact helpers', () => { let fixture: ComponentFixture; let store: InstanceType; - beforeEach(async () => { - ({ fixture, component, store } = await configureTranslationItemTestBed()); + beforeEach(() => { + ({ fixture, component, store } = renderTranslationItem()); }); - it('should display the first non-base locale, not the base locale', () => { + it('should show the base locale in compact until a locale is picked', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); - store.setDensityMode('full'); - store.clearAllLocales(); + store.setDensityMode('compact'); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - expect(component.compactDisplay().locale).toBe('es'); - expect(component.compactDisplay().isBaseFallback).toBe(false); + expect(component.compactDisplay().locale).toBe('en'); + expect(component.compactDisplay().isBase).toBe(true); }); - it('should return selected non-base locale value, or base value when only base is selected', () => { + it('should follow the single compact selection', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); - store.setDensityMode('full'); - store.clearAllLocales(); + store.setDensityMode('compact'); fixture.componentRef.setInput('translation', mockTranslation); fixture.detectChanges(); - // When a non-base locale is in the list alongside base, show non-base value - expect(component.primaryLocaleValue()).toBe('Guardar'); + expect(component.compactDisplay().value).toBe('Save'); - // When only a non-base locale is selected, show its value store.setSelectedLocales(['es']); fixture.detectChanges(); - expect(component.primaryLocaleValue()).toBe('Guardar'); + expect(component.compactDisplay().value).toBe('Guardar'); - // When only base locale is selected (or no non-base locales), show base value store.setSelectedLocales(['en']); fixture.detectChanges(); - expect(component.primaryLocaleValue()).toBe('Save'); + expect(component.compactDisplay().value).toBe('Save'); }); it('rollupStatus should calculate worst status across all locales', () => { @@ -287,10 +344,11 @@ describe('TranslationItem - Compact helpers', () => { describe('TranslationItem - Full density expansion', () => { let component: TranslationItem; let fixture: ComponentFixture; + let spectator: Spectator; let store: InstanceType; - beforeEach(async () => { - ({ fixture, component, store } = await configureTranslationItemTestBed()); + beforeEach(() => { + ({ fixture, component, store, spectator } = renderTranslationItem()); store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); store.setDensityMode('full'); @@ -428,6 +486,55 @@ describe('TranslationItem - Full density expansion', () => { }); }); + describe('double-click to edit', () => { + beforeEach(() => { + store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es', 'fr'], baseLocale: 'en' }); + fixture.componentRef.setInput('translation', mockTranslation); + }); + + function dblclick(el: Element | null): void { + expect(el).not.toBeNull(); + el?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + } + + it('leaves a double-click on the compact value to the browser text selection', () => { + store.setDensityMode('compact'); + fixture.detectChanges(); + const editSpy = vi + .spyOn(spectator.inject(TranslationListStore, true), 'editTranslation') + .mockImplementation(() => undefined); + + dblclick(fixture.nativeElement.querySelector('.compact-value')); + + expect(editSpy).not.toHaveBeenCalled(); + }); + + it('leaves a double-click on a full-density value to the browser text selection', () => { + store.setDensityMode('full'); + fixture.detectChanges(); + const editSpy = vi + .spyOn(spectator.inject(TranslationListStore, true), 'editTranslation') + .mockImplementation(() => undefined); + + dblclick(fixture.nativeElement.querySelector('.locale-value--base')); + dblclick(fixture.nativeElement.querySelector('.locale-line:not(.locale-line--base) .locale-value')); + + expect(editSpy).not.toHaveBeenCalled(); + }); + + it('opens the editor on a double-click of the header', () => { + store.setDensityMode('full'); + fixture.detectChanges(); + const editSpy = vi + .spyOn(spectator.inject(TranslationListStore, true), 'editTranslation') + .mockImplementation(() => undefined); + + dblclick(fixture.nativeElement.querySelector('.item-header')); + + expect(editSpy).toHaveBeenCalledOnce(); + }); + }); + describe('read-only collections', () => { beforeEach(() => { fixture.componentRef.setInput('translation', mockTranslation); @@ -442,7 +549,7 @@ describe('TranslationItem - Full density expansion', () => { }); fixture.detectChanges(); - const listStore = TestBed.inject(TranslationListStore); + const listStore = spectator.inject(TranslationListStore, true); const deleteSpy = vi.spyOn(listStore, 'deleteTranslation'); component.onKeyDown(new KeyboardEvent('keydown', { key: 'Delete' })); @@ -454,7 +561,7 @@ describe('TranslationItem - Full density expansion', () => { store.setSelectedCollection({ collectionName: 'test', locales: ['en', 'es'], baseLocale: 'en' }); fixture.detectChanges(); - const listStore = TestBed.inject(TranslationListStore); + const listStore = spectator.inject(TranslationListStore, true); const deleteSpy = vi.spyOn(listStore, 'deleteTranslation').mockImplementation(() => undefined); component.onKeyDown(new KeyboardEvent('keydown', { key: 'Delete' })); diff --git a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts index 0e46a6e7..a9537030 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts @@ -1,6 +1,5 @@ import { Component, ChangeDetectionStrategy, input, output, computed, effect, inject, signal } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { CdkDrag, CdkDragPlaceholder } from '@angular/cdk/drag-drop'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; import { BrowserStore } from '../../../store/browser.store'; @@ -54,7 +53,6 @@ const STATUS_SORT_PRIORITY: Record = { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatIconModule, - MatTooltipModule, TranslationItemHeader, TranslationItemLocales, HighlightPipe, @@ -166,29 +164,42 @@ export class TranslationItem { readonly currentDensityMode = computed(() => this.#store.densityMode()); /** - * The locale whose value the compact row annotates, together with its status. + * What the compact row shows: one locale's value, and only what is worth + * saying about it. * - * `isBaseFallback` marks the case where the compact locale selection resolved to - * the base locale itself — there is then only one string on the row, and it is - * source text rather than a translation. + * The locale is the store's single compact selection — the base locale until + * the user picks another, at which point that locale's value takes the base + * value's place rather than sitting beside it. Compact used to show source and + * translation as a pair, which made the translation an awkward annotation + * hanging off the right-hand end of the row. + * + * `needsAttention` gates the status chip. `translated` and `verified` are the + * quiet states — the rollup already reports them — so a chip on every row saying + * so was noise. Only `new` and `stale` name work to be done, and only they are + * shown. A row carries at most one marker: the chip when the status asks for + * work, otherwise the same-as-source flag when a "finished" translation is + * really the source text. */ readonly compactDisplay = computed(() => { + const locale = this.#store.compactDisplayLocale(); const base = this.#store.baseLocale(); - const nonBaseLocales = this.#store.filteredLocales().filter((locale) => locale !== base); - - const locale = nonBaseLocales.length > 0 ? nonBaseLocales[0] : base; const translation = this.translation(); const value = translation.translations[locale] || ''; - const isBaseFallback = nonBaseLocales.length === 0; + const isBase = locale === base; + const status = isBase ? undefined : translation.status?.[locale]; + const needsAttention = status === 'new' || status === 'stale'; return { locale, value, - status: translation.status?.[locale], - isBaseFallback, - // A base-locale fallback row is already labelled `source`; saying "same as - // source" next to it would be the same fact twice. - isSameAsBase: !isBaseFallback && isIdenticalToBase(value, translation.translations[base] || ''), + isBase, + status, + needsAttention, + // Source text is trivially the same as itself, so the marker only means + // something for a translation — and only for one the status chip already + // calls finished. A `new` row that still holds the English copy is flagged + // once, by the chip; a second marker saying the same thing is noise. + isSameAsBase: !isBase && !needsAttention && isIdenticalToBase(value, translation.translations[base] || ''), }; }); @@ -198,42 +209,12 @@ export class TranslationItem { /** Transloco token for the compact row's status label ('' when the status is unknown). */ readonly compactStatusToken = computed(() => statusLabelTokenFor(this.compactDisplay().status)); - /** - * How the compact row composes itself. Three genuinely different situations, - * named rather than inferred in the template: - * - * - `paired` — the ordinary case. The base value identifies the row and the - * selected locale's value sits beside it, so source and translation can be - * judged against each other in one horizontal glance. - * - `source-only` — the compact locale selection resolved to the base locale - * itself. One string, and it is source text; there is nothing to compare it - * against, so no annotation value is shown. - * - `no-base` — the collection carries no base locale at all (a vendored - * design-system collection may ship `ar`…`sv` and no `en`). There is no source - * text to lead with, so the shown locale becomes the identity and the row says - * the pick was automatic. - */ - readonly compactLayout = computed<'paired' | 'source-only' | 'no-base'>(() => { - if (this.compactDisplay().isBaseFallback) return 'source-only'; - if (!this.baseValue().trim()) return 'no-base'; - return 'paired'; + /** Whether the compact row has anything to say beyond the value itself. */ + readonly hasCompactAnnotation = computed(() => { + const display = this.compactDisplay(); + return display.needsAttention || display.isSameAsBase; }); - /** - * The string that identifies a compact row. - * - * The base value wherever one exists, because it is the only text on the row the - * developer already knows — they wrote it, and they arrived looking for it. - * Compact used to omit it entirely and lead with a translation, which handed a - * developer scanning an eleven-locale collection a script they could not read. - */ - readonly identityValue = computed(() => - this.compactLayout() === 'no-base' ? this.compactDisplay().value : this.baseValue(), - ); - - /** Value rendered in the compact row's annotation zone. */ - readonly primaryLocaleValue = computed(() => this.compactDisplay().value); - /** Signal controlling whether the full-mode content is expanded */ readonly isExpanded = signal(false); @@ -300,16 +281,23 @@ export class TranslationItem { // Double-click handler --------------------------------------------------- /** - * Opens the edit dialog when the item is double-clicked. - * Ignores double-clicks originating from interactive elements (buttons, inputs, - * anchors, selects) so that action-menu interactions are not accidentally - * treated as edit requests. + * Opens the edit dialog when the item's chrome is double-clicked. + * + * Two kinds of target are excluded. Interactive elements (buttons, inputs, + * anchors, selects), so that action-menu interactions are not treated as edit + * requests. And anything marked `data-selectable-text` — the base value, the + * locale values and the note — because on those a double-click is the + * operating system's word-select gesture: the person is reaching for ⌘C, and a + * dialog opening on top of the selection they just made defeats them. Those + * regions behave as plain text; the row's header, gaps and labels still open + * the editor, as do `E`, long-press and the actions menu. */ onDoubleClick(event: MouseEvent): void { const target = event.target as HTMLElement; const isInteractiveElement = Boolean(target.closest('button, input, textarea, select, a, [role="button"]')); + const isSelectableText = Boolean(target.closest('[data-selectable-text]')); - if (isInteractiveElement) { + if (isInteractiveElement || isSelectableText) { return; } diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index c846dbe4..2cb04b37 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts @@ -1,39 +1,39 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import type { ComponentFixture } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { NotificationService } from '../../../shared/notification'; -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { TranslationList } from './translation-list'; -import { BrowserStore } from '../../store/browser.store'; +import { TranslocoService } from '@jsverse/transloco'; +import { createComponentFactory } from '@ngneat/spectator/vitest'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; -import type { TranslationEditorResult } from '../../dialogs/translation-editor'; -import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; import { of, throwError } from 'rxjs'; -import { BrowserApiService } from '../../services/browser-api.service'; -import { TranslocoService } from '@jsverse/transloco'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; +import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { NotificationService } from '../../../shared/notification'; +import type { TranslationEditorResult } from '../../dialogs/translation-editor'; +import { BrowserApiService } from '../../services/browser-api.service'; +import { BrowserStore } from '../../store/browser.store'; import { TranslationListStore } from './store/translation-list.store'; +import { TranslationList } from './translation-list'; + +const createList = createComponentFactory({ + component: TranslationList, + imports: [getTranslocoTestingModule()], + providers: [provideHttpClient(), provideHttpClientTesting()], + detectChanges: false, +}); + +const renderList = (providers: unknown[] = []): ComponentFixture => createList({ providers }).fixture; describe('TranslationList', () => { let component: TranslationList; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: NotificationService, - useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }, - }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + beforeEach(() => { + fixture = renderList([ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ]); component = fixture.componentInstance; }); @@ -67,7 +67,7 @@ describe('TranslationList - Copy to Clipboard', () => { error: ReturnType; }; - beforeEach(async () => { + beforeEach(() => { mockClipboard = { writeText: vi.fn(() => Promise.resolve()), }; @@ -79,17 +79,10 @@ describe('TranslationList - Copy to Clipboard', () => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + fixture = renderList([ + { provide: NotificationService, useValue: notificationsSpy }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ]); }); it('should copy key to clipboard and show success toast', async () => { @@ -121,25 +114,15 @@ describe('TranslationList - Copy to Clipboard', () => { describe('TranslationList - Loading and Error States', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: NotificationService, - useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }, - }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + beforeEach(() => { + fixture = renderList([ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ]); }); it('should display loading spinner when loading', () => { - const store = TestBed.inject(BrowserStore); + const store = fixture.debugElement.injector.get(BrowserStore); // Set up collection first store.setSelectedCollection({ @@ -161,8 +144,8 @@ describe('TranslationList - Loading and Error States', () => { }); it('should display error message when error occurs', async () => { - const store = TestBed.inject(BrowserStore); - const httpMock = TestBed.inject(HttpTestingController); + const store = fixture.debugElement.injector.get(BrowserStore); + const httpMock = fixture.debugElement.injector.get(HttpTestingController); fixture.componentRef.setInput('collectionName', 'test'); fixture.detectChanges(); @@ -210,8 +193,8 @@ describe('TranslationList - Loading and Error States', () => { }); it('should display "no translations found" empty state when folder is selected but empty', async () => { - const store = TestBed.inject(BrowserStore); - const httpMock = TestBed.inject(HttpTestingController); + const store = fixture.debugElement.injector.get(BrowserStore); + const httpMock = fixture.debugElement.injector.get(HttpTestingController); fixture.componentRef.setInput('collectionName', 'test'); fixture.detectChanges(); @@ -248,27 +231,17 @@ describe('TranslationList - Virtual Scrolling', () => { let component: TranslationList; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: NotificationService, - useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }, - }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + beforeEach(() => { + fixture = renderList([ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ]); component = fixture.componentInstance; }); it('should render translation items with virtual scroll', () => { - const httpMock = TestBed.inject(HttpTestingController); - const store = TestBed.inject(BrowserStore); + const httpMock = fixture.debugElement.injector.get(HttpTestingController); + const store = fixture.debugElement.injector.get(BrowserStore); fixture.componentRef.setInput('collectionName', 'test'); fixture.detectChanges(); @@ -346,20 +319,10 @@ describe('TranslationList - skippedLocales warning snackbar', () => { mockDialogRef = { afterClosed: vi.fn() }; mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; - // TranslationList imports MatDialogModule which provides MatDialog at module scope. - // overrideProvider ensures our mock supersedes the module-level MatDialog instance. - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - ], - }) - .overrideProvider(MatDialog, { useValue: mockDialog }) - .compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + fixture = renderList([ + { provide: NotificationService, useValue: notificationsSpy }, + { provide: MatDialog, useValue: mockDialog }, + ]); fixture.componentRef.setInput('collectionName', 'test-collection'); fixture.detectChanges(); }); @@ -385,7 +348,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { listStore.editTranslation(mockResource, 'test-collection'); await vi.advanceTimersByTimeAsync(2200); - const transloco = TestBed.inject(TranslocoService); + const transloco = fixture.debugElement.injector.get(TranslocoService); const expectedSkippedMessage = transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.SKIPPEDLOCALESX, { locales: 'fr, de', }); @@ -429,7 +392,7 @@ describe('TranslationList - skippedLocales warning snackbar', () => { }); it('should update the store cache when the edit result contains skippedLocales', () => { - const store = TestBed.inject(BrowserStore); + const store = fixture.debugElement.injector.get(BrowserStore); const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); const result: TranslationEditorResult = { @@ -465,24 +428,16 @@ describe('TranslationList - handleEdit key rewrite', () => { mockDialogRef = { afterClosed: vi.fn() }; mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - ], - }) - .overrideProvider(MatDialog, { useValue: mockDialog }) - .compileComponents(); - - fixture = TestBed.createComponent(TranslationList); + fixture = renderList([ + { provide: NotificationService, useValue: notificationsSpy }, + { provide: MatDialog, useValue: mockDialog }, + ]); fixture.componentRef.setInput('collectionName', 'test-collection'); fixture.detectChanges(); }); it('should rewrite key to the store key when calling updateTranslationInCache on edit success', () => { - const store = TestBed.inject(BrowserStore); + const store = fixture.debugElement.injector.get(BrowserStore); const updateCacheSpy = vi.spyOn(store, 'updateTranslationInCache'); // Activate search mode so the store key contains the full path ("buttons.save") @@ -528,22 +483,12 @@ describe('TranslationList - Locale Filtering', () => { let fixture: ComponentFixture; let store: InstanceType; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: NotificationService, - useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }, - }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); - store = TestBed.inject(BrowserStore); + beforeEach(() => { + fixture = renderList([ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + ]); + store = fixture.debugElement.injector.get(BrowserStore); store.setSelectedCollection({ collectionName: 'test', @@ -595,20 +540,12 @@ describe('TranslationList - deleteTranslation', () => { mockDialogRef = { afterClosed: vi.fn() }; mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - { provide: BrowserApiService, useValue: mockBrowserApi }, - ], - }) - .overrideProvider(MatDialog, { useValue: mockDialog }) - .compileComponents(); - - fixture = TestBed.createComponent(TranslationList); - store = TestBed.inject(BrowserStore); + fixture = renderList([ + { provide: NotificationService, useValue: notificationsSpy }, + { provide: MatDialog, useValue: mockDialog }, + { provide: BrowserApiService, useValue: mockBrowserApi }, + ]); + store = fixture.debugElement.injector.get(BrowserStore); store.setSelectedCollection({ collectionName: 'my-collection', locales: ['en', 'fr'] }); fixture.componentRef.setInput('collectionName', 'my-collection'); @@ -688,19 +625,12 @@ describe('TranslationList - handleTranslate', () => { deleteResource: vi.fn(), }; - await TestBed.configureTestingModule({ - imports: [TranslationList, getTranslocoTestingModule()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: NotificationService, useValue: notificationsSpy }, - { provide: MatDialog, useValue: { open: vi.fn() } }, - { provide: BrowserApiService, useValue: mockBrowserApi }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TranslationList); - store = TestBed.inject(BrowserStore); + fixture = renderList([ + { provide: NotificationService, useValue: notificationsSpy }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: BrowserApiService, useValue: mockBrowserApi }, + ]); + store = fixture.debugElement.injector.get(BrowserStore); store.setSelectedCollection({ collectionName: 'my-collection', locales: ['en', 'fr'] }); fixture.componentRef.setInput('collectionName', 'my-collection'); @@ -763,7 +693,7 @@ describe('TranslationList - handleTranslate', () => { const listStore = fixture.debugElement.injector.get(TranslationListStore); listStore.translateResource(mockResource, 'my-collection'); - const transloco = TestBed.inject(TranslocoService); + const transloco = fixture.debugElement.injector.get(TranslocoService); const expectedSkippedMessage = transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.SKIPPEDLOCALESX, { locales: 'fr, de', }); diff --git a/apps/tracker/src/app/browser/ui/comment-popover/comment-popover.component.spec.ts b/apps/tracker/src/app/browser/ui/comment-popover/comment-popover.component.spec.ts index 08c832a6..cba1ba4b 100644 --- a/apps/tracker/src/app/browser/ui/comment-popover/comment-popover.component.spec.ts +++ b/apps/tracker/src/app/browser/ui/comment-popover/comment-popover.component.spec.ts @@ -1,18 +1,19 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach } from 'vitest'; +import type { ComponentFixture } from '@angular/core/testing'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { CommentPopover } from './comment-popover.component'; describe('CommentPopover', () => { let component: CommentPopover; let fixture: ComponentFixture; + let spectator: Spectator; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CommentPopover], - }).compileComponents(); + const createComponent = createComponentFactory({ component: CommentPopover, detectChanges: false }); - fixture = TestBed.createComponent(CommentPopover); - component = fixture.componentInstance; + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); it('should create', () => { diff --git a/apps/tracker/src/app/browser/ui/indexing-overlay/indexing-overlay.component.spec.ts b/apps/tracker/src/app/browser/ui/indexing-overlay/indexing-overlay.component.spec.ts index 333dd9e7..34a21038 100644 --- a/apps/tracker/src/app/browser/ui/indexing-overlay/indexing-overlay.component.spec.ts +++ b/apps/tracker/src/app/browser/ui/indexing-overlay/indexing-overlay.component.spec.ts @@ -1,23 +1,28 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { By } from '@angular/platform-browser'; -import { IndexingOverlay } from './indexing-overlay.component'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatCardModule } from '@angular/material/card'; +import type { ComponentFixture } from '@angular/core/testing'; import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { By } from '@angular/platform-browser'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; +import { IndexingOverlay } from './indexing-overlay.component'; describe('IndexingOverlay', () => { let component: IndexingOverlay; let fixture: ComponentFixture; + let spectator: Spectator; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [IndexingOverlay, MatProgressSpinnerModule, MatCardModule, MatButtonModule, getTranslocoTestingModule()], - }).compileComponents(); + const createComponent = createComponentFactory({ + component: IndexingOverlay, + imports: [MatProgressSpinnerModule, MatCardModule, MatButtonModule, getTranslocoTestingModule()], + detectChanges: false, + }); - fixture = TestBed.createComponent(IndexingOverlay); - component = fixture.componentInstance; + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); it('should create', () => { diff --git a/apps/tracker/src/app/browser/ui/tag-list-popover/tag-list-popover.component.spec.ts b/apps/tracker/src/app/browser/ui/tag-list-popover/tag-list-popover.component.spec.ts index 9d656651..5248ba95 100644 --- a/apps/tracker/src/app/browser/ui/tag-list-popover/tag-list-popover.component.spec.ts +++ b/apps/tracker/src/app/browser/ui/tag-list-popover/tag-list-popover.component.spec.ts @@ -1,18 +1,19 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach } from 'vitest'; +import type { ComponentFixture } from '@angular/core/testing'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { TagListPopover } from './tag-list-popover.component'; describe('TagListPopover', () => { let component: TagListPopover; let fixture: ComponentFixture; + let spectator: Spectator; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TagListPopover], - }).compileComponents(); + const createComponent = createComponentFactory({ component: TagListPopover, detectChanges: false }); - fixture = TestBed.createComponent(TagListPopover); - component = fixture.componentInstance; + beforeEach(() => { + spectator = createComponent(); + fixture = spectator.fixture; + component = spectator.component; }); it('should create', () => { diff --git a/apps/tracker/src/app/collections/bundle-card/bundle-card.html b/apps/tracker/src/app/collections/bundle-card/bundle-card.html new file mode 100644 index 00000000..e985aac5 --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-card/bundle-card.html @@ -0,0 +1,201 @@ +
+ + +
+

{{ entry().name }}

+

+ {{ outputPath().prefix }}@if (outputPath().placeholder) {{{ outputPath().placeholder }}}{{ outputPath().suffix }} +

+
+ +
+ + + + + + +
+
+ + +
+
+ +
{{ TOKENS.BUNDLES.CARD.COLLECTIONSLABEL | transloco }}
+
+
    + @if (includesAllCollections()) { +
  • + + {{ TOKENS.BUNDLES.CARD.ALLCOLLECTIONSX | transloco: { count: collectionNames().length } }} +
  • + } @else { + @for (name of collectionNames(); track name) { +
  • + + {{ name }} +
  • + } + } +
+
+
+ +
+ +
{{ TOKENS.BUNDLES.CARD.TYPESLABEL | transloco }}
+
+ @if (typeDistFile(); as file) { + {{ file }} + } @else { + {{ TOKENS.BUNDLES.CARD.TYPESOFF | transloco }} + } +
+
+
+ + +@if (isRunning()) { +
+ + + {{ TOKENS.BUNDLES.RUN.GENERATING | transloco }} + @if (currentFileName(); as file) { + {{ TOKENS.BUNDLES.RUN.WRITING | transloco }} {{ file }} + } + @if (progress(); as p) { + @if (p.total > 0) { + {{ TOKENS.BUNDLES.RUN.LOCALESPROGRESSX | transloco: { current: p.current, total: p.total } }} + } + } + + +
+} @else if (isCompleted()) { +
+
+ + + + {{ TOKENS.BUNDLES.RUN.GENERATED | transloco }} + {{ TOKENS.BUNDLES.RUN.FILESX | transloco: { count: filesCount() } }} + {{ TOKENS.BUNDLES.RUN.KEYSPERLOCALEX | transloco: { count: keysPerLocale() } }} + @if (hasWarnings()) { + {{ TOKENS.BUNDLES.RUN.WARNINGSX | transloco: { count: warningsCount() } }} + } + + + +
+ + +
+
+ + @if (detailsOpen()) { +
+

{{ TOKENS.BUNDLES.RUN.FILESWRITTEN | transloco }}

+
    + @for (item of resultFiles(); track item.locale) { +
  • + {{ item.file }} + @if (item.keys !== undefined) { + {{ TOKENS.BUNDLES.RUN.KEYSX | transloco: { count: item.keys } }} + } +
  • + } + @if (result()?.typeDistFile; as typesFile) { +
  • + {{ typesFile }} + @if (result()?.typesKeysCount; as typesKeys) { + {{ TOKENS.BUNDLES.RUN.KEYSX | transloco: { count: typesKeys } }} + } +
  • + } +
+ + @if (hasWarnings()) { +

{{ TOKENS.BUNDLES.RUN.WARNINGSHEADING | transloco }}

+
    + @for (warning of result()?.warnings; track $index) { +
  • {{ warning }}
  • + } +
+ } +
+ } +
+} @else if (isFailed()) { +
+ + + {{ TOKENS.BUNDLES.RUN.FAILED | transloco }} + @if (run()?.error; as message) { + {{ message }} + } + + +
+} + + diff --git a/apps/tracker/src/app/collections/bundle-card/bundle-card.scss b/apps/tracker/src/app/collections/bundle-card/bundle-card.scss new file mode 100644 index 00000000..e8025fa9 --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-card/bundle-card.scss @@ -0,0 +1,647 @@ +// ============================================================================= +// Bundle card — compact card for the home page bundles column. +// Shares the collection card anatomy; --card-surface* come from the page host. +// ============================================================================= + +:host { + background: var(--card-surface, var(--color-background)); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + padding: var(--spacing-4); + position: relative; + transition: + transform var(--transition-normal), + box-shadow var(--transition-normal), + border-color var(--transition-normal), + background var(--transition-normal); +} + +:host(.bundle-card--linked) { + background: var(--card-surface-hover, color-mix(in srgb, var(--color-primary) 3%, var(--color-background))); + border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); + + .tile { + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 45%, transparent); + } +} + +// --------------------------------------------------------------------------- +// Header: tile, name + output path, menu +// --------------------------------------------------------------------------- +.card-header { + align-items: start; + display: grid; + gap: var(--spacing-3); + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.tile { + align-items: center; + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + border-radius: var(--border-radius-lg); + display: flex; + flex: none; + height: 38px; + justify-content: center; + transition: background var(--transition-normal), border-color var(--transition-normal); + width: 38px; + + mat-icon { + color: var(--color-primary); + font-size: 19px; + height: 19px; + width: 19px; + } +} + +.card-heading { + min-width: 0; + padding-top: 1px; +} + +.bundle-name { + color: var(--color-text-primary); + font-family: var(--font-family-body); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + letter-spacing: -0.01em; + line-height: var(--line-height-tight); + margin: 0; + overflow-wrap: anywhere; +} + +.output-path { + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: 1.45; + margin: var(--spacing-1) 0 0; + overflow-wrap: anywhere; + + .var { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + } +} + +.card-actions { + flex: none; + margin: calc(-1 * var(--spacing-2)) calc(-1 * var(--spacing-2)) 0 0; +} + +.icon-button { + align-items: center; + background: none; + border: 0; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + cursor: pointer; + display: grid; + height: 40px; + padding: 0; + place-items: center; + transition: color var(--transition-fast), background var(--transition-fast); + width: 40px; + + &:hover { + background: var(--color-background-subtle); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + mat-icon { + font-size: 20px; + height: 20px; + width: 20px; + } +} + +// --------------------------------------------------------------------------- +// Meta rows: Collections chips, Types path +// --------------------------------------------------------------------------- +.meta { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + margin: var(--spacing-3) 0 0; +} + +.meta-row { + align-items: center; + display: flex; + gap: var(--spacing-2); + min-width: 0; +} + +.meta-icon { + color: var(--color-text-tertiary); + flex: none; + font-size: 16px; + height: 16px; + width: 16px; +} + +.meta-label { + color: var(--color-text-secondary); + flex: none; + font-size: var(--font-size-xs); + margin: 0; + width: 66px; +} + +.meta-value { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + margin: 0; + min-width: 0; + + .mono { + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + overflow-wrap: anywhere; + } + + .muted { + color: var(--color-text-secondary); + } +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2) var(--spacing-1); + list-style: none; + margin: 0; + min-width: 0; + padding: 0; +} + +// Text chip in its "soft" voice: Nunito, subtle ground, hairline. Coral text-weight when linked. +.chip { + align-items: center; + background: var(--color-background-subtle); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-full); + color: var(--color-text-primary); + display: inline-flex; + font-family: var(--font-family-body); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + gap: 5px; + line-height: 1.5; + padding: 1px var(--spacing-2) 1px 6px; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + white-space: nowrap; + + mat-icon { + color: var(--color-text-secondary); + flex: none; + font-size: 14px; + height: 14px; + width: 14px; + } + + &--linked { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border)); + color: var(--color-primary-text); + + mat-icon { + color: var(--color-primary-text); + } + } +} + +// --------------------------------------------------------------------------- +// Result strips +// --------------------------------------------------------------------------- +.result { + border-radius: var(--border-radius-md); + font-size: var(--font-size-xs); + line-height: 1.45; + margin-top: var(--spacing-4); + overflow: hidden; + position: relative; + + mat-icon { + flex: none; + font-size: 16px; + height: 16px; + width: 16px; + } + + .mono { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + } + + // Clauses carry their own leading separator and never break internally. A wrapped + // line therefore starts with "· 3 warnings" instead of stranding a bare "·" at the + // end of the line above it, which is what a run of inline separators does. + .fig { + white-space: nowrap; + + & + .fig::before { + content: '·'; + margin: 0 5px; + opacity: 0.6; + } + } + + // A failure message is prose, not a figure, so it may break like prose. + .fig--prose { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; + } + + b { + font-weight: var(--font-weight-bold); + } +} + +// A flex row, not an inline run: Angular strips the whitespace between the clause +// spans, so inline layout has no break opportunity between them and the sentence +// runs straight under the controls instead of wrapping. +.result-text { + align-items: baseline; + display: flex; + flex: 1; + flex-wrap: wrap; + min-width: 0; + row-gap: 2px; +} + +.result-line, +.result--run, +.result--failed { + align-items: flex-start; + display: flex; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3); +} + +// The glyph and the controls sit on the first line's optical centre; only the copy +// grows downwards. Centring them instead leaves both drifting whenever a locale wraps. +.result-line > mat-icon, +.result--run > mat-icon, +.result--failed > mat-icon, +.result-controls { + margin-top: 1px; +} + +.result-controls { + align-items: center; + display: flex; + flex: none; + gap: var(--spacing-1); +} + +.result--run { + background: var(--color-background-subtle); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + padding-bottom: calc(var(--spacing-2) + 2px); + + .mono { + color: var(--color-text-primary); + } +} + +.bar { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + bottom: 0; + height: 2px; + left: 0; + position: absolute; + right: 0; + + i { + background: var(--color-primary); + border-radius: 0 2px 2px 0; + display: block; + height: 100%; + transition: width var(--transition-slow); + } +} + +.result--ok { + background: color-mix(in srgb, var(--color-success) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); + color: color-mix(in srgb, var(--color-success) 55%, var(--color-text-primary)); +} + +.result--warn { + background: color-mix(in srgb, var(--color-warning) 9%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 26%, transparent); + color: var(--color-warning-text); +} + +.result--failed { + background: color-mix(in srgb, var(--color-error) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); + color: color-mix(in srgb, var(--color-error) 55%, var(--color-text-primary)); +} + +.details-toggle { + align-items: center; + background: none; + border: 0; + border-radius: var(--border-radius-sm); + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + font-weight: var(--font-weight-semibold); + gap: 2px; + padding: 0 2px; + white-space: nowrap; + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + .chevron { + transition: transform var(--transition-normal); + } + + .result--open & .chevron { + transform: rotate(180deg); + } +} + +// Dismiss is the strip's own chrome, so it reads quieter than Details and sits +// outermost, against the edge it removes. +.dismiss { + align-items: center; + background: none; + border: 0; + border-radius: var(--border-radius-sm); + color: inherit; + cursor: pointer; + display: inline-flex; + justify-content: center; + height: 20px; + opacity: 0.65; + padding: 0; + transition: opacity var(--transition-fast); + width: 20px; + + &:hover { + opacity: 1; + } + + &:focus-visible { + opacity: 1; + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + mat-icon { + font-size: 15px; + height: 15px; + width: 15px; + } +} + +.result-details { + animation: details-reveal var(--transition-normal) both; + border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent); + color: var(--color-text-secondary); + display: flex; + flex-direction: column; + gap: var(--spacing-2); + padding: var(--spacing-2) var(--spacing-3) var(--spacing-3); +} + +@keyframes details-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +.details-heading { + color: var(--color-text-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + margin: 0; +} + +.details-list { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; + + li { + align-items: baseline; + display: flex; + gap: var(--spacing-3); + justify-content: space-between; + min-width: 0; + } + + .mono { + color: var(--color-text-primary); + min-width: 0; + overflow-wrap: anywhere; + } + + .num { + flex: none; + } + + // Generator warnings are full paragraphs of prose. The card is a summary + // surface, so show the opening lines of each (full text on hover) and leave + // the rest to the CLI rather than letting one warning run the whole column. + &--warnings li { + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + color: var(--color-warning-text); + display: -webkit-box; + line-height: var(--line-height-snug); + list-style: none; + max-width: 62ch; + overflow: hidden; + overflow-wrap: anywhere; + + // Marker kept as inline content so it stays inside the clamped box. + &::before { + content: '•'; + margin-right: var(--spacing-2); + } + } +} + +// --------------------------------------------------------------------------- +// Footer: locale count + Generate +// --------------------------------------------------------------------------- +.card-footer { + align-items: center; + border-top: 1px solid var(--color-border-subtle); + display: flex; + gap: var(--spacing-3); + justify-content: space-between; + margin-top: var(--spacing-4); + padding-top: var(--spacing-3); +} + +.footer-meta { + align-items: center; + color: var(--color-text-secondary); + display: flex; + font-size: var(--font-size-xs); + gap: var(--spacing-3); + min-width: 0; + + > span { + align-items: center; + display: inline-flex; + gap: 4px; + white-space: nowrap; + } + + mat-icon { + color: var(--color-text-tertiary); + font-size: 15px; + height: 15px; + width: 15px; + } +} + +.num { + font-variant-numeric: tabular-nums; +} + +// Hand-rolled outline button (the comp's .btn.outline.sm), a real + + +
+ + + + +
+ @switch (activeKind()) { + + @case ('output') { +
+
+

{{ TOKENS.BUNDLES.DIALOG.SECTIONS.OUTPUT | transloco }}

+

{{ TOKENS.BUNDLES.DIALOG.OUTPUT.DESCRIPTION | transloco }}

+
+
+ +
+ + @if (isEditMode) { +
+ + {{ form.controls.name.value }} +
+

+ } @else { +
+ +
+ @if (showError(form.controls.name, 'required')) { + + } @else if (showError(form.controls.name, 'pattern')) { + + } @else if (showError(form.controls.name, 'nameExists')) { + + } + } +
+ +
+
+ +
+ + +
+ @if (showError(form.controls.dist)) { + + } +
+ +
+ +
+ + +
+ @if (showError(form.controls.bundleName, 'required')) { + + } @else if (showError(form.controls.bundleName, 'missingLocale')) { + + } +
+
+ + @if (patternFiles().length > 0) { +

+ + +

+ } + } + + + @case ('collections') { +
+
+

{{ TOKENS.BUNDLES.DIALOG.SECTIONS.COLLECTIONS | transloco }}

+

{{ TOKENS.BUNDLES.DIALOG.COLLECTIONS.DESCRIPTION | transloco }}

+
+
+ +
+ +
+ {{ TOKENS.BUNDLES.DIALOG.COLLECTIONS.INCLUDEALLLABEL | transloco }} + + {{ TOKENS.BUNDLES.DIALOG.COLLECTIONS.INCLUDEALLHINTX | transloco: { count: allCollectionNames().length } }} + +
+ +
+ + @if (!form.controls.allCollections.value) { +
    + @for (group of form.controls.collections.controls; track group; let index = $index) { +
  • + +
  • + } +
  • + + + @for (name of availableCollections(); track name) { + + } + +
  • +
+ @if (showError(form.controls.collections, 'collectionsEmpty')) { + + } + } + } + + + @case ('collection') { + @if (activeCollectionGroup(); as group) { +
+
+
+

{{ group.controls.name.value }}

+

{{ TOKENS.BUNDLES.DIALOG.COLLECTION.DESCRIPTION | transloco }}

+
+ +
+ +
+
+ +
+ +
+
+
+ {{ TOKENS.BUNDLES.DIALOG.COLLECTION.CONFLICTSLABEL | transloco }} + +
+
+ +
+ {{ TOKENS.BUNDLES.DIALOG.COLLECTION.ENTRIESLABEL | transloco }} + +
+ + @if (!group.controls.allEntries.value) { +
+ + {{ TOKENS.BUNDLES.DIALOG.COLLECTION.RULESLABEL | transloco }} + {{ TOKENS.BUNDLES.DIALOG.COLLECTION.RULESHINT | transloco }} + + + @if (group.controls.rules.length > 0) { + + } + +
+ @for (rule of group.controls.rules.controls; track rule; let ruleIndex = $index) { +
+
+ +
+ +
+ @for (tag of rule.controls.matchingTags.value; track tag; let tagIndex = $index) { + @if (tagIndex > 0) { + + } + + {{ tag }} + + + } + @if (rule.controls.matchingTags.value.length === 0) { + + } + +
+ + +
+ } +
+ + + + @if (showError(group.controls.rules, 'rulesEmpty')) { + + } @else if (submitAttempted() && group.controls.rules.invalid) { + + } +
+ } +
+ } + } + + + @case ('types') { +
+
+

{{ TOKENS.BUNDLES.DIALOG.SECTIONS.TYPES | transloco }}

+

{{ TOKENS.BUNDLES.DIALOG.TYPES.DESCRIPTION | transloco }}

+
+
+ +
+ +
+ {{ TOKENS.BUNDLES.DIALOG.TYPES.SWITCHLABEL | transloco }} + +
+ +
+ + @if (form.controls.typesEnabled.value) { +
+
+ +
+ + +
+ @if (showError(form.controls.typeDistFile, 'required')) { + + } @else if (showError(form.controls.typeDistFile, 'notTypeScript')) { + + } +
+ +
+
+ {{ TOKENS.BUNDLES.DIALOG.TYPES.CASINGLABEL | transloco }} + + @if (form.controls.tokenCasing.value === 'inherit') { +

{{ TOKENS.BUNDLES.DIALOG.INHERITHINT | transloco }}

+ } @else { +

+ +

+ } +
+
+ +
+ +
+ @if (showError(form.controls.tokenConstantName, 'invalidIdentifier')) { + + } @else { +

{{ TOKENS.BUNDLES.DIALOG.TYPES.CONSTANTHINT | transloco }}

+ } +
+
+
+ } + } + + + @case ('options') { +
+
+

{{ TOKENS.BUNDLES.DIALOG.SECTIONS.OPTIONS | transloco }}

+

{{ TOKENS.BUNDLES.DIALOG.OPTIONS.DESCRIPTION | transloco }}

+
+
+ +
+ +
+ {{ TOKENS.BUNDLES.DIALOG.OPTIONS.ICULABEL | transloco }} + +
+ +
+ @if (form.controls.transformICUToTransloco.value === 'inherit') { +

+ + {{ TOKENS.BUNDLES.DIALOG.INHERITHINT | transloco }} +

+ } @else { +

+ + + {{ TOKENS.BUNDLES.DIALOG.OVERRIDEHINTX | transloco: { value: (projectIcuTransform() ? TOKENS.BUNDLES.DIALOG.ON : TOKENS.BUNDLES.DIALOG.OFF) | transloco } }} + +

+ } + } + } + + +
+ + @if (previewOpen()) { +
+ +
+ } +
+
+ + + +
+ + +
+ + + + +
+
+ + + +
+

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.WILLWRITE | transloco }}

+ @if (previewTree().length === 0) { +

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.WAITING | transloco }}

+ } @else { +
    + @for (folder of previewTree(); track folder.path) { +
  • + + + + @for (part of folder.pathParts; track $index) { + {{ part }} + } + / + + +
      + @for (file of folder.files; track file.name) { +
    • + + + @for (part of file.nameParts; track $index) { + {{ part }} + } + + @if (file.exists === false) { + {{ TOKENS.BUNDLES.DIALOG.PREVIEW.NEW | transloco }} + } +
    • + } +
    +
  • + } +
+ } +
+ +
+

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.DRYRUN | transloco }}

+ @if (previewStatus() === 'error') { +

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.COUNTSUNAVAILABLE | transloco }}

+ } @else if (dryRun(); as result) { +
+
{{ TOKENS.BUNDLES.DIALOG.SECTIONS.COLLECTIONS | transloco }}
+
{{ collectionCount() }}
+
{{ TOKENS.BUNDLES.DIALOG.PREVIEW.KEYSPERLOCALE | transloco }}
+
≈ {{ keysPerLocale() }}
+
{{ TOKENS.BUNDLES.DIALOG.PREVIEW.CONFLICTS | transloco }}
+
+ {{ result.conflictsCount }} + @if (result.conflictsCount > 0 && hasOverride()) { + · {{ TOKENS.BUNDLES.DIALOG.PREVIEW.RESOLVEDBYOVERRIDE | transloco }} + } +
+
{{ TOKENS.BUNDLES.DIALOG.PREVIEW.LOCALES | transloco }}
+
{{ result.locales.length }}
+
+ @if (hierarchicalConflicts().length > 0) { + + } +

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.CAPTION | transloco }}

+ } @else { + + } +
+ + @if (dryRun(); as result) { + @if (previewStatus() !== 'error') { +
+

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.EXAMPLEKEY | transloco }}

+ @if (result.exampleKey; as example) { +
+
+ {{ example.sourceKey }} + {{ TOKENS.BUNDLES.DIALOG.PREVIEW.EXAMPLEFROMX | transloco: { collection: example.collectionName } }} +
+
+ + {{ example.bundledKey }} + @if (example.bundledKey === example.sourceKey) { + {{ TOKENS.BUNDLES.DIALOG.PREVIEW.EXAMPLEUNCHANGED | transloco }} + } +
+ @if (example.tokenPath) { +
+ + + @for (part of tokenPathParts(); track $index) { + {{ part }} + } + +
+ } +
+ } @else { +

{{ TOKENS.BUNDLES.DIALOG.PREVIEW.EXAMPLENOEXAMPLE | transloco }}

+ } +
+ } + } +
diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.scss b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.scss new file mode 100644 index 00000000..4356b58e --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.scss @@ -0,0 +1,1281 @@ +// The outer box (width, top rule, zero container padding) is owned by +// `.bundle-form-dialog-panel` in styles.scss, because the CDK appends the +// overlay pane outside this component. Everything here is the inside. +:host { + display: block; + max-height: inherit; + // Same surface pairing as the collection dialog: the panel is one step lighter + // than the page so the wells read as set into it; the rail and preview column + // sit one step back again so the pane is the lit part of the room. + --dialog-surface: var(--color-background); + --dialog-well: var(--color-background); + --dialog-aside: var(--color-background-subtle); + --dialog-active: var(--color-background); +} + +:host-context([data-theme='dark']) { + --dialog-surface: var(--color-background-subtle); + --dialog-aside: var(--color-background); + --dialog-active: var(--color-background-muted); +} + +@media (prefers-color-scheme: dark) { + :host-context(:root:not([data-theme='light']):not([data-theme='dark'])) { + --dialog-surface: var(--color-background-subtle); + --dialog-aside: var(--color-background); + --dialog-active: var(--color-background-muted); + } +} + +.dialog-wrapper { + display: flex; + flex-direction: column; + max-height: inherit; + background: var(--dialog-surface); +} + +code { + font-family: var(--font-family-mono); + font-size: 0.95em; + color: var(--color-text-primary); +} + +.num { + font-variant-numeric: tabular-nums; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Header (inherited from the collection dialog) +// ───────────────────────────────────────────────────────────────────────────── + +.dialog-header { + position: relative; + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + padding: var(--spacing-5) var(--spacing-6) var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.dialog-header-tile { + display: grid; + place-items: center; + flex: none; + width: 38px; + height: 38px; + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + color: var(--color-primary); + + mat-icon { + font-size: 19px; + width: 19px; + height: 19px; + } +} + +.dialog-header-text { + flex: 1; + min-width: 0; + padding-right: var(--spacing-8); +} + +.dialog-title { + margin: 0 0 var(--spacing-1); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight); + letter-spacing: -0.01em; + color: var(--color-text-primary); +} + +.dialog-subtitle { + margin: 0; + font-size: var(--font-size-sm); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); +} + +.dialog-close { + position: absolute; + top: var(--spacing-3); + right: var(--spacing-3); + color: var(--color-text-secondary); + transition: color var(--transition-fast); + + &:hover { + color: var(--color-text-primary); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Master-detail body: rail / pane / preview +// ───────────────────────────────────────────────────────────────────────────── + +.md { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 240px minmax(0, 1fr) 320px; + grid-template-rows: minmax(0, 1fr); + // The comp's 520px body; tall enough that switching panes never resizes the dialog. + min-height: 520px; + margin: 0; +} + +.rail, +.pane, +.prev { + min-height: 0; + overflow-y: auto; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rail +// ───────────────────────────────────────────────────────────────────────────── + +.rail { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-3) 10px var(--spacing-3) var(--spacing-3); + background: var(--dialog-aside); + border-right: 1px solid var(--color-border); +} + +.rail-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: var(--spacing-2) 10px; + border: 0; + border-radius: var(--border-radius-md); + background: transparent; + color: var(--color-text-primary); + font: inherit; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + text-align: left; + cursor: pointer; + transition: + background var(--transition-fast), + box-shadow var(--transition-fast), + color var(--transition-fast); + + > mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); + transition: color var(--transition-fast); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--dialog-active) 60%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } + + &--on { + background: var(--dialog-active); + box-shadow: var(--shadow-sm); + + > mat-icon { + color: var(--color-primary); + } + } + + &--child { + padding-left: var(--spacing-6); + font-weight: var(--font-weight-medium); + + > mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.rail-item--on { + font-weight: var(--font-weight-bold); + } + } + + &--add { + padding-left: var(--spacing-6); + color: var(--color-text-secondary); + + > mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-secondary); + } + + &:disabled { + cursor: default; + opacity: 0.6; + } + } +} + +.rail-stack { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.rail-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rail-sub { + display: block; + margin-top: 1px; + font-family: var(--font-family-mono); + font-size: 11px; + font-weight: var(--font-weight-normal); + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 170px; + + &--prose { + font-family: inherit; + } + + // Placeholder text: present so the row height never changes, but clearly not a value yet. + &--empty { + color: var(--color-text-tertiary); + } +} + +.rail-count { + flex: none; + margin-left: auto; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.rail-dot { + flex: none; + width: 6px; + height: 6px; + margin-left: auto; + border-radius: var(--border-radius-full); + background: var(--color-error); +} + +.rail-rule { + height: 1px; + margin: var(--spacing-2) 6px; + background: var(--color-border); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Pane +// ───────────────────────────────────────────────────────────────────────────── + +.pane { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-5) var(--spacing-6) var(--spacing-3); +} + +.pane-section { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.pane-head { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); +} + +.pane-head-text { + flex: 1; + min-width: 0; + + h3 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + letter-spacing: -0.01em; + color: var(--color-text-primary); + overflow-wrap: anywhere; + } + + p { + margin: 2px 0 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); + } +} + +.pane-head-action { + flex: none; + height: 32px; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-3); +} + +.row-3-2 { + display: grid; + grid-template-columns: 3fr 2fr; + gap: var(--spacing-3); +} + +// Types sub-fields hang off the switch row with a left hairline, like the comp. +.sub-fields { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + margin-left: 14px; + padding-left: 14px; + border-left: 1px solid var(--color-border); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Fields (inherited from the collection dialog) +// ───────────────────────────────────────────────────────────────────────────── + +.field { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + min-width: 0; +} + +.field-label { + display: flex; + align-items: baseline; + gap: 6px; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + color: var(--color-text-primary); +} + +.field-label-opt { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.text-input { + display: flex; + align-items: center; + gap: var(--spacing-2); + height: 40px; + padding: 0 var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + color: var(--color-text-primary); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + + > mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); + } + + input { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--font-size-base); + outline: none; + + &::placeholder { + color: var(--color-text-placeholder); + } + } + + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); + } + + &--mono input { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + } + + &--compact { + height: 36px; + } + + &--invalid, + &--invalid:focus-within { + border-color: var(--color-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent); + } + + &--locked { + border-style: dashed; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-size-base); + } +} + +// The `.json` tail sits inside the pattern well so the user types only the stem. +.text-input-trail { + flex: none; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.field-hint, +.field-error { + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); +} + +.field-hint { + color: var(--color-text-secondary); + + &--icon { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + margin-top: 2px; + color: var(--color-text-tertiary); + } + } +} + +.field-error { + color: var(--color-error); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Switch rows (coral tile variant of the collection dialog's) +// ───────────────────────────────────────────────────────────────────────────── + +.switch-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: var(--color-background-subtle); + transition: + border-color var(--transition-normal), + background var(--transition-normal); + + &--on { + border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border)); + background: color-mix(in srgb, var(--color-primary) 5%, var(--color-background-subtle)); + } +} + +.switch-tile { + display: grid; + place-items: center; + flex: none; + width: 30px; + height: 30px; + border-radius: var(--border-radius-md); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +.switch-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.switch-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + color: var(--color-text-primary); +} + +.switch-description { + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); +} + +.switch-control { + flex: none; + margin-top: 2px; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Collections summary list +// ───────────────────────────────────────────────────────────────────────────── + +.coll-list { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + margin: 0; + padding: 0; + list-style: none; +} + +.coll-row { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: var(--dialog-well); + overflow: hidden; + + &--add { + border-style: dashed; + border-color: var(--color-border-strong); + background: none; + + .coll-row-button { + color: var(--color-text-secondary); + } + + .coll-row-lead { + color: var(--color-text-secondary); + } + } +} + +.coll-row-button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px var(--spacing-3) 10px 14px; + border: 0; + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-background-subtle) 60%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } + + &:disabled { + cursor: default; + opacity: 0.7; + } +} + +.coll-row-lead { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} + +.coll-row-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.coll-row-summary { + margin-left: auto; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.coll-row-chevron { + flex: none; + color: var(--color-text-tertiary); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Rules table +// ───────────────────────────────────────────────────────────────────────────── + +.rules-head, +.rule { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(160px, 1fr) 40px; + gap: var(--spacing-2); + align-items: center; +} + +.rules-head { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); +} + +.rules { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.rule-remove { + color: var(--color-text-tertiary); + + &:hover { + color: var(--color-error); + } +} + +.rule-add { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-2); + margin-left: calc(-1 * var(--spacing-2)); + border: 0; + border-radius: var(--border-radius-md); + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + cursor: pointer; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: var(--color-text-primary); + background: var(--color-background-subtle); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } +} + +// Tags well: chips with the any/all operator between them, and an inline input. +.tags-well { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-1); + min-height: 36px; + padding: 3px var(--spacing-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); + } + + &--empty { + border-style: dashed; + } +} + +.tags-well-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); +} + +.tags-input { + flex: 1 1 60px; + min-width: 60px; + height: 26px; + border: 0; + padding: 0 var(--spacing-1); + background: transparent; + color: var(--color-text-primary); + font: inherit; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + outline: none; + + &::placeholder { + font-family: var(--font-family-body); + color: var(--color-text-placeholder); + } +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0 2px 0 var(--spacing-2); + border-radius: var(--border-radius-full); + border: 1px solid var(--color-border); + background: var(--color-background-subtle); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + line-height: 1.6; +} + +.tag-chip-remove { + display: grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: var(--border-radius-full); + background: transparent; + color: var(--color-text-tertiary); + cursor: pointer; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:hover { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } +} + +.tag-operator { + padding: 0 2px; + border: 0; + border-radius: var(--border-radius-sm); + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + cursor: pointer; + + &:hover { + color: var(--color-secondary-active); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Preview (column and disclosure share every block style) +// ───────────────────────────────────────────────────────────────────────────── + +.prev.col { + display: flex; + flex-direction: column; + gap: var(--spacing-5); + padding: var(--spacing-5) var(--spacing-5) var(--spacing-5); + background: var(--dialog-aside); + border-left: 1px solid var(--color-border); +} + +.preview { + transition: opacity var(--transition-normal); + + &--stale { + opacity: 0.6; + } +} + +.preview-block { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + + h4 { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); + } + } +} + +.preview-note, +.preview-caption { + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); + color: var(--color-text-secondary); +} + +// Hierarchical key collisions make generation throw, so the preview states it +// in the error tint rather than letting the dry run read as a clean pass. +.preview-error { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); + color: var(--color-error); + + mat-icon { + flex: none; + width: 14px; + height: 14px; + font-size: 14px; + line-height: 14px; + } + + span { + overflow-wrap: anywhere; + } +} + +.tree, +.tree ul { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-primary); +} + +.tree-folder { + display: flex; + flex-direction: column; + gap: 3px; +} + +.tree-dir { + display: flex; + align-items: center; + gap: 5px; + color: var(--color-text-secondary); + overflow-wrap: break-word; + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + color: var(--color-text-tertiary); + } +} + +.tree-dir-path { + min-width: 0; + overflow-wrap: break-word; +} + +.tree-file { + display: flex; + align-items: center; + gap: 6px; + padding-left: 21px; + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + color: var(--color-text-tertiary); + } +} + +.tree-file-name { + min-width: 0; + overflow-wrap: break-word; +} + +.tree-new { + padding: 0 6px; + border-radius: var(--border-radius-full); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary-text); + font-family: var(--font-family-body); + font-size: 10px; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.02em; +} + +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px var(--spacing-3); + margin: 0; + font-size: var(--font-size-xs); + + dt { + margin: 0; + color: var(--color-text-secondary); + } + + dd { + margin: 0; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } +} + +.kv--skeleton dd { + color: var(--color-text-tertiary); +} + +.example { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--spacing-2) 10px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); +} + +.example-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 6px; + color: var(--color-text-secondary); + + b { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + // Long token paths break at the hints between segments, never mid-identifier. + overflow-wrap: break-word; + } + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + color: var(--color-text-tertiary); + } + + // The note wraps under the key, indented past the glyph. + > span { + flex-basis: 100%; + padding-left: 20px; + } + + &:first-child > span { + padding-left: 0; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Disclosure (collection dialog recipe); only shown below 1200px +// ───────────────────────────────────────────────────────────────────────────── + +.disclosure { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + overflow: hidden; +} + +.preview-disclosure { + display: none; + margin-top: auto; +} + +.disclosure-toggle { + display: flex; + align-items: center; + gap: var(--spacing-2); + width: 100%; + padding: var(--spacing-2) var(--spacing-3) var(--spacing-2) var(--spacing-4); + border: 0; + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { + background: var(--color-background-subtle); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } +} + +.disclosure-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} + +.disclosure-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: 1.5; +} + +.disclosure-summary { + margin-left: auto; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.disclosure-chevron { + flex: none; + color: var(--color-text-tertiary); + transition: transform var(--transition-normal); + + .disclosure--open & { + transform: rotate(180deg); + } +} + +.disclosure-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-3) var(--spacing-4) var(--spacing-4); + border-top: 1px solid var(--color-border-subtle); + animation: disclosure-reveal var(--transition-normal) both; +} + +@keyframes disclosure-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Footer +// ───────────────────────────────────────────────────────────────────────────── + +.dialog-footer { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-4) var(--spacing-6) var(--spacing-5); + border-top: 1px solid var(--color-border); +} + +.footer-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + color: var(--color-success); + } + + // The affirmative glyph is earned: while the form is invalid it stays a quiet outline. + .footer-meta-icon--idle { + color: var(--color-text-tertiary); + } +} + +.footer-spacer { + flex: 1; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Responsive +// ───────────────────────────────────────────────────────────────────────────── + +// Under 1200px the preview column goes and its blocks reappear in the disclosure +// at the bottom of the pane. +@media (max-width: 1199px) { + .md { + grid-template-columns: 240px minmax(0, 1fr); + } + + .prev.col { + display: none; + } + + .preview-disclosure { + display: block; + } +} + +// Under 800px the rail becomes a horizontal strip of sections above the pane. +@media (max-width: 799px) { + .md { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + } + + .rail { + flex-direction: row; + align-items: stretch; + gap: var(--spacing-1); + flex-wrap: wrap; + row-gap: var(--spacing-1); + padding: var(--spacing-2) var(--spacing-3); + border-right: 0; + border-bottom: 1px solid var(--color-border); + } + + .rail-item { + width: auto; + flex: none; + + &--child, + &--add { + padding-left: 10px; + } + } + + .rail-stack { + flex: none; + } + + .rail-sub { + max-width: 120px; + } + + .rail-count, + .rail-dot { + margin-left: 0; + } + + .rail-rule { + flex-basis: 100%; + width: auto; + height: 1px; + margin: 4px 0; + } + + .row-2, + .row-3-2 { + grid-template-columns: minmax(0, 1fr); + } + + .rules-head { + display: none; + } + + .rule { + grid-template-columns: minmax(0, 1fr) 40px; + + .tags-well { + grid-column: 1 / -1; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .disclosure-panel { + animation: none; + } + + .disclosure-chevron, + .preview, + .rail-item, + .switch-row { + transition: none; + } +} diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.spec.ts b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.spec.ts new file mode 100644 index 00000000..9948efe5 --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.spec.ts @@ -0,0 +1,505 @@ +import { signal } from '@angular/core'; +import type { ComponentFixture } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory } from '@ngneat/spectator/vitest'; +import type { BundleDefinitionDto, BundleDryRunResultDto, LingoTrackerConfigDto } from '@simoncodes-ca/data-transfer'; +import { of, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; +import { CollectionsApiService } from '../services/collections-api.service'; +import { CollectionsStore } from '../store/collections.store'; +import { BundleFormDialog } from './bundle-form-dialog'; +import type { BundleFormDialogData } from './bundle-form-dialog-data'; + +const config: LingoTrackerConfigDto = { + exportFolder: './export', + importFolder: './import', + baseLocale: 'en', + locales: ['en', 'fr-ca', 'es'], + collections: { + trackerResources: { translationsFolder: './apps/tracker/src/i18n' }, + mockDesignSystem: { translationsFolder: './libs/ds/i18n' }, + TestDataPlayground: { translationsFolder: './tmp/i18n' }, + }, +}; + +const trackerBundle: BundleDefinitionDto = { + bundleName: '{locale}', + dist: './apps/tracker/src/assets/i18n', + collections: [{ name: 'trackerResources', entriesSelectionRules: 'All' }], + typeDistFile: './apps/tracker/src/i18n-types/tracker-resources.ts', + transformICUToTransloco: true, +}; + +const dryRunResult: BundleDryRunResultDto = { + name: 'admin', + locales: ['en', 'fr-ca', 'es'], + files: [ + { path: 'dist/i18n/admin.en.json', kind: 'bundle', locale: 'en', exists: false, keysCount: 12 }, + { path: 'dist/i18n/admin.fr-ca.json', kind: 'bundle', locale: 'fr-ca', exists: false, keysCount: 12 }, + { path: 'dist/i18n/admin.es.json', kind: 'bundle', locale: 'es', exists: true, keysCount: 12 }, + ], + keysPerLocale: { en: 12, 'fr-ca': 12, es: 12 }, + conflictsCount: 0, + conflictKeys: [], + hierarchicalConflicts: [], + exampleKey: { collectionName: 'trackerResources', sourceKey: 'a.b', bundledKey: 'a.b' }, + warnings: [], +}; + +interface Harness { + fixture: ComponentFixture; + component: BundleFormDialog; + dialogRef: { close: ReturnType }; + api: { dryRunBundle: ReturnType }; +} + +const createComponent = createComponentFactory({ + component: BundleFormDialog, + imports: [NoopAnimationsModule, getTranslocoTestingModule()], + detectChanges: false, +}); + +const buildHarness = (data: BundleFormDialogData): Harness => { + const dialogRef = { close: vi.fn() }; + const api = { dryRunBundle: vi.fn().mockReturnValue(of(dryRunResult)) }; + const store = { + config: signal(config), + collectionEntries: signal( + Object.entries(config.collections).map(([name, collection]) => ({ name, config: collection })), + ), + bundleEntries: signal([{ name: 'tracker', definition: trackerBundle }]), + }; + + const spectator = createComponent({ + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: CollectionsApiService, useValue: api }, + { provide: CollectionsStore, useValue: store }, + ], + }); + spectator.detectChanges(); + const fixture = spectator.fixture; + return { fixture, component: fixture.componentInstance, dialogRef, api }; +}; + +const fillOutput = (component: BundleFormDialog): void => { + component.form.controls.name.setValue('admin'); + component.form.controls.dist.setValue('./dist/i18n'); + component.form.controls.bundleName.setValue('admin.{locale}'); +}; + +describe('BundleFormDialog — create mode', () => { + let harness: Harness; + let component: BundleFormDialog; + + beforeEach(async () => { + harness = buildHarness({ mode: 'create' }); + component = harness.component; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.isEditMode).toBe(false); + }); + + it('should start with the first collection pane open, seeded with the first collection', () => { + expect(component.form.controls.collections.length).toBe(1); + expect(component.form.controls.collections.at(0).controls.name.value).toBe('trackerResources'); + expect(component.activeSection()).toBe('coll:0'); + expect(component.activeKind()).toBe('collection'); + }); + + it('should keep the name editable', () => { + expect(component.form.controls.name.disabled).toBe(false); + }); + + it('should reject a name that collides with an existing bundle', () => { + component.form.controls.name.setValue('tracker'); + expect(component.form.controls.name.hasError('nameExists')).toBe(true); + + component.form.controls.name.setValue('admin'); + expect(component.form.controls.name.errors).toBeNull(); + }); + + it('should reject names outside the letters, numbers, hyphens and underscores charset', () => { + component.form.controls.name.setValue('my bundle!'); + expect(component.form.controls.name.hasError('pattern')).toBe(true); + }); + + it('should require {locale} in the file name pattern', () => { + component.form.controls.bundleName.setValue('admin'); + expect(component.form.controls.bundleName.hasError('missingLocale')).toBe(true); + + component.form.controls.bundleName.setValue('admin.{locale}'); + expect(component.form.controls.bundleName.errors).toBeNull(); + }); + + it('should require a .ts type file only while types are on', () => { + const typeFile = component.form.controls.typeDistFile; + expect(typeFile.errors).toBeNull(); + + component.form.controls.typesEnabled.setValue(true); + expect(typeFile.hasError('required')).toBe(true); + + typeFile.setValue('./dist/types/admin.js'); + expect(typeFile.hasError('notTypeScript')).toBe(true); + + typeFile.setValue('./dist/types/admin.ts'); + expect(typeFile.errors).toBeNull(); + + typeFile.setValue('./dist/types/admin.js'); + component.form.controls.typesEnabled.setValue(false); + expect(typeFile.errors).toBeNull(); + }); + + it('should require a valid JavaScript identifier for the constant name', () => { + const constant = component.form.controls.tokenConstantName; + constant.setValue('1BAD'); + expect(constant.hasError('invalidIdentifier')).toBe(true); + + constant.setValue('ADMIN_TOKENS'); + expect(constant.errors).toBeNull(); + + constant.setValue(''); + expect(constant.errors).toBeNull(); + }); + + it('should reject reserved words the server would reject, so the dialog never closes on a 400', () => { + const constant = component.form.controls.tokenConstantName; + + for (const reserved of ['type', 'class', 'interface', 'await', 'undefined']) { + constant.setValue(reserved); + expect(constant.hasError('invalidIdentifier')).toBe(true); + } + + constant.setValue('typeTokens'); + expect(constant.errors).toBeNull(); + }); + + it('should require at least one collection unless every collection is included', () => { + component.removeCollection(0); + expect(component.form.controls.collections.hasError('collectionsEmpty')).toBe(true); + expect(component.activeSection()).toBe('collections'); + + component.form.controls.allCollections.setValue(true); + expect(component.form.controls.collections.errors).toBeNull(); + }); + + it('should require at least one rule unless the collection takes all entries', () => { + const group = component.form.controls.collections.at(0); + expect(group.controls.rules.errors).toBeNull(); + + group.controls.allEntries.setValue(false); + expect(group.controls.rules.hasError('rulesEmpty')).toBe(true); + + component.addRule(group); + expect(group.controls.rules.errors).toBeNull(); + expect(group.controls.rules.at(0).controls.matchingPattern.hasError('required')).toBe(true); + + component.removeRule(group, 0); + expect(group.controls.rules.hasError('rulesEmpty')).toBe(true); + }); + + it('should add a collection from the remaining ones and open its pane', () => { + expect(component.availableCollections()).toEqual(['mockDesignSystem', 'TestDataPlayground']); + + component.addCollection('mockDesignSystem'); + + expect(component.form.controls.collections.length).toBe(2); + expect(component.activeSection()).toBe('coll:1'); + expect(component.availableCollections()).toEqual(['TestDataPlayground']); + }); + + it('should flag an invalid section in the rail once it has been touched', () => { + expect(component.sectionErrors().has('output')).toBe(false); + + component.form.controls.bundleName.setValue('admin'); + component.form.controls.bundleName.markAsTouched(); + + expect(component.sectionErrors().has('output')).toBe(true); + }); + + it('should add, normalize and remove rule tags and toggle the operator', () => { + const group = component.form.controls.collections.at(0); + group.controls.allEntries.setValue(false); + component.addRule(group); + const rule = group.controls.rules.at(0); + const input = { value: ' Admin UI ' } as HTMLInputElement; + + component.commitTagInput(rule, input); + expect(rule.controls.matchingTags.value).toEqual(['admin-ui']); + expect(input.value).toBe(''); + + component.commitTagInput(rule, { value: 'admin-ui' } as HTMLInputElement); + expect(rule.controls.matchingTags.value).toEqual(['admin-ui']); + + component.toggleTagOperator(rule); + expect(rule.controls.matchingTagOperator.value).toBe('All'); + + component.removeRuleTag(rule, 'admin-ui'); + expect(rule.controls.matchingTags.value).toEqual([]); + }); + + it('should not close when submitted invalid, and land on the first section with errors', () => { + component.onSubmit(); + + expect(harness.dialogRef.close).not.toHaveBeenCalled(); + expect(component.submitAttempted()).toBe(true); + expect(component.activeSection()).toBe('output'); + }); + + it('should close with a definition that omits empty optional fields and maps inherit to undefined', () => { + fillOutput(component); + const group = component.form.controls.collections.at(0); + group.controls.allEntries.setValue(false); + component.addRule(group); + group.controls.rules.at(0).controls.matchingPattern.setValue('apps.admin.*'); + component.addCollection('mockDesignSystem'); + const second = component.form.controls.collections.at(1); + second.controls.bundledKeyPrefix.setValue('ds'); + second.controls.mergeStrategy.setValue('override'); + component.form.controls.typesEnabled.setValue(true); + component.form.controls.typeDistFile.setValue('./dist/i18n-types/admin.ts'); + + component.onSubmit(); + + expect(harness.dialogRef.close).toHaveBeenCalledWith({ + name: 'admin', + bundle: { + bundleName: 'admin.{locale}', + dist: './dist/i18n', + collections: [ + { name: 'trackerResources', entriesSelectionRules: [{ matchingPattern: 'apps.admin.*' }] }, + { name: 'mockDesignSystem', bundledKeyPrefix: 'ds', entriesSelectionRules: 'All', mergeStrategy: 'override' }, + ], + typeDistFile: './dist/i18n-types/admin.ts', + }, + }); + const bundle = harness.dialogRef.close.mock.calls[0][0].bundle as BundleDefinitionDto; + expect(bundle).not.toHaveProperty('tokenCasing'); + expect(bundle).not.toHaveProperty('tokenConstantName'); + expect(bundle).not.toHaveProperty('transformICUToTransloco'); + }); + + it('should send "All" for collections and explicit casing and ICU choices when set', () => { + fillOutput(component); + component.form.controls.allCollections.setValue(true); + component.form.controls.typesEnabled.setValue(true); + component.form.controls.typeDistFile.setValue('./dist/i18n-types/admin.ts'); + component.form.controls.tokenCasing.setValue('camelCase'); + component.form.controls.tokenConstantName.setValue('ADMIN_KEYS'); + component.setIcu(false); + + component.onSubmit(); + + expect(harness.dialogRef.close).toHaveBeenCalledWith({ + name: 'admin', + bundle: { + bundleName: 'admin.{locale}', + dist: './dist/i18n', + collections: 'All', + typeDistFile: './dist/i18n-types/admin.ts', + tokenCasing: 'camelCase', + tokenConstantName: 'ADMIN_KEYS', + transformICUToTransloco: false, + }, + }); + }); + + it('should close with undefined on cancel', () => { + component.onCancel(); + expect(harness.dialogRef.close).toHaveBeenCalledWith(undefined); + }); +}); + +describe('BundleFormDialog — all collections', () => { + it('should open the Collections pane when the bundle includes every collection', () => { + const { component } = buildHarness({ + mode: 'create', + bundle: { bundleName: '{locale}', dist: './dist', collections: 'All' }, + }); + + expect(component.activeSection()).toBe('collections'); + expect(component.form.controls.collections.length).toBe(0); + }); +}); + +describe('BundleFormDialog — dry run', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should debounce edits and call the API once with the current definition', async () => { + const { component, api } = buildHarness({ mode: 'create' }); + fillOutput(component); + component.form.controls.dist.setValue('./dist/i18n'); + expect(api.dryRunBundle).not.toHaveBeenCalled(); + expect(component.previewStale()).toBe(true); + + vi.advanceTimersByTime(299); + expect(api.dryRunBundle).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(api.dryRunBundle).toHaveBeenCalledTimes(1); + expect(api.dryRunBundle).toHaveBeenCalledWith({ + name: 'admin', + bundle: expect.objectContaining({ bundleName: 'admin.{locale}', dist: './dist/i18n' }), + }); + expect(component.dryRun()).toEqual(dryRunResult); + expect(component.previewStatus()).toBe('ready'); + expect(component.previewStale()).toBe(false); + expect(component.previewFileCount()).toBe(3); + expect(component.keysPerLocale()).toBe(12); + }); + + it('should not call the API until name, folder and pattern are all present', async () => { + const { component, api } = buildHarness({ mode: 'create' }); + component.form.controls.name.setValue('admin'); + vi.advanceTimersByTime(300); + + expect(api.dryRunBundle).not.toHaveBeenCalled(); + expect(component.previewStatus()).toBe('waiting'); + }); + + it('should fall back to the client-side tree when the dry run fails', async () => { + const { component, api } = buildHarness({ mode: 'create' }); + api.dryRunBundle.mockReturnValue(throwError(() => new Error('boom'))); + fillOutput(component); + component.form.controls.typesEnabled.setValue(true); + component.form.controls.typeDistFile.setValue('./dist/i18n-types/admin.ts'); + vi.advanceTimersByTime(300); + + expect(component.previewStatus()).toBe('error'); + expect(component.dryRun()).toBeUndefined(); + expect( + component.previewTree().map((folder) => ({ + path: folder.path, + files: folder.files.map((file) => ({ name: file.name, kind: file.kind, exists: file.exists })), + })), + ).toEqual([ + { + path: 'dist/i18n', + files: [ + { name: 'admin.en.json', kind: 'bundle', exists: undefined }, + { name: 'admin.fr-ca.json', kind: 'bundle', exists: undefined }, + { name: 'admin.es.json', kind: 'bundle', exists: undefined }, + ], + }, + { path: 'dist/i18n-types', files: [{ name: 'admin.ts', kind: 'types', exists: undefined }] }, + ]); + }); + + it('should split tree paths and names after separators so they wrap between segments', async () => { + const { component, api } = buildHarness({ mode: 'create' }); + api.dryRunBundle.mockReturnValue(throwError(() => new Error('boom'))); + fillOutput(component); + vi.advanceTimersByTime(300); + + const folder = component.previewTree()[0]; + expect(folder).toBeDefined(); + expect(folder?.pathParts).toEqual(['dist/', 'i18n']); + expect(folder?.files[0]?.nameParts).toEqual(['admin.', 'en.', 'json']); + expect(folder?.files[0]?.nameParts.join('')).toBe(folder?.files[0]?.name); + }); + + it('should split the example token path after separators', async () => { + const { component } = buildHarness({ mode: 'create' }); + fillOutput(component); + vi.advanceTimersByTime(300); + + component.dryRun.set({ + ...dryRunResult, + exampleKey: { + collectionName: 'trackerResources', + sourceKey: 'app.skipToMainContent', + bundledKey: 'app.skipToMainContent', + tokenPath: 'TRACKER_TOKENS.APP.SKIPTOMAINCONTENT', + }, + }); + + expect(component.tokenPathParts()).toEqual(['TRACKER_', 'TOKENS.', 'APP.', 'SKIPTOMAINCONTENT']); + expect(component.tokenPathParts().join('')).toBe('TRACKER_TOKENS.APP.SKIPTOMAINCONTENT'); + }); + + it('should surface hierarchical key collisions as an error in the preview', async () => { + const { component, fixture } = buildHarness({ mode: 'create' }); + fillOutput(component); + vi.advanceTimersByTime(300); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).querySelector('[data-testid="hierarchical-conflicts"]')).toBeNull(); + + component.dryRun.set({ ...dryRunResult, hierarchicalConflicts: ['buttons.ok', 'menu.file'] }); + fixture.detectChanges(); + + expect(component.hierarchicalConflicts()).toEqual(['buttons.ok', 'menu.file']); + expect(component.hierarchicalConflictList()).toBe('buttons.ok, menu.file'); + + // The message itself is translated (asserted via the token), so the test checks + // that the error line appears at all and that it is announced. + const error = (fixture.nativeElement as HTMLElement).querySelector('[data-testid="hierarchical-conflicts"]'); + expect(error).toBeTruthy(); + expect(error?.getAttribute('role')).toBe('alert'); + }); +}); + +describe('BundleFormDialog — edit mode', () => { + let harness: Harness; + let component: BundleFormDialog; + + beforeEach(async () => { + harness = buildHarness({ mode: 'edit', name: 'tracker', bundle: trackerBundle }); + component = harness.component; + }); + + it('should open on the Output section', () => { + expect(component.isEditMode).toBe(true); + expect(component.activeSection()).toBe('output'); + }); + + it('should lock the name and skip the collision check against itself', () => { + expect(component.form.controls.name.disabled).toBe(true); + expect(component.form.controls.name.value).toBe('tracker'); + expect(component.form.controls.name.errors).toBeNull(); + expect(harness.fixture.nativeElement.querySelector('[data-testid="name-locked"]')).toBeTruthy(); + }); + + it('should pre-populate the definition', () => { + const raw = component.form.getRawValue(); + expect(raw.dist).toBe('./apps/tracker/src/assets/i18n'); + expect(raw.bundleName).toBe('{locale}'); + expect(raw.allCollections).toBe(false); + expect(raw.collections).toHaveLength(1); + expect(raw.collections[0]?.allEntries).toBe(true); + expect(raw.typesEnabled).toBe(true); + expect(raw.typeDistFile).toBe('./apps/tracker/src/i18n-types/tracker-resources.ts'); + expect(raw.tokenCasing).toBe('inherit'); + expect(raw.transformICUToTransloco).toBe('on'); + }); + + it('should keep the name in the result even though the control is disabled', () => { + component.onSubmit(); + + expect(harness.dialogRef.close).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tracker', + bundle: expect.objectContaining({ collections: trackerBundle.collections, transformICUToTransloco: true }), + }), + ); + }); + + it('should collapse an ICU choice equal to the project default (on) back to inherit', () => { + component.setIcu(true); + expect(component.form.controls.transformICUToTransloco.value).toBe('inherit'); + + component.setIcu(false); + expect(component.form.controls.transformICUToTransloco.value).toBe('off'); + }); +}); diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts new file mode 100644 index 00000000..26a62d9d --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-form-dialog/bundle-form-dialog.ts @@ -0,0 +1,733 @@ +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { NgTemplateOutlet } from '@angular/common'; +import { + type AbstractControl, + FormArray, + FormControl, + FormGroup, + ReactiveFormsModule, + type ValidationErrors, + type ValidatorFn, + Validators, +} from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { catchError, debounceTime, map, of, startWith, switchMap, tap } from 'rxjs'; +import { isValidJavaScriptIdentifier, normalizeTag } from '@simoncodes-ca/domain'; +import type { + BundleDefinitionDto, + BundleDryRunRequestDto, + BundleDryRunResultDto, + CollectionBundleDefinitionDto, + EntrySelectionRuleDto, + TokenCasingDto, +} from '@simoncodes-ca/data-transfer'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { CollectionsApiService } from '../services/collections-api.service'; +import { CollectionsStore } from '../store/collections.store'; +import type { BundleFormDialogData, BundleFormResult } from './bundle-form-dialog-data'; +import { SegmentedControl, type SegmentOption } from './segmented-control'; + +export type BundleSection = 'output' | 'collections' | `coll:${number}` | 'types' | 'options'; +export type MergeStrategy = 'merge' | 'override'; +export type TagOperator = 'Any' | 'All'; +export type TokenCasingChoice = 'inherit' | TokenCasingDto; +export type IcuChoice = 'inherit' | 'on' | 'off'; + +export type RuleGroup = FormGroup<{ + matchingPattern: FormControl; + matchingTags: FormControl; + matchingTagOperator: FormControl; +}>; + +export type CollectionGroup = FormGroup<{ + name: FormControl; + bundledKeyPrefix: FormControl; + mergeStrategy: FormControl; + allEntries: FormControl; + rules: FormArray; +}>; + +/** One folder of the "Will write" tree. */ +export interface PreviewFolder { + readonly path: string; + /** `path` split after each separator so the template can offer break opportunities. */ + readonly pathParts: readonly string[]; + readonly files: readonly PreviewFile[]; +} + +export interface PreviewFile { + readonly name: string; + /** `name` split after each separator so the template can offer break opportunities. */ + readonly nameParts: readonly string[]; + readonly kind: 'bundle' | 'types'; + /** `undefined` when the dry run failed and existence is unknown. */ + readonly exists: boolean | undefined; +} + +export type PreviewStatus = 'waiting' | 'loading' | 'ready' | 'error'; + +const LOCALE_PLACEHOLDER = '{locale}'; +const NAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +const escapeHtml = (value: string): string => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +const code = (value: string): string => `${escapeHtml(value)}`; + +/** Mirrors core's `bundleKeyToConstantName` without importing core into the browser. */ +const deriveConstantName = (bundleKey: string): string => `${bundleKey.replace(/-/g, '_').toUpperCase()}_TOKENS`; + +/** Split after each separator so long paths break between segments instead of mid-identifier. */ +const splitAfterSeparators = (value: string, separators: RegExp): readonly string[] => + value.length === 0 ? [] : value.split(separators); + +const PATH_SEPARATORS = /(?<=[._\-/])/; +const TOKEN_SEPARATORS = /(?<=[._])/; + +const stripDotSlash = (path: string): string => path.replace(/^\.\//, '').replace(/\/+$/, ''); + +@Component({ + selector: 'app-bundle-form-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgTemplateOutlet, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatSlideToggleModule, + MatTooltipModule, + TranslocoPipe, + SegmentedControl, + ], + templateUrl: './bundle-form-dialog.html', + styleUrl: './bundle-form-dialog.scss', +}) +export class BundleFormDialog { + readonly #dialogRef = inject(MatDialogRef); + readonly #data = inject(MAT_DIALOG_DATA); + readonly #api = inject(CollectionsApiService); + readonly #destroyRef = inject(DestroyRef); + readonly store = inject(CollectionsStore); + + readonly TOKENS = TRACKER_TOKENS; + readonly isEditMode = this.#data.mode === 'edit'; + readonly configFileMarkup = '.lingo-tracker.json'; + readonly bundlesKeyMarkup = 'bundles'; + readonly tsExtensionMarkup = '.ts'; + /** Braces as entities so messageformat never reads the examples as arguments. */ + readonly icuExampleMarkup = '{count}'; + readonly translocoExampleMarkup = '{{count}}'; + readonly localePlaceholder = LOCALE_PLACEHOLDER; + /** Rail sub-labels always render; these stand in until the matching field is filled. */ + readonly placeholderPattern = TRACKER_TOKENS.BUNDLES.DIALOG.SECTIONS.OUTPUTPLACEHOLDER; + readonly placeholderTypeFile = TRACKER_TOKENS.BUNDLES.DIALOG.SECTIONS.TYPESPLACEHOLDER; + + readonly mergeOptions: readonly SegmentOption[] = [ + { value: 'merge', label: TRACKER_TOKENS.BUNDLES.DIALOG.COLLECTION.FIRSTWINS }, + { value: 'override', label: TRACKER_TOKENS.BUNDLES.DIALOG.COLLECTION.THISOVERRIDES }, + ]; + readonly entriesOptions: readonly SegmentOption[] = [ + { value: true, label: TRACKER_TOKENS.BUNDLES.DIALOG.COLLECTION.ALLENTRIES, icon: 'done_all' }, + { value: false, label: TRACKER_TOKENS.BUNDLES.DIALOG.COLLECTION.ONLYMATCHING, icon: 'filter_alt' }, + ]; + readonly casingOptions: readonly SegmentOption[] = [ + { value: 'upperCase', text: 'UPPER_CASE' }, + { value: 'camelCase', text: 'camelCase' }, + ]; + + readonly form = new FormGroup({ + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(NAME_PATTERN), this.#uniqueNameValidator()], + }), + dist: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + bundleName: new FormControl('', { + nonNullable: true, + validators: [Validators.required, localePlaceholderValidator], + }), + allCollections: new FormControl(false, { nonNullable: true }), + collections: new FormArray([], { validators: [collectionsRequiredValidator] }), + typesEnabled: new FormControl(false, { nonNullable: true }), + typeDistFile: new FormControl('', { nonNullable: true, validators: [typeFileValidator] }), + tokenCasing: new FormControl('inherit', { nonNullable: true }), + tokenConstantName: new FormControl('', { nonNullable: true, validators: [identifierValidator] }), + transformICUToTransloco: new FormControl('inherit', { nonNullable: true }), + }); + + /** + * Reactive forms are not signals. Every form event (value, status, touched, submit) bumps this + * signal so the computeds below re-read the form. + */ + readonly #formTick = toSignal(this.form.events.pipe(map(() => Symbol())), { initialValue: Symbol() }); + + readonly activeSection = signal(this.#initialSection()); + readonly submitAttempted = signal(false); + readonly previewOpen = signal(false); + + readonly dryRun = signal(undefined); + readonly previewStatus = signal('waiting'); + /** True from the first keystroke until the next dry run lands. */ + readonly previewStale = signal(false); + + readonly activeKind = computed<'output' | 'collections' | 'collection' | 'types' | 'options'>(() => { + const section = this.activeSection(); + return section.startsWith('coll:') ? 'collection' : (section as Exclude); + }); + + readonly activeCollectionIndex = computed(() => { + const section = this.activeSection(); + return section.startsWith('coll:') ? Number(section.slice(5)) : -1; + }); + + readonly activeCollectionGroup = computed(() => { + this.#formTick(); + const index = this.activeCollectionIndex(); + return index >= 0 ? this.form.controls.collections.at(index) : undefined; + }); + + /** Names of every configured collection, in config order. */ + readonly allCollectionNames = computed(() => this.store.collectionEntries().map((entry) => entry.name)); + + /** Collections not yet in the bundle — what the "Add a collection" menu offers. */ + readonly availableCollections = computed(() => { + this.#formTick(); + const used = new Set(this.form.controls.collections.controls.map((group) => group.controls.name.value)); + return this.allCollectionNames().filter((name) => !used.has(name)); + }); + + readonly projectLocales = computed(() => this.store.config()?.locales ?? []); + readonly projectBaseLocale = computed(() => this.store.config()?.baseLocale ?? ''); + readonly projectTokenCasing = computed(() => this.store.config()?.tokenCasing ?? 'upperCase'); + readonly projectIcuTransform = computed(() => this.store.config()?.transformICUToTransloco ?? true); + + readonly collectionCount = computed(() => { + this.#formTick(); + return this.form.controls.allCollections.value + ? this.allCollectionNames().length + : this.form.controls.collections.length; + }); + + /** Rail summary under Output: `dist/pattern.json`, or nothing until both are typed. */ + readonly outputSummary = computed(() => { + this.#formTick(); + const { dist, bundleName } = this.form.getRawValue(); + if (!dist.trim() && !bundleName.trim()) return ''; + return `${stripDotSlash(dist.trim())}/${bundleName.trim()}.json`; + }); + + readonly typeFileName = computed(() => { + this.#formTick(); + const { typesEnabled, typeDistFile } = this.form.getRawValue(); + if (!typesEnabled) return ''; + return typeDistFile.trim().split('/').pop() ?? ''; + }); + + readonly derivedConstantName = computed(() => { + this.#formTick(); + return deriveConstantName(this.form.getRawValue().name.trim() || 'bundle'); + }); + + readonly patternFiles = computed(() => { + this.#formTick(); + const pattern = this.form.getRawValue().bundleName.trim(); + if (!pattern) return []; + return this.projectLocales().map((locale) => `${pattern.replace(LOCALE_PLACEHOLDER, locale)}.json`); + }); + + readonly writesHintParams = computed(() => { + const files = this.patternFiles(); + return { examples: files.slice(0, 2).map(code).join(', '), count: files.length }; + }); + + /** Sections whose fields are invalid and worth flagging in the rail. */ + readonly sectionErrors = computed>(() => { + this.#formTick(); + const flag = (control: AbstractControl): boolean => + control.invalid && (control.touched || control.dirty || this.submitAttempted()); + const errors = new Set(); + const controls = this.form.controls; + + if ([controls.name, controls.dist, controls.bundleName].some(flag)) errors.add('output'); + if (flag(controls.collections) && controls.collections.hasError('collectionsEmpty')) errors.add('collections'); + controls.collections.controls.forEach((group, index) => { + if (flag(group)) errors.add(`coll:${index}`); + }); + if ([controls.typeDistFile, controls.tokenConstantName].some(flag)) errors.add('types'); + return errors; + }); + + /** Client-side tree from the form alone; used while waiting and when the dry run fails. */ + readonly localTree = computed(() => { + this.#formTick(); + const raw = this.form.getRawValue(); + const files: { path: string; kind: 'bundle' | 'types' }[] = this.patternFiles().map((file) => ({ + path: `${stripDotSlash(raw.dist.trim())}/${file}`, + kind: 'bundle', + })); + if (raw.typesEnabled && raw.typeDistFile.trim()) { + files.push({ path: stripDotSlash(raw.typeDistFile.trim()), kind: 'types' }); + } + return groupIntoFolders(files.map((file) => ({ ...file, exists: undefined }))); + }); + + readonly previewTree = computed(() => { + const result = this.dryRun(); + if (!result || this.previewStatus() === 'error') return this.localTree(); + return groupIntoFolders( + result.files.map((file) => ({ path: stripDotSlash(file.path), kind: file.kind, exists: file.exists })), + ); + }); + + readonly previewFileCount = computed(() => + this.previewTree().reduce((total, folder) => total + folder.files.length, 0), + ); + + /** + * Bundled keys that are both a leaf and a parent (e.g. `buttons.ok` next to + * `buttons.ok.label`). Generation throws on these, so the preview must show + * them as an error rather than letting the bundle look ready to ship. + */ + readonly hierarchicalConflicts = computed(() => { + if (this.previewStatus() === 'error') return []; + return this.dryRun()?.hierarchicalConflicts ?? []; + }); + + /** The colliding keys as one readable list for the preview error line. */ + readonly hierarchicalConflictList = computed(() => this.hierarchicalConflicts().join(', ')); + + readonly keysPerLocale = computed(() => { + const result = this.dryRun(); + if (!result) return undefined; + const base = result.keysPerLocale[this.projectBaseLocale()]; + if (base !== undefined) return base; + return Math.max(0, ...Object.values(result.keysPerLocale)); + }); + + /** What the ICU switch shows: the explicit choice, or the project default while inheriting. */ + readonly icuChecked = computed(() => { + this.#formTick(); + const choice = this.form.controls.transformICUToTransloco.value; + return choice === 'inherit' ? this.projectIcuTransform() : choice === 'on'; + }); + + /** Example token path split after `.` and `_`, so it wraps between segments, never mid-identifier. */ + readonly tokenPathParts = computed(() => { + const tokenPath = this.dryRun()?.exampleKey?.tokenPath; + if (!tokenPath) return []; + return splitAfterSeparators(tokenPath, TOKEN_SEPARATORS); + }); + + readonly hasOverride = computed(() => { + this.#formTick(); + return this.form.controls.collections.controls.some((group) => group.controls.mergeStrategy.value === 'override'); + }); + + constructor() { + this.#populate(); + this.#wireDependentValidation(); + this.#wireDryRun(); + } + + // ───────────────────────────── navigation ───────────────────────────── + + activate(section: BundleSection): void { + this.activeSection.set(section); + } + + isActive(section: BundleSection): boolean { + return this.activeSection() === section; + } + + activateCollection(index: number): void { + this.activate(`coll:${index}`); + } + + isActiveCollection(index: number): boolean { + return this.activeCollectionIndex() === index; + } + + hasCollectionError(index: number): boolean { + return this.sectionErrors().has(`coll:${index}`); + } + + /** Wraps user text in `` for the innerHTML hints, escaped so a prefix can never inject markup. */ + codeMarkup(value: string): string { + return code(value); + } + + /** A choice equal to the project default is redundant, so it collapses back to inherit. */ + setIcu(checked: boolean): void { + const control = this.form.controls.transformICUToTransloco; + control.setValue(checked === this.projectIcuTransform() ? 'inherit' : checked ? 'on' : 'off'); + control.markAsDirty(); + control.markAsTouched(); + } + + togglePreview(): void { + this.previewOpen.update((open) => !open); + } + + // ───────────────────────────── collections ───────────────────────────── + + addCollection(name: string): void { + const collections = this.form.controls.collections; + collections.push(this.#buildCollectionGroup({ name, entriesSelectionRules: 'All' })); + collections.markAsDirty(); + this.activate(`coll:${collections.length - 1}`); + } + + removeCollection(index: number): void { + const collections = this.form.controls.collections; + if (index < 0 || index >= collections.length) return; + collections.removeAt(index); + collections.markAsDirty(); + collections.markAsTouched(); + this.activate('collections'); + } + + onAllCollectionsToggle(): void { + if (this.form.controls.allCollections.value && this.activeKind() === 'collection') { + this.activate('collections'); + } + } + + /** The one-line summary shown on a collection row and in the rail. */ + ruleCount(group: CollectionGroup): number { + return group.controls.rules.length; + } + + // ───────────────────────────── rules ───────────────────────────── + + addRule(group: CollectionGroup): void { + group.controls.rules.push(this.#buildRuleGroup({ matchingPattern: '' })); + group.controls.rules.markAsDirty(); + } + + removeRule(group: CollectionGroup, index: number): void { + group.controls.rules.removeAt(index); + group.controls.rules.markAsDirty(); + group.controls.rules.markAsTouched(); + } + + onTagInputKeydown(event: KeyboardEvent, rule: RuleGroup, input: HTMLInputElement): void { + if (event.key === 'Backspace' && !input.value) { + const tags = rule.controls.matchingTags.value; + if (tags.length > 0) this.removeRuleTag(rule, tags[tags.length - 1]); + return; + } + if (event.key !== 'Enter' && event.key !== ',') return; + event.preventDefault(); + this.commitTagInput(rule, input); + } + + commitTagInput(rule: RuleGroup, input: HTMLInputElement): void { + const normalized = normalizeTag(input.value); + input.value = ''; + if (!normalized) return; + const control = rule.controls.matchingTags; + if (control.value.includes(normalized)) return; + control.setValue([...control.value, normalized]); + control.markAsDirty(); + } + + removeRuleTag(rule: RuleGroup, tag: string): void { + const control = rule.controls.matchingTags; + control.setValue(control.value.filter((existing) => existing !== tag)); + control.markAsDirty(); + } + + toggleTagOperator(rule: RuleGroup): void { + const control = rule.controls.matchingTagOperator; + control.setValue(control.value === 'Any' ? 'All' : 'Any'); + control.markAsDirty(); + } + + // ───────────────────────────── errors ───────────────────────────── + + showError(control: AbstractControl, error?: string): boolean { + if (!(control.touched || control.dirty || this.submitAttempted())) return false; + return error ? control.hasError(error) : control.invalid; + } + + // ───────────────────────────── submit ───────────────────────────── + + onCancel(): void { + this.#dialogRef.close(undefined); + } + + onSubmit(): void { + this.submitAttempted.set(true); + if (this.form.invalid) { + this.form.markAllAsTouched(); + this.#revealFirstError(); + return; + } + this.#dialogRef.close(this.#buildResult()); + } + + // ───────────────────────────── private ───────────────────────────── + + #initialSection(): BundleSection { + if (this.#data.mode === 'edit') return 'output'; + const collections = this.#data.bundle?.collections; + return Array.isArray(collections) && collections.length > 0 ? 'coll:0' : 'collections'; + } + + #populate(): void { + const bundle = this.#data.bundle; + const name = this.#data.name ?? ''; + const collections = bundle?.collections; + + this.form.patchValue({ + name, + dist: bundle?.dist ?? '', + bundleName: bundle?.bundleName ?? '', + allCollections: collections === 'All', + typesEnabled: Boolean(bundle?.typeDistFile), + typeDistFile: bundle?.typeDistFile ?? '', + tokenCasing: bundle?.tokenCasing ?? 'inherit', + tokenConstantName: bundle?.tokenConstantName ?? '', + transformICUToTransloco: + bundle?.transformICUToTransloco === undefined ? 'inherit' : bundle.transformICUToTransloco ? 'on' : 'off', + }); + + if (Array.isArray(collections)) { + for (const collection of collections) { + this.form.controls.collections.push(this.#buildCollectionGroup(collection), { emitEvent: false }); + } + } + + // In create mode the first collection pane opens straight away when there is one; a brand + // new bundle starts with its first collection so the user lands on something to fill in. + if (!this.isEditMode && this.form.controls.collections.length === 0 && !this.form.controls.allCollections.value) { + const [first] = this.allCollectionNames(); + if (first) { + this.form.controls.collections.push(this.#buildCollectionGroup({ name: first, entriesSelectionRules: 'All' }), { + emitEvent: false, + }); + this.activeSection.set('coll:0'); + } + } + + if (this.isEditMode && name) { + this.form.controls.name.disable({ emitEvent: false }); + } + + // Cross-control validators ran before their siblings had values; settle them now. + this.form.controls.typeDistFile.updateValueAndValidity({ emitEvent: false }); + this.form.controls.collections.updateValueAndValidity({ emitEvent: false }); + } + + #buildCollectionGroup(collection: CollectionBundleDefinitionDto): CollectionGroup { + const rules = Array.isArray(collection.entriesSelectionRules) ? collection.entriesSelectionRules : []; + const group: CollectionGroup = new FormGroup({ + name: new FormControl(collection.name, { nonNullable: true, validators: [Validators.required] }), + bundledKeyPrefix: new FormControl(collection.bundledKeyPrefix ?? '', { nonNullable: true }), + mergeStrategy: new FormControl(collection.mergeStrategy ?? 'merge', { nonNullable: true }), + allEntries: new FormControl(collection.entriesSelectionRules === 'All', { nonNullable: true }), + rules: new FormArray( + rules.map((rule) => this.#buildRuleGroup(rule)), + { validators: [rulesRequiredValidator] }, + ), + }); + // The rules validator reads `allEntries`, which it could not see before the group existed; + // settle it now and re-run it on every toggle. + group.controls.rules.updateValueAndValidity({ emitEvent: false }); + group.controls.allEntries.valueChanges + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(() => group.controls.rules.updateValueAndValidity()); + return group; + } + + #buildRuleGroup(rule: EntrySelectionRuleDto): RuleGroup { + return new FormGroup({ + matchingPattern: new FormControl(rule.matchingPattern, { + nonNullable: true, + validators: [Validators.required], + }), + matchingTags: new FormControl([...(rule.matchingTags ?? [])], { nonNullable: true }), + matchingTagOperator: new FormControl(rule.matchingTagOperator ?? 'Any', { nonNullable: true }), + }); + } + + #uniqueNameValidator(): ValidatorFn { + return (control) => { + if (this.#data.mode === 'edit') return null; + const value = String(control.value ?? '').trim(); + if (!value) return null; + const taken = this.store.bundleEntries().some((entry) => entry.name === value); + return taken ? { nameExists: { name: value } } : null; + }; + } + + /** + * Validators that read a sibling control do not re-run on their own when the sibling changes; + * these subscriptions nudge them. + */ + #wireDependentValidation(): void { + const controls = this.form.controls; + controls.typesEnabled.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + controls.typeDistFile.updateValueAndValidity(); + controls.tokenConstantName.updateValueAndValidity(); + }); + controls.allCollections.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + controls.collections.updateValueAndValidity(); + }); + } + + #wireDryRun(): void { + this.form.valueChanges + .pipe( + startWith(null), + tap(() => this.previewStale.set(true)), + debounceTime(300), + map(() => this.#buildDryRunRequest()), + switchMap((request) => { + if (!request) { + return of({ status: 'waiting' as const, result: undefined }); + } + this.previewStatus.update((status) => (status === 'ready' ? status : 'loading')); + return this.#api.dryRunBundle(request).pipe( + map((result) => ({ status: 'ready' as const, result })), + catchError(() => of({ status: 'error' as const, result: undefined })), + ); + }), + takeUntilDestroyed(), + ) + .subscribe(({ status, result }) => { + this.previewStatus.set(status); + if (status !== 'error') this.dryRun.set(result); + this.previewStale.set(false); + }); + } + + /** The dry run needs a name, a folder and a pattern; anything less would only 400. */ + #buildDryRunRequest(): BundleDryRunRequestDto | undefined { + const raw = this.form.getRawValue(); + const name = raw.name.trim(); + if (!name || !raw.dist.trim() || !raw.bundleName.trim()) return undefined; + return { name, bundle: this.#buildDefinition() }; + } + + #buildDefinition(): BundleDefinitionDto { + const raw = this.form.getRawValue(); + const collections: BundleDefinitionDto['collections'] = raw.allCollections + ? 'All' + : raw.collections.map((collection) => { + const prefix = collection.bundledKeyPrefix.trim(); + const entriesSelectionRules: CollectionBundleDefinitionDto['entriesSelectionRules'] = collection.allEntries + ? 'All' + : collection.rules.map((rule) => ({ + matchingPattern: rule.matchingPattern.trim(), + ...(rule.matchingTags.length > 0 + ? { matchingTags: [...rule.matchingTags], matchingTagOperator: rule.matchingTagOperator } + : {}), + })); + return { + name: collection.name, + ...(prefix ? { bundledKeyPrefix: prefix } : {}), + entriesSelectionRules, + ...(collection.mergeStrategy === 'override' ? { mergeStrategy: 'override' as const } : {}), + }; + }); + + const typeDistFile = raw.typeDistFile.trim(); + const tokenConstantName = raw.tokenConstantName.trim(); + const typesOn = raw.typesEnabled && typeDistFile.length > 0; + + return { + bundleName: raw.bundleName.trim(), + dist: raw.dist.trim(), + collections, + ...(typesOn ? { typeDistFile } : {}), + ...(typesOn && raw.tokenCasing !== 'inherit' ? { tokenCasing: raw.tokenCasing } : {}), + ...(typesOn && tokenConstantName ? { tokenConstantName } : {}), + ...(raw.transformICUToTransloco !== 'inherit' + ? { transformICUToTransloco: raw.transformICUToTransloco === 'on' } + : {}), + }; + } + + #buildResult(): BundleFormResult { + return { name: this.form.getRawValue().name.trim(), bundle: this.#buildDefinition() }; + } + + /** After a failed submit, land on the first section that has something to fix. */ + #revealFirstError(): void { + const order: BundleSection[] = [ + 'output', + 'collections', + ...this.form.controls.collections.controls.map((_, index) => `coll:${index}` as const), + 'types', + ]; + const errors = this.sectionErrors(); + const first = order.find((section) => errors.has(section)); + if (first) this.activate(first); + } +} + +// ───────────────────────────── validators & helpers ───────────────────────────── + +function localePlaceholderValidator(control: AbstractControl): ValidationErrors | null { + const value = String(control.value ?? ''); + if (!value.trim()) return null; + return value.includes(LOCALE_PLACEHOLDER) ? null : { missingLocale: true }; +} + +function typeFileValidator(control: AbstractControl): ValidationErrors | null { + const parent = control.parent; + const enabled = parent?.get('typesEnabled')?.value === true; + if (!enabled) return null; + const value = String(control.value ?? '').trim(); + if (!value) return { required: true }; + return value.endsWith('.ts') ? null : { notTypeScript: true }; +} + +/** + * Mirrors core's `validateJavaScriptIdentifier` exactly: the shared rules live in + * `@simoncodes-ca/domain` so the client check can never be weaker than the server's, + * which would let the dialog close on a name the API then rejects with a 400. + */ +function identifierValidator(control: AbstractControl): ValidationErrors | null { + const value = String(control.value ?? '').trim(); + if (!value) return null; + return isValidJavaScriptIdentifier(value) ? null : { invalidIdentifier: true }; +} + +function collectionsRequiredValidator(control: AbstractControl): ValidationErrors | null { + const parent = control.parent; + const all = parent?.get('allCollections')?.value === true; + if (all) return null; + return control instanceof FormArray && control.length === 0 ? { collectionsEmpty: true } : null; +} + +function rulesRequiredValidator(control: AbstractControl): ValidationErrors | null { + const parent = control.parent; + const all = parent?.get('allEntries')?.value === true; + if (all) return null; + return control instanceof FormArray && control.length === 0 ? { rulesEmpty: true } : null; +} + +function groupIntoFolders(files: readonly { path: string; kind: 'bundle' | 'types'; exists: boolean | undefined }[]) { + const folders = new Map(); + for (const file of files) { + const slash = file.path.lastIndexOf('/'); + const folder = slash >= 0 ? file.path.slice(0, slash) : ''; + const name = slash >= 0 ? file.path.slice(slash + 1) : file.path; + const list = folders.get(folder) ?? []; + list.push({ name, nameParts: splitAfterSeparators(name, PATH_SEPARATORS), kind: file.kind, exists: file.exists }); + folders.set(folder, list); + } + return [...folders.entries()].map( + ([path, list]) => + ({ path, pathParts: splitAfterSeparators(path, PATH_SEPARATORS), files: list }) satisfies PreviewFolder, + ); +} diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.scss b/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.scss new file mode 100644 index 00000000..66471ede --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.scss @@ -0,0 +1,69 @@ +// Segmented control: parchment-muted pills inside a hairline well. Colours and +// focus glow follow the dialog's text wells so the two read as one family. +:host { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well, var(--color-background)); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +:host(:focus-within) { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); +} + +:host(.segmented--disabled) { + opacity: 0.6; +} + +.segment { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: 5px var(--spacing-3); + border: 0; + border-radius: var(--border-radius-sm); + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + line-height: 1.4; + white-space: nowrap; + cursor: pointer; + transition: + background var(--transition-fast), + color var(--transition-fast); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover:not(:disabled) { + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } + + &--on { + background: var(--color-background-muted); + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + } +} + +:host(.segmented--mono) .segment { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} diff --git a/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.ts b/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.ts new file mode 100644 index 00000000..9a8b91a2 --- /dev/null +++ b/apps/tracker/src/app/collections/bundle-form-dialog/segmented-control.ts @@ -0,0 +1,126 @@ +import { ChangeDetectionStrategy, Component, ElementRef, forwardRef, inject, input, signal } from '@angular/core'; +import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslocoPipe } from '@jsverse/transloco'; + +export type SegmentValue = string | boolean; + +export interface SegmentOption { + readonly value: T; + /** Transloco token for the visible label. Omit for technical literals and set `text`. */ + readonly label?: string; + /** Untranslated literal label (identifiers such as `camelCase`). */ + readonly text?: string; + readonly icon?: string; +} + +/** + * A hand-rolled segmented control: parchment-muted pills inside a hairline well, exposed as a + * radio group so arrow keys move the selection the way a native radio set does. Plugs into + * reactive forms through ControlValueAccessor. + */ +@Component({ + selector: 'app-segmented-control', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconModule, TranslocoPipe], + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SegmentedControl), multi: true }], + host: { + class: 'segmented', + role: 'radiogroup', + '[class.segmented--mono]': 'mono()', + '[class.segmented--disabled]': 'disabled()', + '[attr.aria-labelledby]': 'labelledBy() || null', + '(keydown)': 'onKeydown($event)', + }, + template: ` + @for (option of options(); track option.value; let index = $index) { + + } + `, + styleUrl: './segmented-control.scss', +}) +export class SegmentedControl implements ControlValueAccessor { + readonly #host = inject>(ElementRef); + + readonly options = input.required(); + readonly mono = input(false); + readonly labelledBy = input(''); + + readonly value = signal(undefined); + readonly disabled = signal(false); + + #onChange: (value: SegmentValue) => void = () => undefined; + #onTouched: () => void = () => undefined; + + /** Only the checked pill (or the first, when nothing is checked) is in the tab order. */ + tabIndexFor(optionValue: SegmentValue, index: number): number { + const current = this.value(); + const hasSelection = this.options().some((option) => option.value === current); + if (hasSelection) return optionValue === current ? 0 : -1; + return index === 0 ? 0 : -1; + } + + select(next: SegmentValue): void { + if (this.disabled() || next === this.value()) { + this.#onTouched(); + return; + } + this.value.set(next); + this.#onChange(next); + this.#onTouched(); + } + + onKeydown(event: KeyboardEvent): void { + const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'; + const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'; + if (!forward && !backward) return; + event.preventDefault(); + + const options = this.options(); + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option.value === this.value()); + const step = forward ? 1 : -1; + const nextIndex = (currentIndex + step + options.length) % options.length; + const nextOption = options[nextIndex]; + if (!nextOption) return; + + this.select(nextOption.value); + const button = this.#host.nativeElement.querySelector(`[data-index="${nextIndex}"]`); + button?.focus(); + } + + writeValue(value: SegmentValue | undefined): void { + this.value.set(value); + } + + registerOnChange(fn: (value: SegmentValue) => void): void { + this.#onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.#onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } +} diff --git a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog-data.ts b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog-data.ts index 0a987252..fdda514e 100644 --- a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog-data.ts +++ b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog-data.ts @@ -18,4 +18,11 @@ export interface CollectionFormDialogData { * Collection configuration (only present in edit mode). */ config?: LingoTrackerCollectionDto; + + /** + * The base locale the collection actually compares against (edit mode). Falls back to the + * global config when the collection does not set its own, so the dialog can mark and lock it + * without writing an explicit `baseLocale` into the collection. + */ + effectiveBaseLocale?: string; } diff --git a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.html b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.html index 260ec65b..cff80b94 100644 --- a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.html +++ b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.html @@ -1,165 +1,346 @@ -
-

{{ dialogTitle | transloco }}

+ diff --git a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.scss b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.scss index 7c0c9c4b..b326291c 100644 --- a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.scss +++ b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.scss @@ -1,129 +1,646 @@ +// The outer box (width, top rule, zero container padding) is owned by +// `.collection-form-dialog-panel` in styles.scss, because the CDK appends the +// overlay pane outside this component. Everything here is the inside. :host { display: block; - min-width: 420px; - max-width: 600px; + max-height: inherit; + // The panel is one step lighter than the page so the field wells read as set + // into it. Light: page and panel share the parchment; dark: the panel lifts + // to the subtle tone and the wells stay on the page black. + --dialog-surface: var(--color-background); + --dialog-well: var(--color-background); +} + +:host-context([data-theme='dark']) { + --dialog-surface: var(--color-background-subtle); +} + +@media (prefers-color-scheme: dark) { + :host-context(:root:not([data-theme='light']):not([data-theme='dark'])) { + --dialog-surface: var(--color-background-subtle); + } +} + +.dialog-wrapper { + display: flex; + flex-direction: column; + max-height: inherit; + background: var(--dialog-surface); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Header +// ───────────────────────────────────────────────────────────────────────────── + +.dialog-header { + position: relative; + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + padding: var(--spacing-5) var(--spacing-6) var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.dialog-header-tile { + display: grid; + place-items: center; + flex: none; + width: 38px; + height: 38px; + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + color: var(--color-primary); + + mat-icon { + font-size: 19px; + width: 19px; + height: 19px; + } +} + +.dialog-header-text { + flex: 1; + min-width: 0; + // Reserve space so long titles never slide under the close button. + padding-right: var(--spacing-8); +} + +.dialog-title { + margin: 0 0 var(--spacing-1); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight); + letter-spacing: -0.01em; + color: var(--color-text-primary); +} + +.dialog-subtitle { + margin: 0; + font-size: var(--font-size-sm); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); +} + +// File names inside prose keep the mono voice the paths use elsewhere, at the +// surrounding size so the line does not jump. +.dialog-subtitle code, +.field-hint code { + font-family: var(--font-family-mono); + font-size: 0.95em; + color: var(--color-text-primary); +} + +.dialog-close { + position: absolute; + top: var(--spacing-3); + right: var(--spacing-3); + color: var(--color-text-secondary); + transition: color var(--transition-fast); - .mdc-dialog__content { - padding-top: var(--spacing-4); + &:hover { + color: var(--color-text-primary); } } -mat-dialog-content { - padding: var(--spacing-6); +// ───────────────────────────────────────────────────────────────────────────── +// Body & groups +// ───────────────────────────────────────────────────────────────────────────── + +.dialog-body { + // Material caps its content at 65vh; here the body takes whatever the panel + // leaves after the header and footer, so the whole create form sits above + // the fold on a laptop and only long edits scroll. + flex: 1 1 auto; + min-height: 0; + max-height: none; + padding: var(--spacing-5) var(--spacing-6) var(--spacing-4); form { display: flex; flex-direction: column; - gap: var(--spacing-4); - } - - .full-width { - width: 100%; + gap: var(--spacing-5); } } -.locale-section { +.group { display: flex; flex-direction: column; - gap: var(--spacing-2); + gap: var(--spacing-3); } -.locale-section-label { - margin: 0; - font-size: var(--font-size-xs); - color: var(--mat-sys-on-surface-variant); - letter-spacing: 0.03em; +.group-head { + display: flex; + flex-direction: column; + gap: 2px; + + h3 { + margin: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + color: var(--color-text-primary); + } + + p { + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); + } } -.locale-list { +// ───────────────────────────────────────────────────────────────────────────── +// Fields +// ───────────────────────────────────────────────────────────────────────────── + +.field { display: flex; flex-direction: column; - border: 1px solid var(--mat-sys-outline-variant); - border-radius: var(--border-radius-sm); - overflow: hidden; + gap: var(--spacing-1); + min-width: 0; +} + +.field-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + color: var(--color-text-primary); } -.locale-row { +.text-input { display: flex; align-items: center; - padding: var(--spacing-1) var(--spacing-2) var(--spacing-1) var(--spacing-3); - border-bottom: 1px solid var(--mat-sys-outline-variant); + gap: var(--spacing-2); + height: 40px; + padding: 0 var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + color: var(--color-text-primary); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); - &:last-child { - border-bottom: none; + > mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); } - .locale-radio { + input { flex: 1; + min-width: 0; + height: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: var(--font-size-base); + outline: none; + + &::placeholder { + color: var(--color-text-placeholder); + } } - .locale-code { + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); + } + + &--mono input { font-family: var(--font-family-mono); font-size: var(--font-size-sm); } - .locale-remove-btn { - flex-shrink: 0; - width: var(--spacing-8); - height: var(--spacing-8); - color: var(--mat-sys-on-surface-variant); + &--invalid, + &--invalid:focus-within { + border-color: var(--color-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent); + } + + &--locked { + border-style: dashed; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--font-size-base); + } +} + +.field-hint, +.field-error { + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); +} + +.field-hint { + color: var(--color-text-secondary); + + &--icon { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); mat-icon { - /* Material icon size override */ - font-size: 18px; - width: 18px; - height: 18px; + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + margin-top: 2px; + color: var(--color-text-tertiary); } + } + + &--path span { + font-family: var(--font-family-mono); + overflow-wrap: anywhere; + } +} + +.field-error { + color: var(--color-error); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Locale chip editor +// ───────────────────────────────────────────────────────────────────────────── + +.chip-editor { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); + } + + &--empty { + border-style: dashed; + } + + &--invalid, + &--invalid:focus-within { + border-color: var(--color-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent); + } +} + +.chip-list { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + margin: 0; + padding: 0; + list-style: none; + flex: 0 1 auto; + min-width: 0; +} + +// Same pill as the Collections card, so a locale looks like a locale everywhere. +.locale-chip { + display: inline-flex; + align-items: center; + border-radius: var(--border-radius-full); + background: var(--color-background-muted); + border: 1px solid transparent; + color: var(--color-text-secondary); + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + + &--base { + background: color-mix(in srgb, var(--color-secondary) 16%, transparent); + border-color: color-mix(in srgb, var(--color-secondary) 35%, transparent); + color: color-mix(in srgb, var(--color-secondary) 45%, var(--color-text-primary)); + } +} + +.locale-chip-body { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: 3px var(--spacing-2) 3px var(--spacing-3); + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + font: inherit; + line-height: 1.5; + + &--button { + cursor: pointer; &:hover { - color: var(--mat-sys-error); + color: var(--color-text-primary); + } + + .locale-chip:not(.locale-chip--base) &:hover { + background: color-mix(in srgb, var(--color-secondary) 10%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; } } } -.locale-empty-hint { - margin: 0; - padding: var(--spacing-3); +.locale-chip-code { + font-family: var(--font-family-mono); font-size: var(--font-size-sm); - color: var(--mat-sys-on-surface-variant); - font-style: italic; - border: 1px dashed var(--mat-sys-outline-variant); - border-radius: var(--border-radius-sm); - text-align: center; + font-weight: var(--font-weight-semibold); +} + +.locale-chip-tag { + font-size: 0.625rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0.9; +} + +.locale-chip-lock { + font-size: 14px; + width: 14px; + height: 14px; +} + +.locale-chip-remove { + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin: 0 3px 0 -4px; + padding: 0; + border: 0; + border-radius: var(--border-radius-full); + background: transparent; + color: var(--color-text-tertiary); + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } } -.locale-add-row { +.chip-add { + display: flex; + align-items: center; + gap: var(--spacing-1); + flex: 1 1 180px; + min-width: 180px; + min-height: 30px; + padding: 0 var(--spacing-1); + + > mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); + } +} + +.chip-add-input { + flex: 1; + min-width: 120px; + height: 30px; + border: 0; + padding: 0 var(--spacing-1); + background: transparent; + color: var(--color-text-primary); + font: inherit; + font-size: var(--font-size-sm); + outline: none; + + &::placeholder { + color: var(--color-text-placeholder); + } +} + +.chip-add-button { + flex: none; + height: 28px; + padding: 0 var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-full); + background: var(--color-background); + color: var(--color-text-primary); + font: inherit; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + cursor: pointer; + + &:hover { + border-color: var(--color-secondary); + color: var(--color-secondary-active); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Read-only switch row +// ───────────────────────────────────────────────────────────────────────────── + +.switch-row { display: flex; align-items: flex-start; - gap: var(--spacing-2); + gap: var(--spacing-3); + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: var(--color-background-subtle); + transition: border-color var(--transition-normal), background var(--transition-normal); - .locale-add-field { - flex: 1; + &--on { + border-color: color-mix(in srgb, var(--color-warning) 35%, var(--color-border)); + background: color-mix(in srgb, var(--color-warning) 6%, var(--color-background-subtle)); } +} - .locale-add-btn { - margin-top: var(--spacing-1); +.switch-tile { + display: grid; + place-items: center; + flex: none; + width: 30px; + height: 30px; + border-radius: var(--border-radius-md); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning-text); + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; } } -mat-dialog-actions { - padding: var(--spacing-4) var(--spacing-6); +.switch-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.switch-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + color: var(--color-text-primary); +} + +.switch-description { + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); + color: var(--color-text-secondary); +} + +.switch-control { + flex: none; + margin-top: 2px; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tags & protected terms disclosure +// ───────────────────────────────────────────────────────────────────────────── + +.disclosure { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + overflow: hidden; +} + +.disclosure-toggle { + display: flex; + align-items: center; gap: var(--spacing-2); + width: 100%; + padding: var(--spacing-2) var(--spacing-3) var(--spacing-2) var(--spacing-4); + border: 0; + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { + background: var(--color-background-subtle); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } } -.readonly-section { - margin-top: var(--spacing-4); +.disclosure-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} - .readonly-hint { - margin: var(--spacing-1) 0 0 var(--spacing-6); - font-size: var(--font-size-xs, 0.75rem); - color: var(--color-text-secondary); +.disclosure-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: 1.5; +} + +.disclosure-summary { + margin-left: auto; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.disclosure-chevron { + flex: none; + color: var(--color-text-tertiary); + transition: transform var(--transition-normal); + + .disclosure--open & { + transform: rotate(180deg); } } -.protected-terms-file { - margin: -8px 0 12px; - font-size: 12px; - opacity: 0.7; - word-break: break-all; +.disclosure-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-2) var(--spacing-4) var(--spacing-4); + border-top: 1px solid var(--color-border-subtle); + animation: disclosure-reveal var(--transition-normal) both; + + .field-label { + font-size: var(--font-size-sm); + } +} + +@keyframes disclosure-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +// Tags and protected terms are words, not codes: the same pill, set in Nunito +// on the subtle tone, so the disclosure speaks the dialog's one chip language. +.text-chip { + background: var(--color-background-subtle); + border-color: var(--color-border); + color: var(--color-text-primary); + + .locale-chip-body { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Footer +// ───────────────────────────────────────────────────────────────────────────── + +.dialog-footer { + flex-shrink: 0; + padding: var(--spacing-4) var(--spacing-6) var(--spacing-5); + border-top: 1px solid var(--color-border); + gap: var(--spacing-2); +} + +@media (prefers-reduced-motion: reduce) { + .disclosure-panel { + animation: none; + } + + .disclosure-chevron { + transition: none; + } } diff --git a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.spec.ts b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.spec.ts index efdf85cf..91df477f 100644 --- a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.spec.ts +++ b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.spec.ts @@ -1,36 +1,38 @@ -import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import type { ComponentFixture } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; import { CollectionFormDialog } from './collection-form-dialog'; import type { CollectionFormDialogData } from './collection-form-dialog-data'; -import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; -import { of } from 'rxjs'; -const buildTestBed = async ( +const createComponent = createComponentFactory({ + component: CollectionFormDialog, + imports: [NoopAnimationsModule, getTranslocoTestingModule()], + detectChanges: false, +}); + +const buildHarness = ( data: CollectionFormDialogData, mockDialog: Partial = { open: vi.fn() }, -): Promise<{ +): { fixture: ComponentFixture; + spectator: Spectator; mockDialogRef: { close: ReturnType }; mockDialog: Partial; -}> => { +} => { const mockDialogRef = { close: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [CollectionFormDialog, NoopAnimationsModule, getTranslocoTestingModule()], + const spectator = createComponent({ providers: [ { provide: MAT_DIALOG_DATA, useValue: data }, { provide: MatDialogRef, useValue: mockDialogRef }, { provide: MatDialog, useValue: mockDialog }, ], - }).compileComponents(); - - TestBed.overrideProvider(MatDialog, { useValue: mockDialog }); - - const fixture = TestBed.createComponent(CollectionFormDialog); - fixture.detectChanges(); - return { fixture, mockDialogRef, mockDialog }; + }); + spectator.detectChanges(); + return { fixture: spectator.fixture, spectator, mockDialogRef, mockDialog }; }; describe('CollectionFormDialog — create mode', () => { @@ -39,8 +41,7 @@ describe('CollectionFormDialog — create mode', () => { let mockDialogRef: { close: ReturnType }; beforeEach(async () => { - TestBed.resetTestingModule(); - ({ fixture, mockDialogRef } = await buildTestBed({ mode: 'create' })); + ({ fixture, mockDialogRef } = buildHarness({ mode: 'create' })); component = fixture.componentInstance; }); @@ -130,6 +131,49 @@ describe('CollectionFormDialog — create mode', () => { expect(component.form.controls.baseLocale.value).toBe(''); }); + it('should let a clicked locale chip become the base', () => { + component.addLocaleInput.setValue('en'); + component.addLocale(); + component.addLocaleInput.setValue('fr-ca'); + component.addLocale(); + + component.setBaseLocale('fr-ca'); + + expect(component.form.controls.baseLocale.value).toBe('fr-ca'); + expect(component.isBaseLocale('fr-ca')).toBe(true); + expect(component.isBaseLocale('en')).toBe(false); + }); + + it('should ignore a base locale that is not in the list', () => { + component.addLocaleInput.setValue('en'); + component.addLocale(); + + component.setBaseLocale('de'); + + expect(component.form.controls.baseLocale.value).toBe('en'); + }); + + it('should add a pending locale when the input loses focus', () => { + component.addLocaleInput.setValue('es'); + component.addLocaleIfPending(); + + expect(component.form.controls.locales.at(0).value).toBe('es'); + }); + + it('should keep the tags and protected terms disclosure closed when there is nothing in it', () => { + expect(component.advancedOpen()).toBe(false); + component.toggleAdvanced(); + expect(component.advancedOpen()).toBe(true); + }); + + it('should mark required fields touched instead of closing when submitted empty', async () => { + await component.onSubmit(); + + expect(component.form.controls.name.touched).toBe(true); + expect(component.showNameError).toBe(true); + expect(component.showFolderError).toBe(true); + }); + it('should not close dialog when form is invalid', async () => { await component.onSubmit(); expect(mockDialogRef.close).not.toHaveBeenCalled(); @@ -174,6 +218,29 @@ describe('CollectionFormDialog — create mode', () => { expect(component.protectedTermsList()).toEqual(['iPhone', 'Node.js']); }); + it('should commit a typed tag on Enter or comma and clear the input', () => { + const input = { value: ' Design System ' } as HTMLInputElement; + const enter = { key: 'Enter', preventDefault: vi.fn() } as unknown as KeyboardEvent; + + component.onChipInputKeydown(enter, input, 'tag'); + + expect(enter.preventDefault).toHaveBeenCalled(); + expect(component.tagsList()).toEqual(['design-system']); + expect(input.value).toBe(''); + + const other = { key: 'a', preventDefault: vi.fn() } as unknown as KeyboardEvent; + component.onChipInputKeydown(other, { value: 'x' } as HTMLInputElement, 'tag'); + expect(component.tagsList()).toEqual(['design-system']); + }); + + it('should commit a pending protected term when its input blurs', () => { + const input = { value: 'iPhone' } as HTMLInputElement; + component.commitChipInput(input, 'term'); + + expect(component.protectedTermsList()).toEqual(['iPhone']); + expect(input.value).toBe(''); + }); + it('should remove a protected term', () => { component.addProtectedTerm({ value: 'iPhone', chipInput: { clear: () => undefined } } as never); component.addProtectedTerm({ value: 'C++', chipInput: { clear: () => undefined } } as never); @@ -219,10 +286,9 @@ describe('CollectionFormDialog — edit mode', () => { }; beforeEach(async () => { - TestBed.resetTestingModule(); mockDialog = { open: vi.fn() }; - ({ fixture, mockDialogRef, mockDialog } = await buildTestBed(editData, mockDialog)); + ({ fixture, mockDialogRef, mockDialog } = buildHarness(editData, mockDialog)); component = fixture.componentInstance; }); @@ -312,4 +378,48 @@ describe('CollectionFormDialog — edit mode', () => { expect(component.form.controls.locales.length).toBe(3); expect(component.form.controls.locales.at(0).value).toBe('en'); }); + + it('should not offer a remove control for the base locale in edit mode', () => { + expect(component.canRemoveLocale(0)).toBe(false); + expect(component.canRemoveLocale(1)).toBe(true); + }); + + it('should not let the base locale change in edit mode', () => { + component.setBaseLocale('es'); + expect(component.form.controls.baseLocale.value).toBe('en'); + }); + + it('should open the disclosure when protected terms already exist', () => { + expect(component.advancedOpen()).toBe(true); + }); +}); + +describe('CollectionFormDialog — edit mode with inherited base locale', () => { + let component: CollectionFormDialog; + let mockDialogRef: { close: ReturnType }; + + beforeEach(async () => { + const built = buildHarness({ + mode: 'edit', + name: 'inherits-base', + config: { translationsFolder: './i18n', locales: ['en', 'de'] }, + effectiveBaseLocale: 'en', + }); + component = built.fixture.componentInstance; + mockDialogRef = built.mockDialogRef; + }); + + it('should mark and lock the inherited base locale', () => { + expect(component.displayedBaseLocale).toBe('en'); + expect(component.isBaseLocale('en')).toBe(true); + expect(component.canRemoveLocale(0)).toBe(false); + expect(component.canRemoveLocale(1)).toBe(true); + }); + + it('should not write the inherited base locale into the collection on save', async () => { + await component.onSubmit(); + + const closeArg = mockDialogRef.close.mock.calls[0][0]; + expect(closeArg.config).not.toHaveProperty('baseLocale'); + }); }); diff --git a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.ts b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.ts index c51c6043..af3e648e 100644 --- a/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.ts +++ b/apps/tracker/src/app/collections/collection-form-dialog/collection-form-dialog.ts @@ -3,15 +3,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormGroup, FormControl, FormArray, Validators } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; -import { MatRadioModule } from '@angular/material/radio'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatChipsModule, type MatChipInputEvent } from '@angular/material/chips'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import type { MatChipInputEvent } from '@angular/material/chips'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; import { firstValueFrom } from 'rxjs'; import { isUnderNodeModules, normalizeProtectedTerms, normalizeTag, validateLocale } from '@simoncodes-ca/domain'; @@ -34,14 +30,10 @@ export interface CollectionFormResult { CommonModule, ReactiveFormsModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, MatButtonModule, - MatCheckboxModule, MatIconModule, - MatRadioModule, + MatSlideToggleModule, MatTooltipModule, - MatChipsModule, TranslocoModule, ], templateUrl: './collection-form-dialog.html', @@ -71,15 +63,21 @@ export class CollectionFormDialog implements OnInit { }); readonly addLocaleInput = new FormControl('', { nonNullable: true }); - readonly tagSeparatorKeyCodes = [ENTER, COMMA] as const; + /** The config file name, wrapped in our own `` so hints can set it in mono inside translated prose. */ + readonly configFileMarkup = '.lingo-tracker.json'; readonly tagsList = signal([]); readonly protectedTermsList = signal([]); /** The collection's `protectedTermsFile` pointer, preserved across an edit but not editable here. */ readonly protectedTermsFile = signal(undefined); /** Resolved path of that file, shown read-only so the source of a diff is obvious. */ readonly protectedTermsFilePath = signal(undefined); - /** Terms live in a file, so without a pointer there is nowhere to save them — the chips stay disabled. */ + /** Terms live in a file, so without a pointer there is nowhere to save them — the editor stays hidden. */ readonly canEditProtectedTerms = computed(() => this.protectedTermsFile() !== undefined); + /** + * Tags and protected terms are the rarely-touched part of a collection, so they sit behind a + * disclosure. It opens by itself when there is already something in it to look at. + */ + readonly advancedOpen = signal(false); #originalLocales: string[] = []; /** Tracks whether the user manually toggled read-only, so auto-detection stops overriding it. */ @@ -95,6 +93,29 @@ export class CollectionFormDialog implements OnInit { : TRACKER_TOKENS.COLLECTIONS.DIALOG.CREATE.TITLE; } + /** Whether the entered folder is under node_modules (drives the read-only hint). */ + get isNodeModulesPath(): boolean { + return isUnderNodeModules(this.form.controls.translationsFolder.value); + } + + get showNameError(): boolean { + const control = this.form.controls.name; + return control.hasError('required') && control.touched; + } + + get showFolderError(): boolean { + const control = this.form.controls.translationsFolder; + return control.hasError('required') && control.touched; + } + + /** The one line under the locale chips: what clicking does, what is locked, or what empty means. */ + get localesHintToken(): string { + if (this.isEditMode) return TRACKER_TOKENS.COLLECTIONS.DIALOG.BASEHINTEDIT; + return this.form.controls.locales.length > 0 + ? TRACKER_TOKENS.COLLECTIONS.DIALOG.BASEHINTCREATE + : TRACKER_TOKENS.COLLECTIONS.DIALOG.LOCALESINHERITHINT; + } + ngOnInit(): void { if (this.isEditMode && this.#data.config) { const configLocales = this.#data.config.locales ?? []; @@ -111,6 +132,7 @@ export class CollectionFormDialog implements OnInit { this.protectedTermsList.set(this.#data.config.protectedTerms ?? []); this.protectedTermsFile.set(this.#data.config.protectedTermsFile); this.protectedTermsFilePath.set(this.#data.config.protectedTermsFilePath); + this.advancedOpen.set(this.tagsList().length > 0 || this.protectedTermsList().length > 0); // An existing read-only flag is the user's prior choice — don't let auto-detection override it. this.#readOnlyTouchedByUser = this.#data.config.readOnly !== undefined; @@ -136,9 +158,35 @@ export class CollectionFormDialog implements OnInit { this.#readOnlyTouchedByUser = true; } - /** Whether the entered folder is under node_modules (drives the read-only hint). */ - get isNodeModulesPath(): boolean { - return isUnderNodeModules(this.form.controls.translationsFolder.value); + toggleAdvanced(): void { + this.advancedOpen.update((open) => !open); + } + + /** + * The locale shown as BASE. In edit mode a collection without its own `baseLocale` inherits the + * global one, so the inherited value is what gets marked and locked; only an explicit choice + * is ever written back. + */ + get displayedBaseLocale(): string { + const own = this.form.controls.baseLocale.value; + if (own) return own; + return this.isEditMode ? (this.#data.effectiveBaseLocale ?? '') : ''; + } + + isBaseLocale(locale: string): boolean { + return this.displayedBaseLocale === locale; + } + + /** The base locale is a create-time decision; after that it anchors every checksum and is locked. */ + setBaseLocale(locale: string): void { + if (this.isEditMode) return; + if (!this.form.controls.locales.getRawValue().includes(locale)) return; + this.form.controls.baseLocale.setValue(locale); + } + + canRemoveLocale(index: number): boolean { + const locale = this.form.controls.locales.at(index)?.value; + return !(this.isEditMode && locale === this.displayedBaseLocale); } addLocale(): void { @@ -170,11 +218,42 @@ export class CollectionFormDialog implements OnInit { this.addLocaleInput.setValue(''); } - addCollectionTag(event: MatChipInputEvent): void { - const normalized = normalizeTag(event.value); + /** Leaving the input with a locale typed but not confirmed should not silently drop it. */ + addLocaleIfPending(): void { + if (this.addLocaleInput.value.trim()) { + this.addLocale(); + } + } + + /** Enter or comma commits the typed tag or term, like the Material chip input it replaces. */ + onChipInputKeydown(event: KeyboardEvent, input: HTMLInputElement, kind: 'tag' | 'term'): void { + if (event.key !== 'Enter' && event.key !== ',') return; + event.preventDefault(); + this.commitChipInput(input, kind); + } + + commitChipInput(input: HTMLInputElement, kind: 'tag' | 'term'): void { + if (!input.value.trim()) { + input.value = ''; + return; + } + if (kind === 'tag') { + this.addTagValue(input.value); + } else { + this.addProtectedTermValue(input.value); + } + input.value = ''; + } + + addTagValue(value: string): void { + const normalized = normalizeTag(value); if (normalized && !this.tagsList().includes(normalized)) { this.tagsList.update((tags) => [...tags, normalized]); } + } + + addCollectionTag(event: MatChipInputEvent): void { + this.addTagValue(event.value); event.chipInput?.clear(); } @@ -186,11 +265,15 @@ export class CollectionFormDialog implements OnInit { * Adds a protected term, trimming and deduping case-sensitively while preserving * the entered casing and punctuation (`iPhone`, `Node.js`, `C++` stay verbatim). */ - addProtectedTerm(event: MatChipInputEvent): void { - const [term] = normalizeProtectedTerms([event.value]); + addProtectedTermValue(value: string): void { + const [term] = normalizeProtectedTerms([value]); if (term && !this.protectedTermsList().includes(term)) { this.protectedTermsList.update((terms) => [...terms, term]); } + } + + addProtectedTerm(event: MatChipInputEvent): void { + this.addProtectedTermValue(event.value); event.chipInput?.clear(); } @@ -199,7 +282,7 @@ export class CollectionFormDialog implements OnInit { } removeLocale(index: number): void { - if (this.isEditMode && this.form.controls.locales.at(index).value === this.form.controls.baseLocale.value) { + if (!this.canRemoveLocale(index)) { return; } @@ -217,7 +300,10 @@ export class CollectionFormDialog implements OnInit { } async onSubmit(): Promise { - if (this.form.invalid) return; + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } if (this.isEditMode) { const localesArray = this.form.controls.locales.getRawValue(); diff --git a/apps/tracker/src/app/collections/collections-manager.html b/apps/tracker/src/app/collections/collections-manager.html index eb81b8d5..94d807c7 100644 --- a/apps/tracker/src/app/collections/collections-manager.html +++ b/apps/tracker/src/app/collections/collections-manager.html @@ -2,53 +2,24 @@

{{ store.error() }}

- @@ -94,124 +60,260 @@

{{ TOKENS.COLLECTIONS.TITLE | transloco }}

translate

{{ TOKENS.COLLECTIONS.EMPTYSTATE.MESSAGE | transloco }}

- } - -@if (hasNoMatches() && !store.isLoading() && !store.error()) { -
- -

- {{ TOKENS.COLLECTIONS.FILTER.NORESULTSX | transloco: { query: filter().trim() } }} -

- -
-} + +@if (store.hasCollections() && !store.isLoading() && !store.error()) { +
+ + - -@if (cards().length > 0 && !store.isLoading()) { -
- @for (card of cards(); track card.name; let i = $index) { -
-
- +
+
+
+

{{ TOKENS.COLLECTIONS.TITLE | transloco }}

+ {{ store.collectionEntries().length }} +
-
-

{{ card.name }}

-

- {{ card.translationsFolder }} -

-
+
+ @if (showFilter()) { +
+ + + @if (isFiltering()) { + + } +
+ } + + +
+
-
+ + @if (hasNoMatches()) { +
+ +

+ {{ TOKENS.COLLECTIONS.FILTER.NORESULTSX | transloco: { query: filter().trim() } }} +

+ +
+ } + + + @if (cards().length > 0) { +
+ @for (card of cards(); track card.name; let i = $index) { +
+
+ + +
+

{{ card.name }}

+

+ {{ card.translationsFolder }} +

+
+ +
+ + + + + + +
+
+ + @if (card.readOnly) { +

+ + {{ TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco }} +

+ } + + +
+ } +
+ } +
+ + +
- @if (card.readOnly) { -

- - {{ TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco }} -

+
+ @if (!store.hasBundles()) { +
+ +
+

{{ TOKENS.BUNDLES.EMPTY.TITLE | transloco }}

+

{{ TOKENS.BUNDLES.EMPTY.MESSAGE | transloco }}

+
+ +
} - -
- } + @for (card of bundleCards(); track card.entry.name) { + + } +
+
} diff --git a/apps/tracker/src/app/collections/collections-manager.scss b/apps/tracker/src/app/collections/collections-manager.scss index a08652ed..3b724c79 100644 --- a/apps/tracker/src/app/collections/collections-manager.scss +++ b/apps/tracker/src/app/collections/collections-manager.scss @@ -19,7 +19,7 @@ // Page header // --------------------------------------------------------------------------- .page-header { - margin-bottom: var(--spacing-6); + margin-bottom: var(--spacing-8); position: relative; z-index: 1; } @@ -54,13 +54,355 @@ font-size: var(--font-size-sm); font-variant-numeric: tabular-nums; margin: 0; + + .sep { + color: var(--color-text-tertiary); + margin: 0 6px; + } +} + +// Inline code in prose: mono at the surrounding size, ink, no background. +.mono { + background: none; + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: 0.95em; + padding: 0; +} + +.num { + font-variant-numeric: tabular-nums; +} + +// --------------------------------------------------------------------------- +// Split view + section headers +// --------------------------------------------------------------------------- +.split { + align-items: start; + display: grid; + gap: var(--spacing-10); + grid-template-columns: minmax(0, 1fr) 420px; + position: relative; + z-index: 1; +} + +.collections-col { + min-width: 0; +} + +.bundles-col { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; + position: sticky; + top: var(--spacing-6); + // Cap the sticky column so an expanded result strip can never push a card + // (or its Generate button) below the fold with no way to reach it. + max-height: calc(100vh - var(--spacing-6) * 2); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--color-border-strong) transparent; + z-index: 1; +} + +// --------------------------------------------------------------------------- +// Bundle connector lines +// --------------------------------------------------------------------------- +// Spans the whole split at z-index 0. Cards sit at 1 and carry an opaque ground, so a +// line crossing the inner column of collection cards runs behind it and re-emerges in +// the gutter rather than scribbling across another card's content. +.link-layer { + height: 100%; + left: 0; + overflow: visible; + pointer-events: none; + position: absolute; + top: 0; + width: 100%; + z-index: 0; +} + +// `pathLength="1"` normalises every curve, so one dash length draws them all regardless +// of how far the card is from the bundle. +.link-line { + // Literal duration: the shared transition tokens bundle their own easing, and this + // wants the exponential ease-out the cards enter with. + animation: link-draw 340ms cubic-bezier(0.16, 1, 0.3, 1) backwards; + fill: none; + stroke: color-mix(in srgb, var(--color-primary) 85%, transparent); + stroke-dasharray: 1; + stroke-dashoffset: 0; + stroke-linecap: round; + stroke-width: 1.5; +} + +@keyframes link-draw { + from { + stroke-dashoffset: 1; + } +} + +// Solid plug at the collection end, hollow socket at the bundle end: the direction of +// the relationship is legible even where the line itself runs behind a card. +.link-node, +.link-port { + animation: link-fade 200ms ease-out backwards; +} + +.link-node { + fill: var(--color-primary); +} + +.link-port { + fill: var(--card-surface); + stroke: var(--color-primary); + stroke-width: 2; +} + +@keyframes link-fade { + from { + opacity: 0; + } } -.header-tools { +.section-head { + align-items: flex-end; + display: flex; + position: relative; + z-index: 1; + flex-wrap: wrap; + gap: var(--spacing-4); + justify-content: space-between; + margin-bottom: var(--spacing-5); + + // The column's own flex gap already supplies --spacing-4; this tops it up to the + // --spacing-5 the collections grid sits at, so both stacks start on the same line. + &--bundles { + margin-bottom: var(--spacing-1); + } +} + +.section-title { + align-items: baseline; + display: flex; + gap: var(--spacing-3); + min-width: 0; + + h2 { + color: var(--color-text-primary); + font-family: var(--font-family-body); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-semibold); + letter-spacing: -0.01em; + line-height: var(--line-height-tight); + margin: 0; + } +} + +.section-count { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +// Controls take a row of their own under the heading in both columns. The 420px bundles +// column cannot hold its heading and two buttons on one line in any locale, and letting +// only that head wrap would start the two card stacks at different heights and put the +// two h2s on different lines. Giving both heads the same two-row shape settles both. +.section-tools { align-items: center; display: flex; + flex-basis: 100%; flex-wrap: wrap; gap: var(--spacing-3); + justify-content: flex-end; +} + + +.bundles-stack { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +// Hand-rolled buttons for the bundles column (the comp's .btn recipes), real