diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 3a7b5b2..ab7ecb5 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -365,8 +365,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { patch({ trailing_str: e.target.value })} /> - {/* Additional OR searches: a QSO earns a reference if the - primary rule OR any of these match. */} + {/* Fallback searches: tried in order, only while nothing + has matched yet — the first that hits wins (short-circuit), + so a value already resolved by the primary rule isn't + re-derived differently by a later one. */}

{t('awed.additionalSearches')} (OR) {t('awed.orAlsoMatch')}

diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c92aaf1..f8dace8 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -344,6 +344,9 @@ function TreeNodeView({ // Select and slamming the open dropdown shut on any ambient re-render. const THEME_SWATCH: Record, { bg: string; card: string; accent: string }> = { 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, + 'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' }, + 'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' }, + 'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' }, 'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' }, 'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' }, 'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' }, diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index b04cc62..f149a82 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -47,7 +47,8 @@ const en: Dict = { 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.', 'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.', - 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark', + 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light', + 'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark', 'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast', // Settings navigation (sidebar groups + section names) 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', @@ -213,7 +214,7 @@ const en: Dict = { 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', - 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', + 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', // QSO modals (context menu / bulk edit / QSL manager / QSO edit) 'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…', 'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}', @@ -256,7 +257,8 @@ const fr: Dict = { 'lang.english': 'English', 'lang.french': 'Français', 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", 'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.", - 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud', + 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid', + 'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud', 'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé', 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", @@ -403,7 +405,7 @@ const fr: Dict = { 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', - 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', + 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', 'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}', 'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}', diff --git a/frontend/src/lib/theme.tsx b/frontend/src/lib/theme.tsx index 5722f56..afbe388 100644 --- a/frontend/src/lib/theme.tsx +++ b/frontend/src/lib/theme.tsx @@ -5,11 +5,11 @@ import { writeUiPref } from './uiPref'; // CSS variables in style.css key off of. 'auto' follows the OS light/dark // preference. The choice is persisted (localStorage + portable UI pref, so it // travels with the data/ folder like the language). -export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast'; +export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast'; // Selectable, concrete themes (excludes 'auto') in display order. export const CONCRETE_THEMES: Exclude[] = [ - 'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast', + 'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast', ]; export const LS_KEY = 'opslog.theme'; diff --git a/frontend/src/style.css b/frontend/src/style.css index 918bda8..5503f93 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -92,6 +92,207 @@ color-scheme: light; } +/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */ +[data-theme="light-cool"] { + --background: #f4f6f8; /* cool blue-white */ + --foreground: #0f172a; /* slate-900 */ + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #2563eb; /* blue-600 */ + --primary-foreground: #ffffff; + --secondary: #e2e8f0; /* slate-200 */ + --secondary-foreground: #0f172a; + --muted: #e6ebf1; /* toolbars / table headers */ + --muted-foreground: #475569; /* slate-600 — readable on muted */ + --accent: #dbeafe; /* blue-100 */ + --accent-foreground: #1e3a8a; /* blue-900 */ + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --destructive-muted: #fef2f2; + --destructive-muted-foreground: #b91c1c; + --border: #d3dae2; /* cool slate border */ + --input: #d3dae2; + --ring: #3b82f6; /* blue-500 focus ring */ + + --success: #16a34a; + --success-foreground: #ffffff; + --success-muted: #dcfce7; + --success-muted-foreground: #166534; + --success-border: #86efac; + + --warning: #d97706; + --warning-foreground: #ffffff; + --warning-muted: #fef3c7; + --warning-muted-foreground: #92400e; + --warning-border: #fcd34d; + + --caution: #ca8a04; + --caution-foreground: #1c1917; + --caution-muted: #fef9c3; + --caution-muted-foreground: #854d0e; + --caution-border: #fde047; + + --danger: #e11d48; + --danger-foreground: #ffffff; + --danger-muted: #ffe4e6; + --danger-muted-foreground: #9f1239; + --danger-border: #fda4af; + + --info: #0284c7; + --info-foreground: #ffffff; + --info-muted: #e0f2fe; + --info-muted-foreground: #075985; + --info-border: #7dd3fc; + + /* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */ + --mx-call-conf: #15803d; + --mx-call-work: #6ee7b7; + --mx-dx-conf: #3730a3; + --mx-dx-work: #a5b4fc; + --mx-none: #e2e8f0; + + --scrollbar-thumb: #b6c2d1; + --scrollbar-thumb-hover: #8fa1b5; + --card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02); + color-scheme: light; +} + +/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */ +[data-theme="light-sage"] { + --background: #eef1ec; /* pale sage */ + --foreground: #1a2420; /* deep pine-ink */ + --card: #f8faf6; /* lifted off-white with a green cast */ + --card-foreground: #1a2420; + --popover: #f8faf6; + --popover-foreground: #1a2420; + --primary: #2f855a; /* deep sage green */ + --primary-foreground: #f0fdf4; + --secondary: #dde5db; + --secondary-foreground: #1a2420; + --muted: #dde5db; /* toolbars / table headers */ + --muted-foreground: #445048; /* muted pine — readable on muted */ + --accent: #d7e8d5; /* soft green */ + --accent-foreground: #1e4634; /* forest-900 */ + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --destructive-muted: #fef2f2; + --destructive-muted-foreground: #b91c1c; + --border: #cdd8ce; /* soft sage border */ + --input: #cdd8ce; + --ring: #38a169; /* green focus ring */ + + --success: #16a34a; + --success-foreground: #ffffff; + --success-muted: #dcfce7; + --success-muted-foreground: #166534; + --success-border: #86efac; + + --warning: #d97706; + --warning-foreground: #ffffff; + --warning-muted: #fef3c7; + --warning-muted-foreground: #92400e; + --warning-border: #fcd34d; + + --caution: #ca8a04; + --caution-foreground: #1c1917; + --caution-muted: #fef9c3; + --caution-muted-foreground: #854d0e; + --caution-border: #fde047; + + --danger: #e11d48; + --danger-foreground: #ffffff; + --danger-muted: #ffe4e6; + --danger-muted-foreground: #9f1239; + --danger-border: #fda4af; + + --info: #0284c7; + --info-foreground: #ffffff; + --info-muted: #e0f2fe; + --info-muted-foreground: #075985; + --info-border: #7dd3fc; + + /* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */ + --mx-call-conf: #15803d; + --mx-call-work: #6ee7b7; + --mx-dx-conf: #3730a3; + --mx-dx-work: #a5b4fc; + --mx-none: #dde5db; + + --scrollbar-thumb: #b3c2b3; + --scrollbar-thumb-hover: #91a591; + --card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02); + color-scheme: light; +} + +/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */ +[data-theme="dim-slate"] { + --background: #343b47; /* medium slate — light text, not deep dark */ + --foreground: #eceef2; + --card: #3d4552; /* lifted panel */ + --card-foreground: #eceef2; + --popover: #3d4552; + --popover-foreground: #eceef2; + --primary: #fb923c; /* orange-400 — brand warmth, pops on slate */ + --primary-foreground: #241206; + --secondary: #454e5c; + --secondary-foreground: #eceef2; + --muted: #414a58; /* toolbars / table headers */ + --muted-foreground: #b4bcc9; /* readable on muted */ + --accent: #495468; /* hover / selection tint */ + --accent-foreground: #dce3ef; + --destructive: #ef4444; + --destructive-foreground: #1a0808; + --destructive-muted: #3f1f1f; + --destructive-muted-foreground: #fca5a5; + --border: #4e5867; + --input: #4e5867; + --ring: #fdba74; /* orange-300 focus ring */ + + --success: #34d399; + --success-foreground: #06231a; + --success-muted: #1c3d34; + --success-muted-foreground: #6ee7b7; + --success-border: #2a5f4c; + + --warning: #fbbf24; + --warning-foreground: #241a06; + --warning-muted: #41371a; + --warning-muted-foreground: #fcd34d; + --warning-border: #5e4d22; + + --caution: #facc15; + --caution-foreground: #242006; + --caution-muted: #403b1a; + --caution-muted-foreground: #fde047; + --caution-border: #5b5222; + + --danger: #fb7185; + --danger-foreground: #260a11; + --danger-muted: #421f28; + --danger-muted-foreground: #fda4af; + --danger-border: #63303c; + + --info: #38bdf8; + --info-foreground: #061f2b; + --info-muted: #16384a; + --info-muted-foreground: #7dd3fc; + --info-border: #1d5670; + + /* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */ + --mx-call-conf: #22c55e; + --mx-call-work: #2f7d55; + --mx-dx-conf: #6366f1; + --mx-dx-work: #3f3f8a; + --mx-none: #4a5262; + + --scrollbar-thumb: #565f70; + --scrollbar-thumb-hover: #6b7588; + --card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05); + color-scheme: dark; +} + /* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */ [data-theme="dark-warm"] { --background: #221d18; diff --git a/internal/award/award.go b/internal/award/award.go index ae27847..7ecf83c 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -74,10 +74,13 @@ type Def struct { Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes - // OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO - // earns a reference if the primary match OR any of these match. Lets a - // French department (DDFM) be found from "D74" in the note AND from a postal - // code "74140" in the address (pattern captures "74", Prefix "D" → "D74"). + // OrRules are ordered FALLBACK searches for the primary one above: they are + // tried IN ORDER and only while nothing has matched yet — the first rule that + // yields a reference wins and the rest are skipped (short-circuit, like a + // chain of "else if"). Lets a Chinese province (WAPC) be found first from the + // province NAME in the address, and only if that fails, from a city regex + // (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also + // re-tagged, possibly differently, by a later rule. OrRules []OrRule `json:"or_rules,omitempty"` // --- Scope --- @@ -455,17 +458,7 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string { } } rl := NewRefList(metas) - found := candidates(&d, re, q, rl, len(metas) > 0) - // Merge operator-assigned references (manual override). These let the - // operator tag a QSO for an award whose field/description matching can't - // auto-detect the reference — e.g. WAPC scans the ADDRESS field for a - // province NAME, so a contact whose address doesn't spell it out needs the - // province picked by hand. For a predefined award the override is still - // validated against its reference list. - if man := manualRefs(q, d.Code); len(man) > 0 { - found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic) - } - return found + return candidates(&d, re, q, rl, len(metas) > 0) } // ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO, @@ -500,30 +493,6 @@ func manualRefs(q *qso.QSO, code string) []string { return out } -// mergeManual appends operator-assigned codes to the auto-found set, deduped. -// When the award is predefined, only references present and valid in its list -// are kept (so a typo can't invent a reference). -func mergeManual(found, manual []string, rl refList, predefined bool) []string { - seen := map[string]struct{}{} - for _, c := range found { - seen[normalizeRef(c)] = struct{}{} - } - for _, c := range manual { - c = normalizeRef(c) - if _, dup := seen[c]; dup { - continue - } - if predefined { - if m, ok := rl.byCode[c]; !ok || !m.Valid { - continue - } - } - seen[c] = struct{}{} - found = append(found, c) - } - return found -} - // Confirmed reports whether a QSO satisfies any of the given confirmation // sources (lotw|qsl|eqsl). Exported for the statistics view. func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) } @@ -620,9 +589,12 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { predefined := hasList && !d.Dynamic - // Primary search, then each OR rule — a QSO earns a reference if any matches. + // Primary search first; the OR rules are ordered FALLBACKS — try the next + // only while nothing has matched yet, and stop at the first that yields a + // reference (short-circuit). So a province already found by NAME isn't also + // re-derived, possibly differently, from a later city-regex rule. found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined) - for i := range d.OrRules { + for i := 0; len(found) == 0 && i < len(d.OrRules); i++ { r := &d.OrRules[i] var rre *regexp.Regexp if p := strings.TrimSpace(r.Pattern); p != "" { @@ -632,7 +604,18 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) } rre = c } - found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...) + found = searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined) + } + + // Merge operator-assigned references (manual override, ManualRefsKey). Lets + // the operator tag a QSO for an award whose field/description matching can't + // auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so + // a contact whose address doesn't spell it out needs the province picked by + // hand. Applied HERE (not just in MatchQSO) so Compute — which powers the + // awards panel and the per-QSO refs editor — honours overrides too. For a + // predefined award the ref is still validated against the list below. + for _, c := range manualRefs(q, d.Code) { + found = append(found, normalizeRef(c)) } if !predefined { diff --git a/internal/award/award_test.go b/internal/award/award_test.go index 6758cbc..3f1754b 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -128,6 +128,81 @@ func TestComputeMatchByDescription(t *testing.T) { } } +// OR rules are ordered fallbacks: the primary match wins and short-circuits the +// rules; when the primary finds nothing, the first OR rule that hits wins and +// later rules are skipped. DDFM-style: a French department from the NOTE first, +// else captured from a postal code in the address (Prefix "D" → "D74"). +func TestComputeOrRuleFallback(t *testing.T) { + def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`, + DXCCFilter: []int{227}, Valid: true, + OrRules: []OrRule{ + {Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"}, + }, + } + refMetas := map[string][]RefMeta{"DDFM": { + {Code: "D74", Name: "Haute-Savoie", Valid: true}, + {Code: "D75", Name: "Paris", Valid: true}, + }} + worked := func(q qso.QSO) []string { + r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0] + var out []string + for _, rf := range r.Refs { + if rf.Worked { + out = append(out, rf.Ref) + } + } + return out + } + // Primary (note "D74") matches → the postal OR rule is skipped, so the + // address's 75001 does NOT also add D75. Exactly the user's ask: first + // condition wins, no fall-through. + if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" { + t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got) + } + // No note dept → fall through to the OR rule → postal 74140 → D74. + if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" { + t.Errorf("OR fallback: got %v, want [D74]", got) + } +} + +// A manually-assigned override (ManualRefsKey) must count in Compute — not just +// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes +// no QSO field) sticks after the operator picks the province by hand. The engine +// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so +// the ref exists solely as the override. +func TestComputeManualOverride(t *testing.T) { + def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description", + DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true} + qsos := []qso.QSO{ + // Address doesn't name the province → only the override tags it. + {Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5", + Extras: map[string]string{ManualRefsKey: "WAPC@JS"}}, + } + refMetas := map[string][]RefMeta{"WAPC": { + {Code: "JS", Name: "Jiangsu", Valid: true}, + {Code: "GD", Name: "Guangdong", Valid: true}, + }} + r := Compute([]Def{def}, qsos, refMetas, nil)[0] + if r.Worked != 1 { + t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r)) + } + var worked []string + for _, rf := range r.Refs { + if rf.Worked { + worked = append(worked, rf.Ref) + } + } + if len(worked) != 1 || worked[0] != "JS" { + t.Errorf("worked refs = %v, want [JS]", worked) + } + + // An override for a ref NOT in the predefined list is rejected (no typo refs). + qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ" + if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 { + t.Errorf("bogus override ZZ: worked = %d, want 0", w) + } +} + // A description-match award must also honour a REFERENCE's own regex (used to // broaden matching past the plain name) AND match case-insensitively — a log // QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo