"""Deterministic review assets for Lemana VPN; does not modify the application.""" from pathlib import Path import html import json import math import subprocess import zipfile ROOT = Path(__file__).resolve().parent SHIELD = 'M12 2 C14.6 3.7 17.3 4.7 20 5 V11.2 C20 16.1 16.7 19.6 12 22 C7.3 19.6 4 16.1 4 11.2 V5 C6.7 4.7 9.4 3.7 12 2 Z' def curve_length(points): previous=points[0]; length=0.0 for i in range(1,513): t=i/512; u=1-t point=tuple(u**3*points[0][k]+3*u*u*t*points[1][k]+3*u*t*t*points[2][k]+t**3*points[3][k] for k in (0,1)) length+=math.dist(previous,point); previous=point return length SHIELD_LENGTH=2*(6.2+curve_length(((12,2),(14.6,3.7),(17.3,4.7),(20,5)))+curve_length(((20,11.2),(20,16.1),(16.7,19.6),(12,22)))) PALETTES = { 'light': dict(bg='#F6F7F8', surface='#ECEFF2', ink='#27333E', muted='#657381', line='#D6DDE3', green='#278055', amber='#A56A13', red='#BE4149'), 'dark': dict(bg='#202429', surface='#2B3037', ink='#E5EAF0', muted='#ACB7C3', line='#444D57', green='#78CAA2', amber='#F0BB68', red='#F58C95'), } def path(d, **attrs): extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items()) return f'' def circle(x,y,r,**attrs): extra = ''.join(f' {key.replace("_", "-")}="{value}"' for key,value in attrs.items()) return f'' def progress_interior(shape,progress,bottom,height,palette,accent): body=shape(fill=palette['ink'],fill_opacity='.14',stroke='none') if progress: level=height*progress top=bottom-level # A nested viewport clips the fill level without shared SVG clip-path IDs. body+=f'' body+=shape(fill=accent,fill_opacity='.60',stroke='none')+'' return body MARKS = { 'off': path('M8.7 8.7 L15.3 15.3'), 'connecting': ''.join(circle(x,12,.7,fill='currentColor',stroke='none') for x in (9,12,15)), 'unlock': circle(10,10.5,1.8)+path('M11.4 11.9 L15.4 15.9 M13.5 14 L15 12.5'), 'check': path('M7.8 11.8 L10.8 14.7 L16.2 9.3'), 'reconnect': path('M15.6 10.2 A4 4 0 1 0 15.6 14.1 M15.6 7.3 V10.2 H12.7'), 'network': path('M10 10 H9 A2 2 0 0 0 9 14 H10 M14 10 H15 A2 2 0 0 1 15 14 H14'), 'error': path('M9 9 L15 15 M15 9 L9 15'), 'setup': path('M12 7.8 V12.3')+circle(12,15.5,.65,fill='currentColor',stroke='none'), 'unknown': path('M9.7 9.2 C9.8 6.7 14.6 6.7 14.6 9.4 C14.6 11.3 12 11.3 12 13')+circle(12,15.9,.65,fill='currentColor',stroke='none'), 'expired': path('M9 8 H15 M9 16 H15 M9.7 8 C9.7 10.3 14.3 13.7 14.3 16 M14.3 8 C14.3 10.3 9.7 13.7 9.7 16'), 'no-time': path('M9 12 H15'), 'clock': path('M12 7.5 V12 L15 13.7'), } ACTIONS = { 'connect': path('M8 3 V8 M16 3 V8 M6 8 H18 V11 C18 14.3 15.3 17 12 17 C8.7 17 6 14.3 6 11 Z M12 17 V21'), 'disconnect': path('M8 2.5 V6 M16 2.5 V6 M6 6 H18 V8 C18 10.2 15.3 11.5 12 11.5 C8.7 11.5 6 10.2 6 8 Z M7.5 15.5 Q12 19.5 16.5 15.5 M12 17.5 V21.5'), 'cancel': path('M6 6 L18 18 M18 6 L6 18'), 'import': path('M5 13 V20 H19 V13 M12 3 V15 M8 11 L12 15 L16 11'), 'password': circle(8,8,4)+path('M11 11 L20 20 M15 15 L17 13 M18 18 L20 16'), 'copy': path('M9 4 H19 V15 M5 8 H15 V21 H5 Z'), 'logs': path('M6 3 H14 L19 8 V21 H6 Z M14 3 V8 H19 M9 12 H16 M9 16 H16'), 'refresh': path('M19 9 A7.2 7.2 0 0 0 6.8 6.5 L4 9 M4 4 V9 H9 M5 15 A7.2 7.2 0 0 0 17.2 17.5 L20 15 M20 20 V15 H15'), 'quit': path('M11 3 H4 V21 H11 M10 12 H21 M17 8 L21 12 L17 16'), 'manual': ''+path('M6.5 10 H7 M11.5 10 H12 M16.5 10 H17 M7 14 H17'), 'auto': path('M13 3 L6 13 H11 L10 21 L18 10 H13 Z'), 'local': ''+path('M2.5 19 H21.5 L20 21 H4 Z'), 'vault': ''+circle(12,12,3.8)+path('M12 8.2 V10 M15.8 12 H14 M12 15.8 V14 M8.2 12 H10'), } ENTRIES=[] def add(key,label,section,kind,mark='',tone='ink',progress=None): ENTRIES.append(dict(key=key,label=label,section=section,kind=kind,mark=mark,tone=tone,progress=progress)) for args in [ ('vpn-off','Отключён','off','ink'),('vpn-connecting','Подключение','connecting','ink'), ('vpn-unlock','Ожидаем вход','unlock','ink'),('vpn-connected','Подключён','check','green'), ('vpn-reconnect','Переподключение','reconnect','ink'),('vpn-network','Проблема сети','network','amber'), ('vpn-error','Ошибка','error','red'),('vpn-setup','Нужна настройка','setup','amber'), ('vpn-unknown','Статус неизвестен','unknown','ink')]: add(args[0],args[1],'Состояния VPN','shield',args[2],args[3]) for key,label,progress,tone in [ ('time-start','Начало',0,'green'),('time-quarter','25% прошло',.25,'green'), ('time-half','50% прошло',.5,'green'),('time-three-quarter','75% прошло',.75,'green'), ('time-hour','Остался час',.875,'amber'),('time-quarter-hour','Осталось 15 мин',.96875,'red'), ('time-expired','Срок истёк',1,'red')]: add(key,label,'Время сессии','progress','expired' if progress==1 else '',tone,progress) add('time-unknown','Срок неизвестен','Время сессии','shield','no-time') add('format-shield','Щит с прогрессом','Форматы времени','progress','', 'green',.58) add('format-clock','Щит-часы','Форматы времени','shield','clock','green') add('format-time','Щит + время','Форматы времени','digital','check','green') add('format-ring','Кольцо','Форматы времени','ring','', 'green',.58) for key,label in [('connect','Подключить'),('disconnect','Отключить'),('cancel','Отменить'),('import','Импорт ключа'),('password','Логин и пароль'),('copy','Копировать IP'),('logs','Логи'),('refresh','Обновить статус'),('quit','Выход')]: add('action-'+key,label,'Действия меню','action',key) for key,label in [('manual','Вручную'),('auto','Автоматически'),('local','Ключ на Mac'),('vault','Bitwarden')]: add('source-'+key,label,'Источники и режимы','action',key) def content(entry,palette): ink=palette['ink'];accent=palette[entry['tone']] if entry['kind']=='action': return ACTIONS[entry['mark']] if entry['kind']=='ring': body=progress_interior(lambda **attrs:circle(12,12,9,**attrs),entry['progress'],21,18,palette,accent) return body+circle(12,12,9,stroke=palette['muted'],stroke_opacity='.65')+circle(12,12,9,stroke=accent,stroke_dasharray=f'{entry["progress"]*math.tau*9:.5f} {math.tau*9:.5f}',transform='rotate(-90 12 12)',stroke_linecap='butt') shape=path(SHIELD,stroke=ink) if entry['kind']=='progress': shape=progress_interior(lambda **attrs:path(SHIELD,**attrs),entry['progress'],22,20,palette,accent) shape+=path(SHIELD,stroke=palette['muted'],stroke_opacity='.68') if entry['progress']: shape+=path(SHIELD,stroke=accent,stroke_dasharray=f'{entry["progress"]*SHIELD_LENGTH:.5f} {SHIELD_LENGTH:.5f}',stroke_linecap='butt') if entry['mark']: mark_color=ink if entry['kind']=='progress' else accent shape+=f'{MARKS[entry["mark"]]}' if entry['kind']=='digital': shape+='2:18' return shape def svg(entry,theme): p=PALETTES[theme];width=74 if entry['kind']=='digital' else 24 return f'{html.escape(entry["label"])}'+content(entry,p)+'' def text(x,y,value,size=14,color='#657381',weight=400): return f'{html.escape(value)}' def sheet(theme): p=PALETTES[theme];width=1120;elements=[] elements.append(text(42,53,'Lemana VPN',27,p['ink'],600)) elements.append(text(42,82,'Единая семья значков',16,p['muted'])) elements.append(text(824,53,'Щит / 16 pt',14,p['ink'],500)) elements.append(text(824,78,'Контур 1,75 · сетка 24',12,p['muted'])) y=130 for section in dict.fromkeys(e['section'] for e in ENTRIES): elements.append(text(42,y,section,16,p['ink'],500));y+=24 subset=[e for e in ENTRIES if e['section']==section] for index,e in enumerate(subset): col=index%6;row=index//6;x=42+col*174;top=y+row*119 logical_width=74 if e['kind']=='digital' else 24 large_x=x+(160-logical_width*1.5)/2 small_x=x+(160-logical_width*2/3)/2 elements.append(f''+content(e,p)+'') elements.append(f''+content(e,p)+'') elements.append(text(x+7,top+91,e['label'],12,p['ink'])) y+=((len(subset)+5)//6)*119+14 elements.append(f'') elements.append(text(42,y+10,'Увеличенный вид + 16 pt. Цвет дополнен формой и подписью.',12,p['muted'])) elements.append(text(42,y+32,'Пример сессии: 8 ч. Контур и внутренняя заливка растут к её концу. Набор пока не установлен.',12,p['muted'])) height=y+65 return f''+''.join(elements)+'' for theme in PALETTES: for e in ENTRIES: source=ROOT/'svg'/f'{e["key"]}-{theme}.svg' source.write_text(svg(e,theme)) subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/'png'/f'{e["key"]}-{theme}.png'),'-h','96'],check=True) source=ROOT/f'icon-sheet-{theme}.svg';source.write_text(sheet(theme)) subprocess.run(['rsvg-convert',str(source),'-o',str(ROOT/f'icon-sheet-{theme}.png'),'-w','2240'],check=True) (ROOT/'manifest.json').write_text(json.dumps(ENTRIES,ensure_ascii=False,indent=2)+'\n') with zipfile.ZipFile(ROOT/'lemana-vpn-icons-v1.zip','w',zipfile.ZIP_DEFLATED) as archive: for file in sorted((ROOT/'svg').glob('*.svg')): archive.write(file,file.relative_to(ROOT)) archive.write(ROOT/'manifest.json','manifest.json') print(f'{len(ENTRIES)} icon entries, two themes, SVG assets and preview sheets created.')