diff --git a/AGENTS.md b/AGENTS.md index 7f5f0d7..d410344 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,23 @@ as project instructions, not as optional documentation. proof. - Do not hand-edit generated frontend output. +## Phone Configurator parity (mandatory) + +- Every new, changed, renamed, moved, or removed configurable value in + `sky_phone/config/config.lua` or `sky_phone/config/media.lua` **must** be + reflected in the in-game Phone Configurator in the same change. Config-only + changes without matching Configurator support are incomplete and must not be + committed or merged. +- Keep the Configurator's runtime schema, defaults, value types, fixed versus + extensible collection rules, labels, descriptions, English and German + locales, SQL persistence, validation, and save/load roundtrip aligned with + the Lua configuration. +- The Configurator must remain complete when file-based configuration is + disabled. Do not introduce a setting that can only be managed by editing Lua + after `Config.PhoneConfigurator.Enabled` is enabled. +- Add or update contract/fixture coverage so configuration parity regressions + fail automated checks. + ## Working method and verification 1. Trace the relevant client, server, NUI, configuration, persistence, and event diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue index efb4d11..a04ecdb 100644 --- a/frontend/src/components/AdminConfigValueEditor.vue +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -8,6 +8,7 @@ import { } from 'lucide-vue-next' import { computed, ref } from 'vue' +import { vConfigInputWidth } from '@/directives/configInputWidth' import type { AdminConfiguratorStructure } from '@/types/admin' import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescription' @@ -48,6 +49,7 @@ const props = withDefaults( modelValue: unknown path?: string structure?: AdminConfiguratorStructure + tabLabel?: (key: string, value: unknown) => string }>(), { ariaLabel: '', depth: 0, disabled: false, path: '' }, ) @@ -163,7 +165,7 @@ const rootTableTabs = computed(() => { count: configuratorStructureSize(tableFieldStructure(key)), id: `field:${key}`, key, - label: key, + label: props.tabLabel?.(key, tableValue.value[key]) ?? key, })), ] }) @@ -666,6 +668,7 @@ function mapEntryStructure( :aria-label="`${ariaLabel} ${index + 1}`" :describe="describe" :labels="labels" + :tab-label="tabLabel" :disabled="disabled" :depth="depth + 1" :path="listEntryPath(index)" @@ -797,6 +800,7 @@ function mapEntryStructure( :aria-label="`${ariaLabel} ${activeRootTableField.key}`" :describe="describe" :labels="labels" + :tab-label="tabLabel" :disabled="disabled" :depth="depth + 1" :path="tableEntryPath(activeRootTableField.key)" @@ -871,6 +875,7 @@ function mapEntryStructure( :aria-label="`${ariaLabel} ${entry.key}`" :describe="describe" :labels="labels" + :tab-label="tabLabel" :disabled="disabled" :depth="depth + 1" :path="mapValuePath(entry)" @@ -953,6 +958,7 @@ function mapEntryStructure( :aria-label="`${ariaLabel} ${key}`" :describe="describe" :labels="labels" + :tab-label="tabLabel" :disabled="disabled" :depth="depth + 1" :path="tableEntryPath(key)" @@ -1059,6 +1065,7 @@ function mapEntryStructure( class="config-value-optional" > { expect(source).not.toContain(' { }) it('loads the SQL phone configurator before framework-owned configuration is read', () => { + expect(agentInstructions).toContain('Phone Configurator parity (mandatory)') + expect(agentInstructions).toContain( + 'changes without matching Configurator support are incomplete', + ) expect(config).toMatch( /Config\.PhoneConfigurator\s*=\s*\{[\s\S]*?Enabled\s*=\s*false[\s\S]*?Config\.Bridge\s*=/, ) @@ -313,6 +339,21 @@ describe('standalone admin panel contracts', () => { expect(configuratorValueEditor).toContain( 'class="config-structured-editor__tab-panel"', ) + expect(source).toContain(':tab-label="configuratorSubtabLabel"') + expect(source).toContain('configurator.table.subtabs.${key}') + expect(configuratorValueEditor).toContain( + 'props.tabLabel?.(key, tableValue.value[key]) ?? key', + ) + expect(source).toContain('v-config-input-width') + expect(configuratorValueEditor).toContain('v-config-input-width') + expect(configInputWidth).toContain("input.addEventListener('input'") + expect(configInputWidth).toContain('input.scrollWidth') + expect(source).toContain('filter: drop-shadow') + expect(source).toContain("input[type='number']::-webkit-inner-spin-button") + expect(configuratorValueEditor).toContain( + "input[type='number']::-webkit-inner-spin-button", + ) + expect(source).toContain('justify-self: start') expect(configuratorValueEditor).toContain('function tableFieldStructure(') expect(configuratorValueEditor).toContain('function isFixedTableField(') expect(configuratorValueEditor).toContain('function blankCollectionValue(') diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index f9a6ee0..608242c 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -3,6 +3,7 @@ import { BadgeDollarSign, BriefcaseBusiness, Check, + ChevronDown, ChevronRight, CircleUserRound, Clipboard, @@ -42,6 +43,7 @@ import { isPhoneAppRemovable, PHONE_APPS, } from '@/config/apps' +import { vConfigInputWidth } from '@/directives/configInputWidth' import { useAdminStore } from '@/stores/admin' import { usePhoneStore } from '@/stores/phone' import type { @@ -53,7 +55,10 @@ import type { } from '@/types/admin' import type { LaunchablePhoneAppDefinition } from '@/types/apps' import { SkyButton } from '@/ui' -import { describeConfiguratorValue } from '@/utils/adminConfiguratorDescription' +import { + configuratorPathName, + describeConfiguratorValue, +} from '@/utils/adminConfiguratorDescription' import { copyText } from '@/utils/clipboard' import { parseDatabaseDate } from '@/utils/date' import { nuiCall } from '@/utils/nui' @@ -76,6 +81,16 @@ type AdminTab = type ConfiguratorScope = 'config' | 'media' type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset' type PendingAction = { kind: 'close' } | { kind: 'player'; source: number } +type AccentChannel = 'blue' | 'green' | 'red' +type AdminFontFamily = + | 'classic' + | 'georgia' + | 'inter' + | 'mono' + | 'system' + | 'tahoma' + | 'trebuchet' + | 'verdana' const emit = defineEmits<{ close: [] }>() const admin = useAdminStore() @@ -94,6 +109,7 @@ const revealDialogImei = ref('') const deviceAction = ref(null) const deviceActionInput = ref('') const discardDialog = ref(false) +const fontMenuOpen = ref(false) const pendingAction = ref(null) const toast = ref('') const toastTone = ref<'error' | 'success'>('success') @@ -106,7 +122,48 @@ const accentOptions = [ { color: '#f0a24b', key: 'orange' }, { color: '#ef6969', key: 'red' }, ] as const -const accentColor = ref<(typeof accentOptions)[number]['color']>('#74d66f') +const accentChannels = ['red', 'green', 'blue'] as const +const fontFamilyOptions: Array<{ + key: AdminFontFamily + value: string +}> = [ + { key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' }, + { + key: 'system', + value: + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, + { key: 'classic', value: 'Arial, Helvetica, sans-serif' }, + { key: 'verdana', value: 'Verdana, Geneva, sans-serif' }, + { key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' }, + { + key: 'trebuchet', + value: '"Trebuchet MS", "Lucida Grande", sans-serif', + }, + { key: 'georgia', value: 'Georgia, "Times New Roman", serif' }, + { + key: 'mono', + value: + 'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace', + }, +] +const accentColor = ref('#74d66f') +const adminFontFamily = ref('inter') +const adminFontSize = ref(100) +const adminFontSizePreview = ref(100) +const activeAdminFontFamily = computed( + () => + fontFamilyOptions.find((option) => option.key === adminFontFamily.value) + ?.value ?? fontFamilyOptions[0].value, +) +const accentRgb = computed(() => { + const color = accentColor.value.replace('#', '') + return { + red: Number.parseInt(color.slice(0, 2), 16), + green: Number.parseInt(color.slice(2, 4), 16), + blue: Number.parseInt(color.slice(4, 6), 16), + } +}) const filteredPlayers = computed(() => { const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang) @@ -351,6 +408,32 @@ function t(key: string, params?: Record): string { return phone.t('AdminPanel.' + key, params) } +function configuratorLocaleText(key: string, fallback: string): string { + const translated = t(key) + return translated === key || translated === `AdminPanel.${key}` + ? fallback + : translated +} + +function configuratorSubtabLabel(key: string, value: unknown): string { + const record = + value !== null && typeof value === 'object' + ? (value as Record) + : null + if ( + /^[a-z0-9_-]+$/.test(key) && + typeof record?.Name === 'string' && + record.Name + ) { + return record.Name + } + const fallback = configuratorPathName(key) + return configuratorLocaleText( + `configurator.table.subtabs.${key}`, + fallback.charAt(0).toLocaleUpperCase(phone.lang) + fallback.slice(1), + ) +} + function selectConfiguratorScope(scope: ConfiguratorScope): void { configuratorScope.value = scope configuratorQuery.value = '' @@ -375,7 +458,7 @@ const configuratorEditorLabels = computed(() => ({ emptyList: t('configurator.table.emptyList'), emptyTable: t('configurator.table.emptyTable'), entry: t('configurator.table.entry'), - general: t('configurator.table.general'), + general: configuratorLocaleText('configurator.table.general', 'General'), keyPlaceholder: t('configurator.table.keyPlaceholder'), list: t('configurator.table.list'), remove: t('configurator.table.remove'), @@ -502,6 +585,7 @@ function queueAction(action: PendingAction): void { function selectTab(nextTab: AdminTab): void { tab.value = nextTab + fontMenuOpen.value = false if (nextTab === 'configurator' && !admin.configurator) { void admin.loadConfigurator().then((loaded) => { if (!loaded) showToast(errorText(), 'error') @@ -509,9 +593,61 @@ function selectTab(nextTab: AdminTab): void { } } -function selectAccent(color: (typeof accentOptions)[number]['color']): void { - accentColor.value = color - window.localStorage.setItem('sky-phone-admin-accent', color) +function selectAccent(color: string): void { + if (!/^#[0-9a-f]{6}$/i.test(color)) return + accentColor.value = color.toLowerCase() + window.localStorage.setItem('sky-phone-admin-accent', accentColor.value) +} + +function updateAccent(event: Event): void { + const input = event.currentTarget as HTMLInputElement + if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value) + else input.value = accentColor.value.toUpperCase() +} + +function updateAccentChannel(channel: AccentChannel, event: Event): void { + const input = event.currentTarget as HTMLInputElement + const value = Math.min(255, Math.max(0, Number(input.value) || 0)) + const channels = { ...accentRgb.value, [channel]: value } + selectAccent( + `#${channels.red.toString(16).padStart(2, '0')}${channels.green + .toString(16) + .padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`, + ) +} + +function accentChannelGradient(channel: AccentChannel): string { + const { red, green, blue } = accentRgb.value + if (channel === 'red') { + return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))` + } + if (channel === 'green') { + return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))` + } + return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))` +} + +function selectAdminFontFamily(family: AdminFontFamily): void { + adminFontFamily.value = family + fontMenuOpen.value = false + window.localStorage.setItem('sky-phone-admin-font-family', family) +} + +function closeFontMenu(event: FocusEvent): void { + const menu = event.currentTarget as HTMLElement + if (!menu.contains(event.relatedTarget as Node | null)) { + fontMenuOpen.value = false + } +} + +function updateAdminFontSize(event: Event): void { + const size = Number((event.currentTarget as HTMLInputElement).value) + adminFontSize.value = Math.min(115, Math.max(90, size)) + adminFontSizePreview.value = adminFontSize.value + window.localStorage.setItem( + 'sky-phone-admin-font-size', + String(adminFontSize.value), + ) } async function runAction(action: PendingAction): Promise { @@ -772,6 +908,10 @@ function auditDescription(entry: AdminAuditEntry): string { function onKeydown(event: KeyboardEvent): void { if (event.key !== 'Escape') return event.preventDefault() + if (fontMenuOpen.value) { + fontMenuOpen.value = false + return + } if (revealDialogImei.value) { revealDialogImei.value = '' return @@ -792,10 +932,25 @@ function onKeydown(event: KeyboardEvent): void { onMounted(() => { document.addEventListener('keydown', onKeydown) const storedAccent = window.localStorage.getItem('sky-phone-admin-accent') - const storedOption = accentOptions.find( - (option) => option.color === storedAccent, + if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) { + accentColor.value = storedAccent.toLowerCase() + } + const storedFontFamily = window.localStorage.getItem( + 'sky-phone-admin-font-family', ) - if (storedOption) accentColor.value = storedOption.color + if ( + storedFontFamily && + fontFamilyOptions.some((option) => option.key === storedFontFamily) + ) { + adminFontFamily.value = storedFontFamily as AdminFontFamily + } + const storedFontSize = Number( + window.localStorage.getItem('sky-phone-admin-font-size'), + ) + if (storedFontSize >= 90 && storedFontSize <= 115) { + adminFontSize.value = storedFontSize + adminFontSizePreview.value = storedFontSize + } void refreshData() }) @@ -810,7 +965,11 @@ onBeforeUnmount(() => { class="admin-panel-overlay" role="dialog" :aria-label="t('name')" - :style="{ '--admin-accent': accentColor }" + :style="{ + '--admin-accent': accentColor, + '--admin-font-family': activeAdminFontFamily, + '--admin-font-scale': String(adminFontSize / 100), + }" >
@@ -1260,42 +1419,125 @@ onBeforeUnmount(() => {
+
+
+
+ {{ t('appearance.controls.customColor') }} + {{ + t('appearance.controls.customColorBody') + }} +
+
+ +
+ +
+
+
+ +
+ + {{ t('appearance.controls.fontFamily') }} + {{ t('appearance.controls.fontFamilyBody') }} + +
+ +
+ +
+
+
+ + +
-
-
-
- {{ t('audit.eyebrow') }} -

{{ t('overview.recent') }}

-
- -
-
-
-
- {{ t('audit.actions.' + entry.action) }} - -
- {{ auditDescription(entry) }} -

- {{ - t('audit.by', { - actor: entry.actorName, - target: String(entry.targetSource ?? '—'), - }) - }} -

-
-
-
- {{ t('audit.emptyBody') }} -
-
{ :labels="configuratorEditorLabels" :disabled="!admin.configurator.enabled" :path="field.path" + :tab-label="configuratorSubtabLabel" @update:model-value=" updateConfiguratorField(field, $event) " @@ -1436,6 +1679,7 @@ onBeforeUnmount(() => { class="admin-panel-config-optional" >