diff --git a/.deploy-manifest.json b/.deploy-manifest.json deleted file mode 100644 index 8502c33..0000000 --- a/.deploy-manifest.json +++ /dev/null @@ -1,1586 +0,0 @@ -{ - "assets/ai-platform-primo-rpa/.gitkeep": { - "mtime": 1777548036560.4653, - "size": 2 - }, - "assets/ai-platform-primo-rpa/cover.png": { - "mtime": 1779377996031.643, - "size": 159015 - }, - "assets/ai-platform-primo-rpa/cover.webp": { - "mtime": 1779377996032.6443, - "size": 30364 - }, - "assets/ai-platform-primo-rpa/dataset-editor-old.png": { - "mtime": 1777636422809.364, - "size": 888287 - }, - "assets/ai-platform-primo-rpa/dataset-editor-old.webp": { - "mtime": 1779295491001, - "size": 280160 - }, - "assets/ai-platform-primo-rpa/dataset-editor-small-screen.png": { - "mtime": 1779377996036.6465, - "size": 404244 - }, - "assets/ai-platform-primo-rpa/dataset-editor-small-screen.webp": { - "mtime": 1779377996037.6458, - "size": 88614 - }, - "assets/ai-platform-primo-rpa/dataset-editor.png": { - "mtime": 1779377996046.131, - "size": 819234 - }, - "assets/ai-platform-primo-rpa/dataset-editor.webp": { - "mtime": 1779377996048.131, - "size": 153230 - }, - "assets/ai-platform-primo-rpa/dataset-lerning.png": { - "mtime": 1778061201695.4707, - "size": 178506 - }, - "assets/ai-platform-primo-rpa/dataset-lerning.webp": { - "mtime": 1779295491358, - "size": 76082 - }, - "assets/ai-platform-primo-rpa/dataset-quality.png": { - "mtime": 1778061201699.9893, - "size": 256076 - }, - "assets/ai-platform-primo-rpa/dataset-quality.webp": { - "mtime": 1779295491507, - "size": 101230 - }, - "assets/ai-platform-primo-rpa/dataset_editor-main_toolbar.png": { - "mtime": 1779377996049.637, - "size": 15031 - }, - "assets/ai-platform-primo-rpa/dataset_editor-main_toolbar.webp": { - "mtime": 1779377996050.6484, - "size": 6034 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-default_mode.png": { - "mtime": 1779377996050.6484, - "size": 47547 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-default_mode.webp": { - "mtime": 1779377996051.6475, - "size": 38664 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-both_panels.png": { - "mtime": 1779377996053.8552, - "size": 56585 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-both_panels.webp": { - "mtime": 1779377996054.8564, - "size": 44568 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-left_panel.png": { - "mtime": 1779377996056.8555, - "size": 59824 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-left_panel.webp": { - "mtime": 1779377996057.8606, - "size": 55050 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-right_panel.png": { - "mtime": 1779377996058.8574, - "size": 60884 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode-right_panel.webp": { - "mtime": 1779377996059.86, - "size": 55078 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode.png": { - "mtime": 1779377996060.891, - "size": 64220 - }, - "assets/ai-platform-primo-rpa/dataset_editor-wireframe-zen_mode.webp": { - "mtime": 1779377996062.934, - "size": 63640 - }, - "assets/ai-platform-primo-rpa/fields-maping.mp4": { - "mtime": 1778061201754.2502, - "size": 12663587 - }, - "assets/ai-platform-primo-rpa/fields-maping.webm": { - "mtime": 1778061201761.628, - "size": 811806 - }, - "assets/ai-platform-primo-rpa/fields-maping.webp": { - "mtime": 1778061201763.6267, - "size": 86646 - }, - "assets/ai-platform-primo-rpa/full-screen-mode-with-panels.png": { - "mtime": 1778061201769.9807, - "size": 555909 - }, - "assets/ai-platform-primo-rpa/full-screen-mode-with-panels.webp": { - "mtime": 1779295492433, - "size": 110334 - }, - "assets/ai-platform-primo-rpa/full-screen-mode-zen.png": { - "mtime": 1778061201774.9817, - "size": 502027 - }, - "assets/ai-platform-primo-rpa/full-screen-mode-zen.webp": { - "mtime": 1779295492517, - "size": 94372 - }, - "assets/ai-platform-primo-rpa/fullscreen_modal-import_dataset.png": { - "mtime": 1779377996064.941, - "size": 72399 - }, - "assets/ai-platform-primo-rpa/fullscreen_modal-import_dataset.webp": { - "mtime": 1779377996066.941, - "size": 26252 - }, - "assets/ai-platform-primo-rpa/fullscreen_modal-in_project-import_dataset.png": { - "mtime": 1779377996069.4497, - "size": 130169 - }, - "assets/ai-platform-primo-rpa/fullscreen_modal-in_project-import_dataset.webp": { - "mtime": 1779377996071.4585, - "size": 30506 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-learning_process.png": { - "mtime": 1779377996073.4573, - "size": 113156 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-learning_process.webp": { - "mtime": 1779377996075.4573, - "size": 67896 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-learning_process_wireframe.png": { - "mtime": 1779377996076.4575, - "size": 8462 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-learning_process_wireframe.webp": { - "mtime": 1779377996077.4592, - "size": 4008 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-new.png": { - "mtime": 1779377996079.963, - "size": 109260 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-new.webp": { - "mtime": 1779377996081.969, - "size": 77098 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-сollapsed.png": { - "mtime": 1779377996082.9697, - "size": 6342 - }, - "assets/ai-platform-primo-rpa/in_project-learn-card-сollapsed.webp": { - "mtime": 1779377996085.1777, - "size": 4868 - }, - "assets/ai-platform-primo-rpa/in_project-learn-overview.png": { - "mtime": 1779377996088.1775, - "size": 147934 - }, - "assets/ai-platform-primo-rpa/in_project-learn-overview.webp": { - "mtime": 1779377996089.6838, - "size": 28904 - }, - "assets/ai-platform-primo-rpa/main-new.png": { - "mtime": 1779377996092.691, - "size": 191708 - }, - "assets/ai-platform-primo-rpa/main-new.webp": { - "mtime": 1779377996093.6912, - "size": 56804 - }, - "assets/ai-platform-primo-rpa/main.png": { - "mtime": 1779377996099.692, - "size": 430951 - }, - "assets/ai-platform-primo-rpa/main.webp": { - "mtime": 1779377996101.8147, - "size": 108008 - }, - "assets/ai-platform-primo-rpa/nav-project-nlp.png": { - "mtime": 1779377996103.8135, - "size": 109931 - }, - "assets/ai-platform-primo-rpa/nav-project-nlp.webp": { - "mtime": 1779377996104.8171, - "size": 18854 - }, - "assets/ai-platform-primo-rpa/nav-project-ocr.png": { - "mtime": 1779377996106.8132, - "size": 116365 - }, - "assets/ai-platform-primo-rpa/nav-project-ocr.webp": { - "mtime": 1779377996107.8135, - "size": 24166 - }, - "assets/ai-platform-primo-rpa/nav-secondary-detail_view.png": { - "mtime": 1779377996108.8142, - "size": 17770 - }, - "assets/ai-platform-primo-rpa/nav-secondary-detail_view.webp": { - "mtime": 1779377996108.8142, - "size": 5266 - }, - "assets/favicon/android-chrome-192x192.png": { - "mtime": 1780513420000, - "size": 45462 - }, - "assets/favicon/android-chrome-512x512.png": { - "mtime": 1780513420000, - "size": 259917 - }, - "assets/favicon/apple-touch-icon.png": { - "mtime": 1780513420000, - "size": 41086 - }, - "assets/favicon/favicon-16x16.png": { - "mtime": 1780513420000, - "size": 776 - }, - "assets/favicon/favicon-32x32.png": { - "mtime": 1780513420000, - "size": 2166 - }, - "assets/favicon/favicon.ico": { - "mtime": 1780513420000, - "size": 15406 - }, - "assets/favicon/favicon.png": { - "mtime": 1780520464204.617, - "size": 778312 - }, - "assets/favicon/favicon_bg.png": { - "mtime": 1780520601264.4983, - "size": 791215 - }, - "assets/favicon/site.webmanifest": { - "mtime": 1780513420000, - "size": 263 - }, - "assets/fonts/files/alice-cyrillic-400-normal.woff2": { - "mtime": 1779377996112.3274, - "size": 17784 - }, - "assets/fonts/files/alice-cyrillic-ext-400-normal.woff2": { - "mtime": 1779377996113.3308, - "size": 15748 - }, - "assets/fonts/files/alice-latin-400-normal.woff2": { - "mtime": 1779377996114.3271, - "size": 25788 - }, - "assets/fonts/files/alice-latin-ext-400-normal.woff2": { - "mtime": 1779377996115.328, - "size": 4880 - }, - "assets/fonts/files/geologica-cyrillic-ext-wght-normal.woff2": { - "mtime": 1779377996117.3262, - "size": 11488 - }, - "assets/fonts/files/geologica-cyrillic-wght-normal.woff2": { - "mtime": 1779377996118.3267, - "size": 16428 - }, - "assets/fonts/files/geologica-greek-wght-normal.woff2": { - "mtime": 1779377996118.3267, - "size": 10928 - }, - "assets/fonts/files/geologica-latin-ext-wght-normal.woff2": { - "mtime": 1779377996119.833, - "size": 22404 - }, - "assets/fonts/files/geologica-latin-wght-normal.woff2": { - "mtime": 1779377996120.8398, - "size": 24944 - }, - "assets/fonts/files/geologica-vietnamese-wght-normal.woff2": { - "mtime": 1779377996121.8386, - "size": 8328 - }, - "assets/fonts/files/merriweather-cyrillic-400-normal.woff2": { - "mtime": 1779377996122.839, - "size": 25484 - }, - "assets/fonts/files/merriweather-cyrillic-700-normal.woff2": { - "mtime": 1779377996123.8394, - "size": 25492 - }, - "assets/fonts/files/merriweather-cyrillic-ext-400-normal.woff2": { - "mtime": 1779377996124.9783, - "size": 18600 - }, - "assets/fonts/files/merriweather-cyrillic-ext-700-normal.woff2": { - "mtime": 1779377996125.9778, - "size": 18560 - }, - "assets/fonts/files/merriweather-latin-400-normal.woff2": { - "mtime": 1779377996126.9783, - "size": 49168 - }, - "assets/fonts/files/merriweather-latin-700-normal.woff2": { - "mtime": 1779377996127.9773, - "size": 48660 - }, - "assets/fonts/files/merriweather-latin-ext-400-normal.woff2": { - "mtime": 1779377996129.4827, - "size": 42952 - }, - "assets/fonts/files/merriweather-latin-ext-700-normal.woff2": { - "mtime": 1779377996130.4888, - "size": 42880 - }, - "assets/fonts/files/merriweather-vietnamese-400-normal.woff2": { - "mtime": 1779377996131.49, - "size": 6664 - }, - "assets/fonts/files/merriweather-vietnamese-700-normal.woff2": { - "mtime": 1779377996133.4978, - "size": 6660 - }, - "assets/fonts/files/pt-mono-cyrillic-400-normal.woff2": { - "mtime": 1779377996134.4993, - "size": 15356 - }, - "assets/fonts/files/pt-mono-cyrillic-ext-400-normal.woff2": { - "mtime": 1779377996135.4956, - "size": 22028 - }, - "assets/fonts/files/pt-mono-latin-400-normal.woff2": { - "mtime": 1779377996136.4954, - "size": 27436 - }, - "assets/fonts/files/pt-mono-latin-ext-400-normal.woff2": { - "mtime": 1779377996137.4963, - "size": 16072 - }, - "assets/fonts/files/pt-sans-cyrillic-400-normal.woff2": { - "mtime": 1779377996138.4968, - "size": 28548 - }, - "assets/fonts/files/pt-sans-cyrillic-700-normal.woff2": { - "mtime": 1779377996139.4963, - "size": 30164 - }, - "assets/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2": { - "mtime": 1779377996140.0027, - "size": 42680 - }, - "assets/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2": { - "mtime": 1779377996142.004, - "size": 39584 - }, - "assets/fonts/files/pt-sans-latin-400-normal.woff2": { - "mtime": 1779377996143.0056, - "size": 45532 - }, - "assets/fonts/files/pt-sans-latin-700-normal.woff2": { - "mtime": 1779377996144.0051, - "size": 47180 - }, - "assets/fonts/files/pt-sans-latin-ext-400-normal.woff2": { - "mtime": 1779377996145.0054, - "size": 26812 - }, - "assets/fonts/files/pt-sans-latin-ext-700-normal.woff2": { - "mtime": 1779377996147.0066, - "size": 29624 - }, - "assets/fonts/files/source-code-pro-cyrillic-400-normal.woff2": { - "mtime": 1779377996147.0066, - "size": 6788 - }, - "assets/fonts/files/source-code-pro-cyrillic-500-normal.woff2": { - "mtime": 1779377996148.0044, - "size": 6836 - }, - "assets/fonts/files/source-code-pro-cyrillic-600-normal.woff2": { - "mtime": 1779377996149.5103, - "size": 6796 - }, - "assets/fonts/files/source-code-pro-cyrillic-700-normal.woff2": { - "mtime": 1779377996150.517, - "size": 6800 - }, - "assets/fonts/files/source-code-pro-cyrillic-ext-400-normal.woff2": { - "mtime": 1779377996150.517, - "size": 4516 - }, - "assets/fonts/files/source-code-pro-cyrillic-ext-500-normal.woff2": { - "mtime": 1779377996151.5173, - "size": 4556 - }, - "assets/fonts/files/source-code-pro-cyrillic-ext-600-normal.woff2": { - "mtime": 1779377996152.5176, - "size": 4520 - }, - "assets/fonts/files/source-code-pro-cyrillic-ext-700-normal.woff2": { - "mtime": 1779377996153.5164, - "size": 4480 - }, - "assets/fonts/files/source-code-pro-greek-400-normal.woff2": { - "mtime": 1779377996154.5195, - "size": 5528 - }, - "assets/fonts/files/source-code-pro-greek-500-normal.woff2": { - "mtime": 1779377996155.5168, - "size": 5584 - }, - "assets/fonts/files/source-code-pro-greek-600-normal.woff2": { - "mtime": 1779377996155.5168, - "size": 5532 - }, - "assets/fonts/files/source-code-pro-greek-700-normal.woff2": { - "mtime": 1779377996156.5166, - "size": 5528 - }, - "assets/fonts/files/source-code-pro-greek-ext-400-normal.woff2": { - "mtime": 1779377996157.5166, - "size": 1852 - }, - "assets/fonts/files/source-code-pro-greek-ext-500-normal.woff2": { - "mtime": 1779377996158.5164, - "size": 1856 - }, - "assets/fonts/files/source-code-pro-greek-ext-600-normal.woff2": { - "mtime": 1779377996159.5183, - "size": 1868 - }, - "assets/fonts/files/source-code-pro-greek-ext-700-normal.woff2": { - "mtime": 1779377996160.6191, - "size": 1824 - }, - "assets/fonts/files/source-code-pro-latin-400-normal.woff2": { - "mtime": 1779377996160.6191, - "size": 11656 - }, - "assets/fonts/files/source-code-pro-latin-500-normal.woff2": { - "mtime": 1779377996161.619, - "size": 11604 - }, - "assets/fonts/files/source-code-pro-latin-600-normal.woff2": { - "mtime": 1779377996163.6172, - "size": 11628 - }, - "assets/fonts/files/source-code-pro-latin-700-normal.woff2": { - "mtime": 1779377996163.6172, - "size": 11540 - }, - "assets/fonts/files/source-code-pro-latin-ext-400-normal.woff2": { - "mtime": 1779377996165.6191, - "size": 17748 - }, - "assets/fonts/files/source-code-pro-latin-ext-500-normal.woff2": { - "mtime": 1779377996166.6196, - "size": 17752 - }, - "assets/fonts/files/source-code-pro-latin-ext-600-normal.woff2": { - "mtime": 1779377996167.6196, - "size": 17640 - }, - "assets/fonts/files/source-code-pro-latin-ext-700-normal.woff2": { - "mtime": 1779377996168.6196, - "size": 17644 - }, - "assets/fonts/files/source-code-pro-vietnamese-400-normal.woff2": { - "mtime": 1779377996169.619, - "size": 4752 - }, - "assets/fonts/files/source-code-pro-vietnamese-500-normal.woff2": { - "mtime": 1779377996170.1245, - "size": 4804 - }, - "assets/fonts/files/source-code-pro-vietnamese-600-normal.woff2": { - "mtime": 1779377996171.1304, - "size": 4704 - }, - "assets/fonts/files/source-code-pro-vietnamese-700-normal.woff2": { - "mtime": 1779377996172.1353, - "size": 4748 - }, - "assets/fonts/geologica.css": { - "mtime": 1779377996172.1353, - "size": 5047 - }, - "assets/fonts/merriweather.css": { - "mtime": 1779377996173.1355, - "size": 5119 - }, - "assets/fonts/mono.css": { - "mtime": 1779377996174.135, - "size": 11463 - }, - "assets/index/ava-bg-1.png": { - "mtime": 1776187876634.7048, - "size": 1279764 - }, - "assets/index/ava-bg-1.webp": { - "mtime": 1777054556943, - "size": 81378 - }, - "assets/index/me-transparentBG-1.png": { - "mtime": 1776189627628.54, - "size": 1011694 - }, - "assets/index/me-transparentBG-1.webp": { - "mtime": 1777054557058, - "size": 141834 - }, - "assets/index/me_motion.mp4": { - "mtime": 1777636422897.9202, - "size": 311169 - }, - "assets/index/me_motion.webm": { - "mtime": 1777636422899.8853, - "size": 215347 - }, - "assets/index/me_motion.webp": { - "mtime": 1777636422902.3997, - "size": 79376 - }, - "assets/index/me_static.png": { - "mtime": 1777636422916.1433, - "size": 1592317 - }, - "assets/index/me_static.webp": { - "mtime": 1777636422917.152, - "size": 141572 - }, - "assets/lunacy/.gitkeep": { - "mtime": 1779377996175.1355, - "size": 2 - }, - "assets/lunacy/blob_settings_on_canvas.png": { - "mtime": 1779377996193.6858, - "size": 185060 - }, - "assets/lunacy/blob_settings_on_canvas.webp": { - "mtime": 1779377996194.6858, - "size": 48762 - }, - "assets/lunacy/blob_settings_panel_v1.png": { - "mtime": 1779377996195.6848, - "size": 16000 - }, - "assets/lunacy/blob_settings_panel_v1.webp": { - "mtime": 1779377996196.6843, - "size": 6868 - }, - "assets/lunacy/blob_settings_panel_v2.png": { - "mtime": 1779377996196.6843, - "size": 13903 - }, - "assets/lunacy/blob_settings_panel_v2.webp": { - "mtime": 1779377996197.6863, - "size": 4308 - }, - "assets/lunacy/blob_settings_panel_v3.png": { - "mtime": 1779377996197.6863, - "size": 20852 - }, - "assets/lunacy/blob_settings_panel_v3.webp": { - "mtime": 1779377996198.6855, - "size": 9948 - }, - "assets/lunacy/blob_settings_panel_v4.png": { - "mtime": 1779377996198.6855, - "size": 15539 - }, - "assets/lunacy/blob_settings_panel_v4.webp": { - "mtime": 1779377996199.6868, - "size": 3930 - }, - "assets/lunacy/blob_settings_panel_v5.png": { - "mtime": 1779377996200.7917, - "size": 14378 - }, - "assets/lunacy/blob_settings_panel_v5.webp": { - "mtime": 1779377996200.7917, - "size": 3864 - }, - "assets/lunacy/blob_settings_panel_v6.png": { - "mtime": 1779377996201.7903, - "size": 14869 - }, - "assets/lunacy/blob_settings_panel_v6.webp": { - "mtime": 1779377996202.7896, - "size": 4080 - }, - "assets/lunacy/blob_settings_panel_v7.png": { - "mtime": 1779377996202.7896, - "size": 23381 - }, - "assets/lunacy/blob_settings_panel_v7.webp": { - "mtime": 1779377996203.7908, - "size": 7690 - }, - "assets/lunacy/blob_settings_panel_v8.png": { - "mtime": 1779377996204.7915, - "size": 15387 - }, - "assets/lunacy/blob_settings_panel_v8.webp": { - "mtime": 1779377996204.7915, - "size": 4542 - }, - "assets/lunacy/blob_settings_panel_v9.png": { - "mtime": 1779377996205.7905, - "size": 18344 - }, - "assets/lunacy/blob_settings_panel_v9.webp": { - "mtime": 1779377996206.7903, - "size": 8820 - }, - "assets/lunacy/compare_components_modal_window.png": { - "mtime": 1779377996207.7922, - "size": 103262 - }, - "assets/lunacy/compare_components_modal_window.webp": { - "mtime": 1779377996208.7913, - "size": 23548 - }, - "assets/lunacy/components_update_indicator_location_panel.png": { - "mtime": 1779377996210.2961, - "size": 75843 - }, - "assets/lunacy/components_update_indicator_location_panel.webp": { - "mtime": 1779377996211.3025, - "size": 24570 - }, - "assets/lunacy/cover.webp": { - "mtime": 1779377996212.307, - "size": 198532 - }, - "assets/lunacy/Lunacy_app_overview.png": { - "mtime": 1779377996188.6538, - "size": 1304403 - }, - "assets/lunacy/Lunacy_app_overview.webp": { - "mtime": 1779377996191.6865, - "size": 198532 - }, - "assets/lunacy/modal_window_for_prototype_create_on_canvas.png": { - "mtime": 1779377996218.308, - "size": 699587 - }, - "assets/lunacy/modal_window_for_prototype_create_on_canvas.webp": { - "mtime": 1779377996219.8125, - "size": 114986 - }, - "assets/lunacy/modal_window_for_prototype_panel_states.png": { - "mtime": 1779377996221.818, - "size": 163537 - }, - "assets/lunacy/modal_window_for_prototype_panel_states.webp": { - "mtime": 1779377996222.8186, - "size": 67292 - }, - "assets/lunacy/shadow_pro_settings_ideas.png": { - "mtime": 1779377996224.3208, - "size": 150278 - }, - "assets/lunacy/shadow_pro_settings_ideas.webp": { - "mtime": 1779377996225.3252, - "size": 41824 - }, - "assets/lunacy/shadow_pro_settings_modal_auto-off.png": { - "mtime": 1779377996227.3298, - "size": 149676 - }, - "assets/lunacy/shadow_pro_settings_modal_auto-off.webp": { - "mtime": 1779377996228.329, - "size": 20878 - }, - "assets/lunacy/shadow_pro_settings_modal_auto_on.png": { - "mtime": 1779377996229.8337, - "size": 133884 - }, - "assets/lunacy/shadow_pro_settings_modal_auto_on.webp": { - "mtime": 1779377996230.84, - "size": 20552 - }, - "assets/lunacy/shadow_pro_settings_panel.png": { - "mtime": 1779377996232.8403, - "size": 175508 - }, - "assets/lunacy/shadow_pro_settings_panel.webp": { - "mtime": 1779377996233.8416, - "size": 64304 - }, - "assets/lunacy/sliders_all_set.png": { - "mtime": 1779377996260.5515, - "size": 653520 - }, - "assets/lunacy/sliders_all_set.webp": { - "mtime": 1779377996261.5532, - "size": 82004 - }, - "assets/lunacy/slider_14.png": { - "mtime": 1779377996233.8416, - "size": 15296 - }, - "assets/lunacy/slider_14.webp": { - "mtime": 1779377996234.8418, - "size": 6104 - }, - "assets/lunacy/slider_v1.png": { - "mtime": 1779377996235.8403, - "size": 11997 - }, - "assets/lunacy/slider_v1.webp": { - "mtime": 1779377996235.8403, - "size": 4894 - }, - "assets/lunacy/slider_v10.png": { - "mtime": 1779377996236.8413, - "size": 16438 - }, - "assets/lunacy/slider_v10.webp": { - "mtime": 1779377996237.8428, - "size": 5316 - }, - "assets/lunacy/slider_v11.png": { - "mtime": 1779377996237.8428, - "size": 15713 - }, - "assets/lunacy/slider_v11.webp": { - "mtime": 1779377996238.8408, - "size": 5264 - }, - "assets/lunacy/slider_v12.png": { - "mtime": 1779377996239.8408, - "size": 16355 - }, - "assets/lunacy/slider_v12.webp": { - "mtime": 1779377996239.8408, - "size": 5236 - }, - "assets/lunacy/slider_v13.png": { - "mtime": 1779377996240.93, - "size": 16412 - }, - "assets/lunacy/slider_v13.webp": { - "mtime": 1779377996240.93, - "size": 4780 - }, - "assets/lunacy/slider_v15.png": { - "mtime": 1779377996241.9312, - "size": 16438 - }, - "assets/lunacy/slider_v15.webp": { - "mtime": 1779377996241.9312, - "size": 5316 - }, - "assets/lunacy/slider_v16.png": { - "mtime": 1779377996242.932, - "size": 16992 - }, - "assets/lunacy/slider_v16.webp": { - "mtime": 1779377996242.932, - "size": 5042 - }, - "assets/lunacy/slider_v17.png": { - "mtime": 1779377996243.93, - "size": 14273 - }, - "assets/lunacy/slider_v17.webp": { - "mtime": 1779377996244.9321, - "size": 4370 - }, - "assets/lunacy/slider_v18.png": { - "mtime": 1779377996244.9321, - "size": 15153 - }, - "assets/lunacy/slider_v18.webp": { - "mtime": 1779377996245.9304, - "size": 5138 - }, - "assets/lunacy/slider_v2.png": { - "mtime": 1779377996245.9304, - "size": 12918 - }, - "assets/lunacy/slider_v2.webp": { - "mtime": 1779377996246.9302, - "size": 5676 - }, - "assets/lunacy/slider_v3.png": { - "mtime": 1779377996247.9326, - "size": 24683 - }, - "assets/lunacy/slider_v3.webp": { - "mtime": 1779377996247.9326, - "size": 6056 - }, - "assets/lunacy/slider_v4.png": { - "mtime": 1779377996249.4392, - "size": 21075 - }, - "assets/lunacy/slider_v4.webp": { - "mtime": 1779377996250.448, - "size": 5806 - }, - "assets/lunacy/slider_v5.png": { - "mtime": 1779377996251.4482, - "size": 13119 - }, - "assets/lunacy/slider_v5.webp": { - "mtime": 1779377996251.4482, - "size": 5512 - }, - "assets/lunacy/slider_v6.png": { - "mtime": 1779377996252.4485, - "size": 15467 - }, - "assets/lunacy/slider_v6.webp": { - "mtime": 1779377996252.4485, - "size": 4526 - }, - "assets/lunacy/slider_v7.png": { - "mtime": 1779377996253.4475, - "size": 15261 - }, - "assets/lunacy/slider_v7.webp": { - "mtime": 1779377996254.446, - "size": 5300 - }, - "assets/lunacy/slider_v8.png": { - "mtime": 1779377996254.446, - "size": 15432 - }, - "assets/lunacy/slider_v8.webp": { - "mtime": 1779377996255.4485, - "size": 4910 - }, - "assets/lunacy/slider_v9.png": { - "mtime": 1779377996256.447, - "size": 17093 - }, - "assets/lunacy/slider_v9.webp": { - "mtime": 1779377996256.447, - "size": 5676 - }, - "assets/mamadaevv_resume.pdf": { - "mtime": 1780575571973.1184, - "size": 160205 - }, - "assets/manu-game-maker/.gitkeep": { - "mtime": 1779377996263.5508, - "size": 2 - }, - "assets/manu-game-maker/cover.png": { - "mtime": 1779377996266.5527, - "size": 483251 - }, - "assets/manu-game-maker/cover.webp": { - "mtime": 1779377996268.5513, - "size": 94600 - }, - "assets/misc/dribbble_logo.png": { - "mtime": 1780407662552.718, - "size": 794904 - }, - "assets/misc/dribbble_logo.webp": { - "mtime": 1780407662555.7188, - "size": 16768 - }, - "assets/misc/figma_logo.jpg": { - "mtime": 1780407662557.7192, - "size": 10883 - }, - "assets/misc/figma_logo.webp": { - "mtime": 1780407662559.719, - "size": 5108 - }, - "assets/misc/tg-channel-logo-design_not_pizza.jpg": { - "mtime": 1780407662562.7192, - "size": 48308 - }, - "assets/misc/tg-channel-logo-design_not_pizza.webp": { - "mtime": 1780407662564.7183, - "size": 23598 - }, - "assets/mobile-clinic-app/.gitkeep": { - "mtime": 1779377996270.7036, - "size": 2 - }, - "assets/mobile-clinic-app/cover.png": { - "mtime": 1779377996275.6907, - "size": 626518 - }, - "assets/mobile-clinic-app/cover.webp": { - "mtime": 1779377996277.6917, - "size": 121088 - }, - "assets/obsidian-plugin-columns-view/filter use.mp4": { - "mtime": 1782054448519.579, - "size": 36000629 - }, - "assets/obsidian-plugin-columns-view/filter use.webm": { - "mtime": 1782054874042.0066, - "size": 4117727 - }, - "assets/obsidian-plugin-columns-view/filter use.webp": { - "mtime": 1782054874401.6035, - "size": 82876 - }, - "assets/obsidian-plugin-columns-view/overview.png": { - "mtime": 1782055820170.6465, - "size": 358265 - }, - "assets/obsidian-plugin-columns-view/overview.webp": { - "mtime": 1782057055802, - "size": 199104 - }, - "assets/obsidian-plugin-columns-view/view configure.mp4": { - "mtime": 1782054318526.4917, - "size": 41097403 - }, - "assets/obsidian-plugin-columns-view/view configure.webm": { - "mtime": 1782054960161.7544, - "size": 2104173 - }, - "assets/obsidian-plugin-columns-view/view configure.webp": { - "mtime": 1782054960408.1843, - "size": 69110 - }, - "assets/orc-primo-rpa/cover.webp": { - "mtime": 1780407662568.7195, - "size": 42848 - }, - "assets/orc-primo-rpa/projects--table-new.png": { - "mtime": 1780407662571.719, - "size": 86822 - }, - "assets/orc-primo-rpa/projects--table-new.webp": { - "mtime": 1780407662572.718, - "size": 42848 - }, - "assets/orc-primo-rpa/projects--table-old.png": { - "mtime": 1780407662575.226, - "size": 139826 - }, - "assets/orc-primo-rpa/projects--table-old.webp": { - "mtime": 1780407662577.2327, - "size": 78910 - }, - "assets/README.md": { - "mtime": 1779377996027.13, - "size": 843 - }, - "assets/shared/icons/arrow_upright.svg": { - "mtime": 1776362143026.8838, - "size": 1215 - }, - "assets/shared/icons/chat_start.svg": { - "mtime": 1776193642521.755, - "size": 3485 - }, - "assets/shared/icons/checkmark.svg": { - "mtime": 1776422601550.154, - "size": 767 - }, - "assets/shared/icons/close.svg": { - "mtime": 1776857552773.9846, - "size": 407 - }, - "assets/shared/icons/copy.svg": { - "mtime": 1776352846992.719, - "size": 1418 - }, - "assets/shared/icons/cv.svg": { - "mtime": 1776514945920.6653, - "size": 2492 - }, - "assets/shared/icons/delete.svg": { - "mtime": 1779377996278.6897, - "size": 1486 - }, - "assets/shared/icons/design_edit.svg": { - "mtime": 1776193642520.2417, - "size": 5473 - }, - "assets/shared/icons/email.svg": { - "mtime": 1776362122606.4749, - "size": 1589 - }, - "assets/shared/icons/link_url.svg": { - "mtime": 1780407662580.2358, - "size": 1525 - }, - "assets/shared/icons/moon.svg": { - "mtime": 1776193642519.029, - "size": 1090 - }, - "assets/shared/icons/original_size.svg": { - "mtime": 1776857547534.4807, - "size": 2382 - }, - "assets/shared/icons/portfolio.svg": { - "mtime": 1776515141309.182, - "size": 1352 - }, - "assets/shared/icons/shevron-down.svg": { - "mtime": 1777636422918.6008, - "size": 1222 - }, - "assets/shared/icons/shevron-left.svg": { - "mtime": 1777636422919.6145, - "size": 1207 - }, - "assets/shared/icons/shevron-right.svg": { - "mtime": 1777636422919.6145, - "size": 1209 - }, - "assets/shared/icons/shevron-up.svg": { - "mtime": 1777636422921.079, - "size": 1208 - }, - "assets/shared/icons/sun.svg": { - "mtime": 1776193642517.9954, - "size": 4326 - }, - "assets/shared/icons/system.svg": { - "mtime": 1776193642519.029, - "size": 2249 - }, - "assets/shared/icons/tg.svg": { - "mtime": 1776362122602.7698, - "size": 1373 - }, - "assets/shared/icons/vk.svg": { - "mtime": 1776362122605.4763, - "size": 615 - }, - "assets/shared/icons/write_me.svg": { - "mtime": 1777030666208.4812, - "size": 3077 - }, - "assets/shared/icons/yandex_telemost.svg": { - "mtime": 1776362122604.4692, - "size": 572 - }, - "assets/shared/icons/zoom_in.svg": { - "mtime": 1776856548780.6626, - "size": 1953 - }, - "assets/shared/icons/zoom_out.svg": { - "mtime": 1776856548780.6626, - "size": 1592 - }, - "assets/shared/icons/zoom_to_fit.svg": { - "mtime": 1779377996280.203, - "size": 1248 - }, - "assets/shared/icons/сookie.svg": { - "mtime": 1776607984487.0112, - "size": 3508 - }, - "assets/shared/placeholder.jpg": { - "mtime": 1777053044955.2012, - "size": 34829 - }, - "assets/shared/placeholder.webp": { - "mtime": 1780407662581.2354, - "size": 240 - }, - "assets/task-distribution-concept/.gitkeep": { - "mtime": 1779377996281.21, - "size": 2 - }, - "assets/task-distribution-concept/cover.png": { - "mtime": 1779377996284.209, - "size": 197021 - }, - "assets/task-distribution-concept/cover.webp": { - "mtime": 1779377996286.2097, - "size": 81646 - }, - "assets/task-tracker/actions_for_page.png": { - "mtime": 1724892346000, - "size": 25027 - }, - "assets/task-tracker/actions_for_page.webp": { - "mtime": 1777119492700, - "size": 23404 - }, - "assets/task-tracker/cover.webp": { - "mtime": 1779377996288.2092, - "size": 151720 - }, - "assets/task-tracker/dark_theme.png": { - "mtime": 1724892366000, - "size": 291518 - }, - "assets/task-tracker/dark_theme.webp": { - "mtime": 1777119492957, - "size": 147348 - }, - "assets/task-tracker/drag-drop-action_example.mp4": { - "mtime": 1776793076285.7869, - "size": 2095990 - }, - "assets/task-tracker/drag-drop-action_example.webm": { - "mtime": 1777118933364.2495, - "size": 147445 - }, - "assets/task-tracker/drag-drop-action_example.webp": { - "mtime": 1777118933542.3682, - "size": 3604 - }, - "assets/task-tracker/drag-drop-item_board-view.mp4": { - "mtime": 1724892358000, - "size": 1784143 - }, - "assets/task-tracker/drag-drop-item_board-view.webm": { - "mtime": 1777118939415.8855, - "size": 232186 - }, - "assets/task-tracker/drag-drop-item_board-view.webp": { - "mtime": 1777118939601.6213, - "size": 27548 - }, - "assets/task-tracker/drag-drop-item_list-view.mp4": { - "mtime": 1724892360000, - "size": 2886192 - }, - "assets/task-tracker/drag-drop-item_list-view.webm": { - "mtime": 1777118960260.8003, - "size": 331393 - }, - "assets/task-tracker/drag-drop-item_list-view.webp": { - "mtime": 1777118960514.3586, - "size": 27766 - }, - "assets/task-tracker/dropdown_menu_for_views.png": { - "mtime": 1724892346000, - "size": 75360 - }, - "assets/task-tracker/dropdown_menu_for_views.webp": { - "mtime": 1777119493007, - "size": 64398 - }, - "assets/task-tracker/expand-collapse_group.mp4": { - "mtime": 1724892356000, - "size": 1663390 - }, - "assets/task-tracker/expand-collapse_group.webm": { - "mtime": 1777118977581.77, - "size": 196247 - }, - "assets/task-tracker/expand-collapse_group.webp": { - "mtime": 1777118977813.5178, - "size": 27750 - }, - "assets/task-tracker/full_preview.mp4": { - "mtime": 1776877975221.4644, - "size": 2480939 - }, - "assets/task-tracker/full_preview.webm": { - "mtime": 1777119206780.3667, - "size": 1852963 - }, - "assets/task-tracker/full_preview.webp": { - "mtime": 1777119207226.3965, - "size": 31316 - }, - "assets/task-tracker/light_theme_color.png": { - "mtime": 1724892364000, - "size": 555991 - }, - "assets/task-tracker/light_theme_color.webp": { - "mtime": 1777119493297, - "size": 164962 - }, - "assets/task-tracker/refs.png": { - "mtime": 1776792419154.0342, - "size": 1779282 - }, - "assets/task-tracker/refs.webp": { - "mtime": 1777119493506, - "size": 225610 - }, - "assets/task-tracker/tabs.mp4": { - "mtime": 1776792488669.555, - "size": 4908318 - }, - "assets/task-tracker/tabs.webm": { - "mtime": 1777119316281.4424, - "size": 804022 - }, - "assets/task-tracker/tabs.webp": { - "mtime": 1777119316704.9702, - "size": 31082 - }, - "assets/task-tracker/tab_all-tasks.png": { - "mtime": 1724892344000, - "size": 285868 - }, - "assets/task-tracker/tab_all-tasks.webp": { - "mtime": 1777119493851, - "size": 151720 - }, - "assets/task-tracker/tab_my-tasks.png": { - "mtime": 1724892344000, - "size": 232690 - }, - "assets/task-tracker/tab_my-tasks.webp": { - "mtime": 1777119494176, - "size": 118694 - }, - "assets/task-tracker/tab_tasks-by-assign.png": { - "mtime": 1724892344000, - "size": 283151 - }, - "assets/task-tracker/tab_tasks-by-assign.webp": { - "mtime": 1777119494494, - "size": 147370 - }, - "assets/task-tracker/views.mp4": { - "mtime": 1776792546358.3933, - "size": 3655835 - }, - "assets/task-tracker/views.webm": { - "mtime": 1777119348131.017, - "size": 588868 - }, - "assets/task-tracker/views.webp": { - "mtime": 1777119348524.0642, - "size": 30412 - }, - "assets/task-tracker/view_board.png": { - "mtime": 1724892346000, - "size": 285868 - }, - "assets/task-tracker/view_board.webp": { - "mtime": 1777119494790, - "size": 151720 - }, - "assets/task-tracker/view_list.png": { - "mtime": 1724892350000, - "size": 281726 - }, - "assets/task-tracker/view_list.webp": { - "mtime": 1777119495084, - "size": 153030 - }, - "assets/task-tracker/view_table.png": { - "mtime": 1724892354000, - "size": 306629 - }, - "assets/task-tracker/view_table.webp": { - "mtime": 1777119495385, - "size": 159062 - }, - "assets/template-elements-demo/hello.gif": { - "mtime": 1713970676235.8618, - "size": 1445905 - }, - "assets/template-elements-demo/img-long.png": { - "mtime": 1779377996303.2358, - "size": 1430322 - }, - "assets/template-elements-demo/img-long.webp": { - "mtime": 1779377996305.2405, - "size": 134456 - }, - "assets/template-elements-demo/small-image-1.png": { - "mtime": 1779377996307.2373, - "size": 127198 - }, - "assets/template-elements-demo/small-image-1.webp": { - "mtime": 1779377996307.2373, - "size": 6676 - }, - "assets/template-elements-demo/small-image-2.png": { - "mtime": 1779377996309.7412, - "size": 220181 - }, - "assets/template-elements-demo/small-image-2.webp": { - "mtime": 1779377996309.7412, - "size": 9214 - }, - "assets/template-elements-demo/small-image-3.png": { - "mtime": 1779377996312.7493, - "size": 330973 - }, - "assets/template-elements-demo/small-image-3.webp": { - "mtime": 1779377996313.7478, - "size": 19430 - }, - "assets/template-elements-demo/small-image-4.png": { - "mtime": 1779377996314.7488, - "size": 211288 - }, - "assets/template-elements-demo/small-image-4.webp": { - "mtime": 1779377996315.7507, - "size": 11306 - }, - "assets/template-elements-demo/small-image-5.png": { - "mtime": 1779377996317.748, - "size": 249941 - }, - "assets/template-elements-demo/small-image-5.webp": { - "mtime": 1779377996318.7485, - "size": 15056 - }, - "assets/template-elements-demo/small-image-6.png": { - "mtime": 1779377996319.7495, - "size": 161762 - }, - "assets/template-elements-demo/small-image-6.webp": { - "mtime": 1779377996320.861, - "size": 8470 - }, - "assets/template-elements-demo/small-image-7.png": { - "mtime": 1779377996322.8584, - "size": 196080 - }, - "assets/template-elements-demo/small-image-7.webp": { - "mtime": 1779377996323.8606, - "size": 7832 - }, - "assets/template-elements-demo/video-1.mp4": { - "mtime": 1774365295973.9094, - "size": 3676034 - }, - "assets/template-elements-demo/video-1.webm": { - "mtime": 1777119460084.1753, - "size": 3490147 - }, - "assets/template-elements-demo/video-1.webp": { - "mtime": 1777119460340.927, - "size": 112702 - }, - "assets/template-elements-demo/wallpaper-1.jpg": { - "mtime": 1770206903718.8467, - "size": 1011262 - }, - "assets/template-elements-demo/wallpaper-1.webp": { - "mtime": 1777119432828, - "size": 795944 - }, - "cases/ai-platform-primo-rpa-2.html": { - "mtime": 1782245252136.977, - "size": 56010 - }, - "cases/ai-platform-primo-rpa.html": { - "mtime": 1782245252138.9834, - "size": 65300 - }, - "cases/design-system.html": { - "mtime": 1782245252140.9756, - "size": 51499 - }, - "cases/lunacy.html": { - "mtime": 1782245252140.9756, - "size": 51520 - }, - "cases/manu-game-maker.html": { - "mtime": 1782245252142.1548, - "size": 33426 - }, - "cases/mini-projects-showcase.html": { - "mtime": 1782245252144.1592, - "size": 45596 - }, - "cases/mobile-clinic-app.html": { - "mtime": 1782245252145.1597, - "size": 33340 - }, - "cases/obsidian-plugin-columns-view.html": { - "mtime": 1782245252145.1597, - "size": 34314 - }, - "cases/orc-automation-panel.html": { - "mtime": 1782245252146.161, - "size": 48044 - }, - "cases/task-distribution-concept.html": { - "mtime": 1782245252147.1592, - "size": 33330 - }, - "cases/task-tracker.html": { - "mtime": 1782245252149.1597, - "size": 66746 - }, - "cases/template-elements-demo.html": { - "mtime": 1782245252150.16, - "size": 47480 - }, - "cookie-policy.html": { - "mtime": 1782245252151.1606, - "size": 20265 - }, - "css/main.css": { - "mtime": 1782122516629.0486, - "size": 129130 - }, - "index.html": { - "mtime": 1782245252151.1606, - "size": 69045 - }, - "js/main.js": { - "mtime": 1780407662602.799, - "size": 2804 - }, - "js/modules/cases/image-viewer.js": { - "mtime": 1780407662603.8, - "size": 13683 - }, - "js/modules/cases/natural-image-size.js": { - "mtime": 1777287125125.0857, - "size": 625 - }, - "js/modules/cases/sequential-images.js": { - "mtime": 1779377996345.018, - "size": 1224 - }, - "js/modules/cases/tall-media-expand.js": { - "mtime": 1779377996345.018, - "size": 1861 - }, - "js/modules/cases/video-player.js": { - "mtime": 1779377996347.0156, - "size": 1095 - }, - "js/modules/experience/expand.js": { - "mtime": 1777287125125.0857, - "size": 3993 - }, - "js/modules/experience/hover-glow.js": { - "mtime": 1777636422925.3562, - "size": 912 - }, - "js/modules/experience/masonry.js": { - "mtime": 1780407662605.8008, - "size": 2916 - }, - "js/modules/header/header-scroll.js": { - "mtime": 1779377996348.017, - "size": 4328 - }, - "js/modules/header/nav-overflow.js": { - "mtime": 1777287125117.0547, - "size": 5621 - }, - "js/modules/mini-projects/expand.js": { - "mtime": 1780407662607.4187, - "size": 4418 - }, - "js/modules/mini-projects/masonry.js": { - "mtime": 1782122516632.0474, - "size": 5036 - }, - "js/modules/shared/dom.js": { - "mtime": 1777287125128.091, - "size": 221 - }, - "js/modules/shared/image-src.js": { - "mtime": 1780407662612.4294, - "size": 214 - }, - "js/modules/shared/math.js": { - "mtime": 1777287125125.0857, - "size": 161 - }, - "js/modules/shared/media.js": { - "mtime": 1777287125117.0547, - "size": 645 - }, - "js/modules/shared/schedule-idle.js": { - "mtime": 1779377996348.017, - "size": 457 - }, - "js/modules/theme/theme-controls.js": { - "mtime": 1779377996349.5276, - "size": 6205 - }, - "js/modules/theme/theme-core.js": { - "mtime": 1782122516625.0393, - "size": 4885 - }, - "js/modules/theme/theme-fonts.js": { - "mtime": 1779377996352.5422, - "size": 1147 - }, - "js/modules/ui/contact-copy.js": { - "mtime": 1777287125128.091, - "size": 1793 - }, - "js/modules/ui/content-edit/content-edit-dock.js": { - "mtime": 1779377996353.5432, - "size": 9560 - }, - "js/modules/ui/content-edit/content-edit-mode.js": { - "mtime": 1779377996355.5415, - "size": 11767 - }, - "js/modules/ui/content-edit/content-edit-storage.js": { - "mtime": 1779377996355.5415, - "size": 868 - }, - "js/modules/ui/content-edit/content-edit-target.js": { - "mtime": 1779377996357.5415, - "size": 9573 - }, - "js/modules/ui/cookie-notice.js": { - "mtime": 1779377996357.5415, - "size": 923 - }, - "js/modules/ui/deferred-media.js": { - "mtime": 1779377996358.5417, - "size": 470 - }, - "js/modules/ui/hero-motion-video.js": { - "mtime": 1782244713466.1138, - "size": 1004 - }, - "js/modules/ui/hero-word-slot.js": { - "mtime": 1777287125117.0547, - "size": 3717 - }, - "js/modules/ui/image-load-placeholder.js": { - "mtime": 1780407662613.426, - "size": 2435 - }, - "js/modules/ui/mesh-panel.js": { - "mtime": 1779377996360.0525, - "size": 859 - }, - "js/modules/ui/page-mesh-bg.js": { - "mtime": 1779377996361.0547, - "size": 3216 - }, - "js/modules/ui/primary-button-shine.js": { - "mtime": 1777287125118.0613, - "size": 952 - }, - "js/modules/ui/projects-carousel.js": { - "mtime": 1779377996362.0613, - "size": 6390 - }, - "js/modules/ui/skills-accordion.js": { - "mtime": 1778061201791.5735, - "size": 5099 - }, - "js/modules/ui/skills-intro.js": { - "mtime": 1780407662615.4253, - "size": 1983 - }, - "js/skill-connectors.js": { - "mtime": 1776439133838.0786, - "size": 12132 - }, - "README.md": { - "mtime": 1777051804851.6003, - "size": 1363 - }, - "vendor/plyr/plyr.css": { - "mtime": 1779377996364.0625, - "size": 32455 - }, - "vendor/plyr/plyr.min.mjs": { - "mtime": 1779377996366.0618, - "size": 121675 - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 784bae0..98c2777 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ coverage/ !.cursor/commands/ !.cursor/commands/** .vscode/ +.zed/ gitverse/ # Logs diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md index 170afb3..1bcdcb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,18 @@ # Подсказки для агента (Cursor) -## GitVerse / деплой статики +## Сборка и деплой -Полный чеклист: **`docs/gitverse-sync.md`** → раздел **«Инструкция для агента (Cursor)»**. +Деплой автоматический: пуш в git → автодеплой на хостинг. -Деплой на VPS: **`docs/vps-deploy.md`**. +По запросу пользователя «собрать», «билд»: -Кратко по запросу пользователя «выложить на GitVerse», «синк gitverse», «задеплоить»: +1. При правках `src/pages/**` или `src/partials/**` — `npm run build:pages`. +2. При смене шрифтов/Plyr или `package.json` — `npm run build` (включает `vendor:copy`). +3. Для деплоя — сделать git commit + push. -1. При правках `src/pages/**` или `src/partials/**` — `npm run build:pages`; при смене шрифтов/Plyr или `package.json` — `npm run build` (включает `vendor:copy`). -2. Затем `npm run sync-gitverse` (разрешения **network** и **git_write**). -3. Не запускать синк без явного запроса пользователя. +## Git -По запросу «задеплой в Coolify», «задеплой на VPS»: - -1. При правках в `public/` — `npm run deploy:vps` (разрешения **all**). -2. По запросу «задеплой везде» — `npm run deploy` (VPS + GitVerse). - -Не запускать деплой на VPS без явного запроса: скрипт перезаписывает `/data/static-site` на сервере. - -## Git: корневой репозиторий - -Этот репозиторий в корне проекта **только локальный** (рабочая копия, история коммитов для себя). **Не добавлять `origin` и не пушить** в удалённый репозиторий из агента, если пользователь явно не попросил настроить remote и не дал URL. Выгрузка наружу — отдельный сценарий через **`npm run sync-gitverse`** в папку `gitverse` (см. выше), а не `git push` из корня. +Репозиторий: `http://x1qnvk08zec2u5cx6yh5iexh.155.212.186.39.sslip.io/admin/site-portfolio.git` ### Влить в `master`, оставаясь на текущей ветке @@ -33,9 +24,14 @@ ## Design System -Страница-каталог: **`src/pages/cases/design-system.html`** → после сборки `public/cases/design-system.html`. +Перед работой с UI **прочитай `DESIGN.md`** в корне проекта — там описаны токены, типографика, компоненты и правила их применения. -При **глобальных** правках стилей (токены в `public/css/main.css`, компоненты главной, блоки лонгрида кейса) **обновляй Design System**: живой пример и краткое описание в соответствующей секции, при необходимости — `docs/case-page-blocks.md`. +Страницы-каталоги (живые примеры): +- **`src/pages/cases/design-system.html`** → токены, типографика, кнопки, карточки, секции +- **`src/pages/cases/template-elements-demo.html`** → элементы лонгрида: медиа, галереи, видео, callout +- **`src/pages/cases/mini-projects-showcase.html`** → карточки активности (mp-card) + +При **глобальных** правках стилей (токены в `public/css/main.css`, компоненты главной, блоки лонгрида кейса) **обновляй соответствующий каталог**: живой пример и краткое описание в секции, при необходимости — `docs/case-page-blocks.md`. ## Контент кейса в public (по запросу) @@ -49,6 +45,18 @@ Режим на странице при `npm run serve` → очередь в **`texts/content-edits/pending.json`**. Apply по запросу **«примени правки»**: читать этот файл, править **`src/pages/`**, **humanizer-ru**, **`npm run build:pages`**; **`pending.json` не очищать** (пользователь удаляет пункты сам). Подробности: **`docs/content-edit-mode.md`**, **`.cursor/rules/content-edit-apply.mdc`**. +## PDF резюме (по запросу) + +По запросу «обнови PDF», «перегенерируй PDF»: + +1. Убедиться, что dev-сервер запущен (`localhost:4175`). +2. Запустить `npm run generate:resume-pdf`. +3. Файл обновится в `public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf`. + +Подробнее: **`docs/resume-pdf.md`**. + +Скрипт требует запущенный сервер — нельзя встроить в обычный `build`. + ## Vendor (шрифты, Plyr) Локально в `public/`, без CDN. Обновление: **`docs/vendor-assets.md`**, команда `npm run vendor:copy`. @@ -60,3 +68,16 @@ ## Остальное Правила и таблица доков: **`.cursor/rules/read-docs-before-feature-dev.mdc`** (локально; папка `.cursor/` может быть в `.gitignore`). + +### Ключевые файлы + +| Файл | Назначение | +|---|---| +| `DESIGN.md` | Описание дизайн-системы для AI-агентов | +| `public/css/main.css` | Единый CSS: токены + все компоненты | +| `src/pages/cases/design-system.html` | Живой каталог токенов, типографики, кнопок, карточек | +| `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout | +| `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) | +| `docs/case-page-blocks.md` | Инструкция по блокам лонгридов | +| `docs/resume-pdf.md` | Генерация PDF резюме (Puppeteer) | +| `docs/translation.md` | Локализация (ru/en) и перевод через apinex | diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e6af351 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,247 @@ +# DESIGN.md + +Портфолио-сайт продуктового дизайнера. Лонгриды кейсов, резюме, блог. Визуал — минимализм с акцентом на типографику и контент. + +--- + +## Продукт + +Одностраничный сайт-портфолио с внутренними страницами (кейсы, резюме, блог). Основной контент — лонгриды с описанием проектов. Навигация через главную страницу с секциями: навыки, портфолио, контакты. + +**Характер:** Сдержанный, профессиональный. Без визуального шума. Контент — главный элемент интерфейса. + +--- + +## Цвета + +### Токены темы + +| Токен | Назначение | +|---|---| +| `--theme-h` | Оттенок акцента (0–360°) | +| `--theme-s` | Насыщенность акцента | +| `--theme-l` | Светлота акцента | +| `--accent` | Основной акцентный цвет | +| `--accent-text` | Цвет текста на акцентном фоне | +| `--accent-soft` | Мягкий акцент для фона | +| `--bg` | Фон страницы | +| `--bg-elevated` | Поверхность второго уровня | +| `--surface` | Фон карточек и секций | +| `--text` | Основной текст | +| `--text-muted` | Приглушённый текст | +| `--line` | Рамки и разделители | + +### Правила + +- Акцент меняется через `--theme-h/s/l` на ``. Все производные пересчитываются автоматически. +- Светлая/тёмная тема — `data-theme="light|dark"` на ``. +- Нейтральные фоны (`--bg`, `--surface`) слегка подмешивают оттенок акцента через `--neutral-accent-tint`. +- Не используйте хардкод hex-значений. Всегда обращайтесь к токенам. + +--- + +## Типографика + +### Семейства + +| `data-font` | Стек | Назначение | +|---|---|---| +| `merriweather` | Merriweather + Alice | По умолчанию. Alice — проза, Merriweather — заголовки | +| `geologica` | Geologica + PT Sans | Гротеск + нейтральная проза | +| `mono` | Source Code Pro + PT Mono | Моноширинный | + +### Шкала + +| Класс | Размер | Вес | Назначение | +|---|---|---|---| +| `.hero__eyebrow` | `0.78rem` | 700 | Подзаголовок секции, ввод кейса. Uppercase, letter-spacing 0.14em | +| `.hero__title` | `clamp(3.1rem, 7.2vw, 4.85rem)` | 700 | H1 главной страницы | +| `.case-page__h1` | `clamp(2rem, 3.9vw, 2.9rem)` | 700 | H1 кейса / внутренней страницы | +| `.case-page__sub` | `clamp(1.5rem, 2vw, 1.65rem)` | 500 | Подзаголовок под H1 | +| `.section__title` | `clamp(0.95rem, 1.35vw, 1.1rem)` | 600 | Заголовки секций («Навыки», «Портфолио») | +| `.section__prose` | наследует | — | Основной текст абзацев | +| `h2` в `.case-article` | `clamp(1.5rem, 2vw, 1.65rem)` | 500 | Разделы лонгрида | +| `h3` в `.case-article` | наследует | — | Подразделы внутри H2 | + +### Правила + +- Заголовки используют `clamp()` — размер масштабируется от вьюпорта. +- Не задавайте `font-size` в пикселях. Используйте `rem` или `clamp()`. +- `line-height` для прозы: `1.55` (базовый), для заголовков: `1.2`. +- `max-width` текстовой колонки: `42rem` (`.section__prose`). + +--- + +## Отступы и сетка + +### Токены + +| Токен | Значение | Назначение | +|---|---|---| +| `--space` | `clamp(1rem, 4vw, 2.75rem)` | Горизонтальные поля + safe-area | +| `--max` | `none` | Ширина контента = вьюпорт минус padding | +| `--radius-sm` | Зависит от `data-radius` | Малые скругления | +| `--radius-md` | Зависит от `data-radius` | Средние скругления | +| `--radius-card` | Зависит от `data-radius` | Карточки | + +### Секции + +| Класс | Назначение | +|---|---| +| `.section` | Вертикальные отступы через `clamp()` | +| `.section--tight` | Компактные отступы (кейсы, внутренние страницы) | +| `.section--surface` | Прозрачный фон (для mesh на главной) | +| `.section__inner` | Центрированная обёртка с `max-width: var(--max)` | +| `.case-article` | Контентная колонка лонгрида: `max-width: 48rem` | + +### Брейкпоинты + +| Ширина | Что меняется | +|---|---| +| `860px` | Hero → 2 колонки (`1.15fr 0.85fr`) | +| `760px` / `1180px` | Карусель портфолио: 1 → 2 → 3 карточки | +| `720px` | Сетка навыков → 2 колонки | +| `1024px` | Навыки: 6-колоночная мозаика | +| `560px` / `1024px` | Контакты → 2 → 4 колонки | +| `640px` | Wide-блоки схлопываются в ширину колонки | + +### Правила + +- Отдельной 12-колоночной сетки нет. Используйте CSS Grid внутри секций. +- Горизонтальные отступы — `--space` + `safe-area-inset`. +- Wide-блоки (`.case-media--wide`, `.case-callout`) шире колонки: `min(100vw - 2*padding, content + 8rem)`. + +--- + +## Компоненты + +### Кнопки + +| Класс | Назначение | +|---|---| +| `.btn` | Базовый класс кнопки | +| `.btn--primary` | Основная кнопка (акцентный фон, стекло) | +| `.btn--secondary` | Вторичная кнопка (нейтральное стекло) | +| `.btn--ghost` | Текстовая кнопка без фона | +| `.btn--flat` | Плоский вариант (без «объёма») | +| `.btn--raised` | Приподнятый вариант (с тенью) | +| `.btn__icon` | Иконка внутри кнопки | + +### FAB-кнопки + +| Класс | Назначение | +|---|---| +| `.fab` | Базовый класс плавающей кнопки (круглая, `position: fixed`) | +| `.fab--primary` | Акцентная FAB (градиент, стекло, `--on-accent` для текста/иконки) | +| `.fab--secondary` | Вторичная FAB (нейтральная, для настроек) | +| `.fab--label` | Модификатор: снимает фиксированную ширину, добавляет текст рядом с иконкой | +| `.fab-primary__ico` | Иконка внутри primary FAB (маска через `mask-image`) | +| `.fab__label` | Текстовая подпись внутри FAB | + +Правило: `--on-accent` вычисляется в синхронном `theme-init-script` (в ``) по luminance акцента. Не задавайте `color` вручную — наследуется от `.fab--primary`. + +### Теги + +| Класс | Назначение | +|---|---| +| `.hero__tags` | Контейнер тегов в Hero | +| `.hero__tag` | Тег (пилл с рамкой). Используйте для ключевых слов | +| `.portfolio-card__tag` | Тег внутри карточки портфолио | + +### Карточки + +| Класс | Назначение | +|---|---| +| `.portfolio-card` | Карточка проекта в портфолио | +| `.portfolio-card__body` | Текстовая часть | +| `.portfolio-card__cover` | Обложка | +| `.skill-task-card` | Карточка навыка (задача + решения) | +| `.contact-card` | Контактная карточка (ссылка или действие) | + +### Блоки лонгрида + +| Класс | Назначение | +|---|---| +| `.hero__eyebrow` | Подзаголовок перед H1 | +| `.case-page__h1` | Заголовок страницы | +| `.case-page__sub` | Подзаголовок | +| `.section__prose` | Абзац текста | +| `.section__title` | Заголовок секции | +| `.case-callout` | Акцентный блок (важная мысль) | +| `.case-media` | Обёртка для изображения/видео | +| `.case-media--natural` | Изображение в натуральном масштабе | +| `.case-media--content` | По ширине текстовой колонки | +| `.case-media--wide` | Шире колонки статьи | +| `.case-media-gallery--wide` | Шире колонки статьи (галерея) | +| `.case-media-slider` | Слайдер: одна картинка, стрелки и точки-пагинация | + +--- + +## Поведение + +### Изображения + +- Формат: **WebP** для всех изображений. +- Видео: **WebM** (MP4 как fallback). +- Lazy loading: `loading="lazy"`, `decoding="async"`. +- Просмотр по клику: `data-image-viewer` на ``. +- Заглушка при загрузке: `.is-img-loading` / `.has-img-loading`. + +### Секции + +- Порядок на странице кейса: `header-case` → `fab-stack` → `main.case-article` → `projects-section` → `contact-section` → `footer`. +- Порядок на внутренних страницах: `header-minimal` → `fab-stack-home-contact` → `main.section` → `footer`. + +### Навигация + +- Главная: горизонтальная навигация по секциям. +- Внутренние страницы: ссылка «на главную» в шапке. +- Плавающие кнопки (FAB): тема, контакты. + +--- + +## Do's and Don'ts + +### Do's + +- Используйте токены цвета (`--accent`, `--text`, `--surface`), не хардкодьте. +- Используйте `clamp()` для типографики — не фиксированные px/rem. +- Используйте `.section__prose` для основного текста — он задаёт `max-width` и `color`. +- Используйте `.hero__eyebrow` для подзаголовков перед заголовками. +- Используйте WebP для изображений, WebM для видео. +- Для лонгридов внутри `.case-article` — заголовки `h2`/`h3` стилизуются автоматически. + +### Don'ts + +- Не создавайте новые классы для типографики, если есть готовые в таблице шкалы. +- Не используйте `font-size` в пикселях. +- Не смешивайте стили из разных `data-font` на одной странице. +- Не используйте `.hero__tag` для навыков в резюме — используйте `.skill-card` или простой список. +- Не создавайте wide-блоки без необходимости — они выходят за колонку и ломают ритм. +- Не используйте декоративные градиенты на функциональных поверхностях. + +--- + +## Страницы + +| Страница | Шаблон | Ключевые компоненты | +|---|---|---| +| Главная | `src/pages/index.html` | Hero, Skills, Projects, Contact | +| Кейс | `src/pages/cases/*.html` | `.case-article`, `.hero__eyebrow`, `.case-page__h1`, `.case-media` | +| Резюме | `src/pages/resume*.html` | `.doc`, `.profile-header`, `.meta-grid`, `.timeline`, `.skill-grid` | +| Design System | `src/pages/cases/design-system.html` | Живой каталог компонентов и токенов | +| Demo elements | `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout | +| Mini projects | `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) | + +--- + +## Файлы + +| Файл | Назначение | +|---|---| +| `public/css/main.css` | Единый CSS: токены + все компоненты | +| `src/pages/cases/design-system.html` | Живой каталог токенов, типографики, кнопок, карточек | +| `src/pages/cases/template-elements-demo.html` | Элементы лонгрида: медиа, галереи, видео, callout | +| `src/pages/cases/mini-projects-showcase.html` | Карточки активности (mp-card) | +| `docs/case-page-blocks.md` | Инструкция по блокам лонгридов | +| `DESIGN.md` | Этот файл — описание для AI-агентов | diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..7f114c8 --- /dev/null +++ b/IDEA.md @@ -0,0 +1 @@ +Мой сайт портфолио diff --git a/_fab_ds.png b/_fab_ds.png new file mode 100644 index 0000000..98a3963 Binary files /dev/null and b/_fab_ds.png differ diff --git a/_fab_lunacy.png b/_fab_lunacy.png new file mode 100644 index 0000000..65b3b80 Binary files /dev/null and b/_fab_lunacy.png differ diff --git a/_menu2.mjs b/_menu2.mjs new file mode 100644 index 0000000..ef7677c --- /dev/null +++ b/_menu2.mjs @@ -0,0 +1,18 @@ +import puppeteer from "puppeteer"; +const b = await puppeteer.launch({ args: ["--no-sandbox","--disable-dev-shm-usage"] }); +const p = await b.newPage(); +await p.setViewport({ width: 768, height: 900, deviceScaleFactor: 1 }); +await p.goto("http://localhost:4175/", { waitUntil: "networkidle0" }); +await p.evaluate(() => window.scrollTo(0, 400)); +await new Promise(r=>setTimeout(r,400)); +await p.evaluate(() => document.querySelector("[data-nav-more-btn]")?.click()); +await new Promise(r=>setTimeout(r,300)); +const m = await p.evaluate(() => { + const menu = document.querySelector("#nav-more-menu-floating"); + if (!menu || menu.hidden) return { hidden: true }; + const mr = menu.getBoundingClientRect(); + const vw = document.documentElement.clientWidth; + return { rightFromEdge: Math.round(vw - mr.right), edgePad: getComputedStyle(document.documentElement).getPropertyValue("--edge-pad").trim() }; +}); +console.log("MENU@768:", JSON.stringify(m)); +await b.close(); diff --git a/allsh.mjs b/allsh.mjs new file mode 100644 index 0000000..dd12e25 --- /dev/null +++ b/allsh.mjs @@ -0,0 +1,23 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1500}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView()); +await new Promise(r=>setTimeout(r,1500)); +const r = await p.evaluate(()=>{ + const cells=[...document.querySelectorAll('[data-shader-cell]')]; + const cellInfo=cells.map(c=>{const m=c.querySelector('.card-shader')?.paperShaderMount; return {key:c.dataset.shaderCell, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null};}); + const demo=document.querySelector('[data-shader-demo]'); + const demoMounted=!!demo.querySelector('.card-shader')?.paperShaderMount; + // переключим на spiral и проверим label + const btn=document.querySelector('[data-shader-switch="spiral"]'); + let labelAfter=null; + if(btn){ btn.click(); labelAfter=document.querySelector('[data-shader-label]')?.textContent; } + return { cellCount: cells.length, cellInfo, demoMounted, labelAfter }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,8)); +await b.close(); diff --git a/anim.mjs b/anim.mjs new file mode 100644 index 0000000..d26f97a --- /dev/null +++ b/anim.mjs @@ -0,0 +1,20 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,600)); +const snap = ()=>p.evaluate(()=>{ + const cv=document.querySelector('[data-shader-demo] canvas'); + const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true}); + const a=new Uint8Array(4*9); let k=0; + for(const [x,y] of [[20,20],[100,80],[200,150],[50,180],[300,40]]){ gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a.subarray(k,k+4)); k+=4; } + return Array.from(a).join(','); +}); +const s1=await snap(); await new Promise(r=>setTimeout(r,700)); const s2=await snap(); +console.log('frame1:', s1); +console.log('frame2:', s2); +console.log('MOVING:', s1!==s2); +await b.close(); diff --git a/anim2.mjs b/anim2.mjs new file mode 100644 index 0000000..07b83ec --- /dev/null +++ b/anim2.mjs @@ -0,0 +1,18 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,600)); +async function shot(){ const el=await p.$('[data-shader-demo]'); await el.screenshot({path:'/tmp/a.png'}); + const {data,info}=await sharp('/tmp/a.png').raw().toBuffer({resolveWithObject:true}); + return {w:info.width,h:info.height,data}; } +const s1=await shot(); await new Promise(r=>setTimeout(r,800)); const s2=await shot(); +// сравним центральные пиксели +function center(d,info){ const i=((info.height>>1)*info.width+(info.width>>1))*3; return [d[i],d[i+1],d[i+2]]; } +const c1=center(s1.data,s1), c2=center(s2.data,s2); +console.log('center px t1:', c1, ' t2:', c2, ' MOVED:', JSON.stringify(c1)!==JSON.stringify(c2)); +await b.close(); diff --git a/anim3.mjs b/anim3.mjs new file mode 100644 index 0000000..d7bbb2b --- /dev/null +++ b/anim3.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; }); +await p.evaluate((t)=>window.scrollTo(0, t-150), top); +await new Promise(r=>setTimeout(r,700)); +async function region(){ await p.screenshot({path:'/tmp/r.png'}); + const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; }); + const {data,info}=await sharp('/tmp/r.png').raw().toBuffer({resolveWithObject:true}); + // хеш центральной полосы + const samples=[]; const y=Math.floor(rect.y+rect.h*0.4); + for(let x=Math.floor(rect.x+20); xsetTimeout(r,800)); const c=await region(); +console.log('moved:', a!==c); +console.log('t1 head:', a.slice(0,60)); console.log('t2 head:', c.slice(0,60)); +await b.close(); diff --git a/animlong.mjs b/animlong.mjs new file mode 100644 index 0000000..d8e68d8 --- /dev/null +++ b/animlong.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +import crypto from 'crypto'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; }); +await p.evaluate((t)=>window.scrollTo(0, t-150), top); +await new Promise(r=>setTimeout(r,700)); +async function hash(){ await p.screenshot({path:'/tmp/rl.png'}); + const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; }); + const {data,info}=await sharp('/tmp/rl.png').raw().toBuffer({resolveWithObject:true}); + const x0=Math.floor(rect.x),y0=Math.floor(rect.y),x1=Math.floor(rect.x+rect.w),y1=Math.floor(rect.y+rect.h); + const buf=Buffer.alloc((x1-x0)*(y1-y0)*3); + let k=0; for(let y=y0;ysetTimeout(r,3000)); const c=await hash(); +console.log('hash1', a); console.log('hash2', c); console.log('ANIMATED:', a!==c); +await b.close(); diff --git a/basicshader.mjs b/basicshader.mjs new file mode 100644 index 0000000..8c7dc5c --- /dev/null +++ b/basicshader.mjs @@ -0,0 +1,26 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:800,height:600}); +await p.goto('about:blank',{waitUntil:'load'}); +await new Promise(r=>setTimeout(r,300)); +const r = await p.evaluate(()=>{ + const cv=document.createElement('canvas'); cv.width=64; cv.height=64; document.body.appendChild(cv); + const gl=cv.getContext('webgl2'); + if(!gl) return {webgl2:false}; + const vs=`#version 300 es\nin vec4 a_position; void main(){ gl_Position=a_position; }`; + const fs=`#version 300 es\nprecision mediump float; out vec4 o; void main(){ o=vec4(1.0,0.3,0.0,1.0); }`; + const mk=(s)=>{const sh=gl.createShader(s.includes('main(){ gl_Position')?gl.VERTEX_SHADER:gl.FRAGMENT_SHADER);gl.shaderSource(sh,s);gl.compileShader(sh);return {sh,ok:gl.getShaderParameter(sh,gl.COMPILE_STATUS),log:gl.getShaderInfoLog(sh)};}; + const v=mk(vs), f=mk(fs); + const prog=gl.createProgram(); gl.attachShader(prog,v.sh); gl.attachShader(prog,f.sh); gl.linkProgram(prog); + const linked=gl.getProgramParameter(prog,gl.LINK_STATUS); + gl.useProgram(prog); + const buf=gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buf); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW); + const loc=gl.getAttribLocation(prog,'a_position'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0); + gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT); + gl.drawArrays(gl.TRIANGLES,0,6); + const px=new Uint8Array(4); gl.readPixels(32,32,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + return { webgl2:true, vsOk:v.ok, fsOk:f.ok, fsLog:f.log, linked, center:Array.from(px) }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/bright.mjs b/bright.mjs new file mode 100644 index 0000000..b22f7f3 --- /dev/null +++ b/bright.mjs @@ -0,0 +1,18 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; }); +await p.evaluate((t)=>window.scrollTo(0, t-150), top); +await new Promise(r=>setTimeout(r,800)); +await p.screenshot({path:'/tmp/sec.png'}); +const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; }); +const {data,info}=await sharp('/tmp/sec.png').raw().toBuffer({resolveWithObject:true}); +const x0=Math.floor(rect.x), y0=Math.floor(rect.y), x1=Math.floor(rect.x+rect.w), y1=Math.floor(rect.y+rect.h); +let maxR=0,maxG=0,maxB=0, brightPx=0, n=0; +for(let y=Math.max(0,y0);y250) brightPx++; n++; } +console.log('max channel values in demo card:', maxR, maxG, maxB, 'bright pixels:', brightPx, '/', n); +await b.close(); diff --git a/checkshadows.mjs b/checkshadows.mjs new file mode 100644 index 0000000..44aaf7a --- /dev/null +++ b/checkshadows.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1500}); +await p.goto('http://10.200.201.3:4175/',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(()=>{ + const pick = (sel)=>{ const el=document.querySelector(sel); if(!el) return null; + const cs=getComputedStyle(el); + return { boxShadow: cs.boxShadow.slice(0,60), shadowLen: cs.boxShadow.length, transform: cs.transform, bg: cs.backgroundImage.slice(0,40) }; }; + return { + mp: pick('.mp-card'), + portfolio: pick('.portfolio-card'), + skill: pick('.skill-task-card'), + theme: document.documentElement.getAttribute('data-theme'), + }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,6)); +await b.close(); diff --git a/chk2.mjs b/chk2.mjs new file mode 100644 index 0000000..a78b186 --- /dev/null +++ b/chk2.mjs @@ -0,0 +1,18 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); }); +p.on('pageerror',e=>errs.push('PAGEERR '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +const cards = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>{ + const cs = getComputedStyle(e); + const before = getComputedStyle(e,'::before'); + const img = e.querySelector('img'); + return { name:e.querySelector('.contact-card__name')?.textContent, + boxShadow: cs.boxShadow.slice(0,40), + beforeBg: before.backgroundImage.slice(0,60), + borderW: cs.borderTopWidth, + imgLoaded: img ? (img.complete && img.naturalWidth>0) : 'no-img' }; +})); +console.log(JSON.stringify({count:cards.length, cards, errs}, null, 1)); +await b.close(); diff --git a/chk3.mjs b/chk3.mjs new file mode 100644 index 0000000..3b841be --- /dev/null +++ b/chk3.mjs @@ -0,0 +1,13 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{ if(m.type()==='error') errs.push(m.text()); }); +p.on('pageerror',e=>errs.push('PAGEERR '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +// dark +const dark = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, bs:e.style.boxShadow?'inline':'', shadow:getComputedStyle(e).boxShadow.length}))); +// light theme +await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light')); +const light = await p.$$eval('#sec-contact .contact-card', els => els.map(e=>({n:e.querySelector('.contact-card__name')?.textContent, shadow:getComputedStyle(e).boxShadow.slice(0,30)}))); +console.log(JSON.stringify({darkCount:dark.length, light, errs}, null, 1)); +await b.close(); diff --git a/clean.mjs b/clean.mjs new file mode 100644 index 0000000..3facf85 --- /dev/null +++ b/clean.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1400}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const r = await p.evaluate(()=>{ + const shaderCards=[...document.querySelectorAll('.card-shader[data-shader]')]; + const mounted=shaderCards.map(w=>!!w.paperShaderMount); + return { + hasSecShader: !!document.querySelector('#sec-shader'), + hasSecShaderGrid: !!document.querySelector('#sec-shader-grid'), + hasSecContact: !!document.querySelector('#sec-contact'), + totalShaderWraps: shaderCards.length, + allMounted: mounted.every(Boolean), + mountedCount: mounted.filter(Boolean).length, + }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,5)); +await b.close(); diff --git a/cmp.mjs b/cmp.mjs new file mode 100644 index 0000000..39b8e6c --- /dev/null +++ b/cmp.mjs @@ -0,0 +1,23 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); + +const probe = async (sel) => p.evaluate((s)=>{ + const e = document.querySelector(s); + const cs = getComputedStyle(e); + const bs = cs.boxShadow.split('),').map(x=>x.trim()); + return { + bg: cs.backgroundColor, + boxShadow: bs, + }; +}, sel); + +const neu = await probe('#sec-final .neu-card'); +const cc = await probe('#sec-contact .contact-card'); +console.log('=== NEU-CARD (Финал) ==='); +console.log(JSON.stringify(neu,null,1)); +console.log('=== CONTACT-CARD ==='); +console.log(JSON.stringify(cc,null,1)); +await b.close(); diff --git a/cmp2.mjs b/cmp2.mjs new file mode 100644 index 0000000..a7e8f12 --- /dev/null +++ b/cmp2.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +const probe = async (sel) => p.evaluate((s)=>{ + const e = document.querySelector(s); const cs=getComputedStyle(e); + return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,55), shadow: cs.boxShadow.split('),').map(x=>x.trim().replace(/rgba\([^)]*\)/,'C')) }; +}, sel); +console.log('DARK'); +console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card'))); +console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card'))); +await p.evaluate(()=>document.documentElement.setAttribute('data-theme','light')); +console.log('LIGHT'); +console.log(' neu:', JSON.stringify(await probe('#sec-final .neu-card'))); +console.log(' cc :', JSON.stringify(await probe('#sec-contact .contact-card'))); +await b.close(); diff --git a/cmp3.mjs b/cmp3.mjs new file mode 100644 index 0000000..cfbc9df --- /dev/null +++ b/cmp3.mjs @@ -0,0 +1,11 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +const probe = async (sel) => p.evaluate((s)=>{ + const e = document.querySelector(s); const cs=getComputedStyle(e); + return { bgImg: cs.backgroundImage.slice(0,60) }; +}, sel); +console.log('DARK neu bgImg:', (await probe('#sec-final .neu-card')).bgImg); +console.log('DARK cc bgImg:', (await probe('#sec-contact .contact-card')).bgImg); +await b.close(); diff --git a/cmpf.mjs b/cmpf.mjs new file mode 100644 index 0000000..7aa2351 --- /dev/null +++ b/cmpf.mjs @@ -0,0 +1,19 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +const probe = async (sel) => p.evaluate((s)=>{ + const e=document.querySelector(s); const cs=getComputedStyle(e); + return { bg: cs.backgroundColor, bgImg: cs.backgroundImage.slice(0,45), shadowLen: cs.boxShadow.length, + dir: cs.flexDirection, pad: cs.paddingTop }; +}, sel); +const neu = await probe('#sec-final .neu-card'); +const cc = await probe('#sec-contact .contact-card'); +const neuShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-final .neu-card')).boxShadow); +const ccShadow = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-contact .contact-card')).boxShadow); +console.log('neu:', JSON.stringify(neu)); +console.log('cc :', JSON.stringify(cc)); +console.log('shadows equal:', neuShadow===ccShadow); +console.log('errs:', errs); +await b.close(); diff --git a/console.mjs b/console.mjs new file mode 100644 index 0000000..e544346 --- /dev/null +++ b/console.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const logs=[]; +p.on('console', m=>logs.push('['+m.type()+'] '+m.text())); +p.on('pageerror', e=>logs.push('[pageerror] '+e.message)); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(async ()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod; + const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative'; + document.body.appendChild(host); + const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString); + const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }; + const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0); + return { keys: Object.keys(m.uniformLocations), uColorsLoc: m.uniformLocations.u_colors, uColorsCountLoc: m.uniformLocations.u_colorsCount }; +}); +console.log('RESULT', JSON.stringify(r)); +console.log('LOGS:\n'+logs.join('\n')); +await b.close(); diff --git a/correct.mjs b/correct.mjs new file mode 100644 index 0000000..83adb3f --- /dev/null +++ b/correct.mjs @@ -0,0 +1,32 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(async ()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod; + const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative'; + document.body.appendChild(host); + const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString); + const m = new ShaderMount(host, meshGradientFragmentShader, { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }, { preserveDrawingBuffer:true, antialias:true }, 1.0); + const gl=m.gl, pr=m.program; + // правильное чтение: getUniform ВОЗВРАЩАЕТ значение + const gf=(n)=>gl.getUniform(pr, gl.getUniformLocation(pr,n)); + const colorsWritten = gf('u_colors[0]'); // Float32Array(4) + const countWritten = gf('u_colorsCount'); // number + const swirlWritten = gf('u_swirl'); + // readPixels скан + m.render(performance.now()); m.render(performance.now()); + const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf); + let bright=0,maxc=0; for(let i=0;i120)bright++; maxc=Math.max(maxc,mx);} + return { + u_colors0: colorsWritten ? Array.from(colorsWritten).map(x=>+x.toFixed(2)) : null, + u_colorsCount: countWritten, + u_swirl: swirlWritten, + brightPixels: bright, maxChannel: maxc, + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/ctor.mjs b/ctor.mjs new file mode 100644 index 0000000..05abcc2 --- /dev/null +++ b/ctor.mjs @@ -0,0 +1,25 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(async ()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod; + const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative'; + document.body.appendChild(host); + const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString); + const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }; + console.log('uniforms passed:', JSON.stringify(uniforms.u_colors), 'count', uniforms.u_colorsCount); + const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0); + m.render(performance.now()); m.render(performance.now()); + const gl=m.gl; + const cnt=new Float32Array(1); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colorsCount'), cnt); + const c0=new Float32Array(4); gl.getUniform(m.program, gl.getUniformLocation(m.program,'u_colors[0]'), c0); + const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf); + let bright=0,maxc=0; for(let i=0;i120)bright++; maxc=Math.max(maxc,mx);} + return { count:cnt[0], c0:Array.from(c0).map(x=>+x.toFixed(2)), bright, maxc }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/demochk.mjs b/demochk.mjs new file mode 100644 index 0000000..51c17a5 --- /dev/null +++ b/demochk.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1200}); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const demo = await p.$('[data-shader-demo]'); +await demo.evaluate(el=>el.scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,400)); +async function probe(key){ + if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,700)); } + const el = await p.$('[data-shader-demo]'); + const box = await el.boundingBox(); + await el.screenshot({path:'/tmp/demo.png'}); + const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true}); + let n=info.width*info.height, rs=0,gs=0,bs=0; const cols=new Set(); + for(let i=0;i>5},${data[i+1]>>5},${data[i+2]>>5}`); } + return { key: key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct: cols.size }; +} +const r1=await probe(); const r2=await probe('grain'); const r3=await probe('voronoi'); const r4=await probe('staticMesh'); +console.log(JSON.stringify({r1,r2,r3,r4,errs},null,1)); +await b.close(); diff --git a/democrop.mjs b/democrop.mjs new file mode 100644 index 0000000..2485ec6 --- /dev/null +++ b/democrop.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView()); +await new Promise(r=>setTimeout(r,900)); +// координаты демо-карточки +const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; }); +await p.screenshot({path:'/tmp/full.png'}); +const img = sharp('/tmp/full.png'); +const {data,info}=await img.raw().toBuffer({resolveWithObject:true}); +// вырежем центр демо-карточки (по координатам вьюпорта) +const x0=Math.floor(rect.x+rect.w*0.2), y0=Math.floor(rect.y+rect.h*0.2); +const x1=Math.floor(rect.x+rect.w*0.8), y1=Math.floor(rect.y+rect.h*0.8); +let rs=0,gs=0,bs=0,n=0; const cols=new Set(); +for(let y=y0;y>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } } +console.log('demo card region avg:', [Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], 'distinct coarse colors:', cols.size); +await b.close(); diff --git a/demodata.mjs b/demodata.mjs new file mode 100644 index 0000000..2c14e87 --- /dev/null +++ b/demodata.mjs @@ -0,0 +1,34 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView()); +await new Promise(r=>setTimeout(r,600)); +async function probe(key){ + if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); } + // снимок canvas напрямую через toDataURL -> png + const dataUrl = await p.evaluate(()=>{ + const cv=document.querySelector('[data-shader-demo] canvas'); + if(!cv) return null; + try { return cv.toDataURL('image/png').slice(0,30); } catch(e){ return 'ERR '+e.message; } + }); + const buf = await p.evaluate(()=>{ + const cv=document.querySelector('[data-shader-demo] canvas'); + return cv ? cv.toDataURL('image/png') : null; + }); + let distinct=0, avg=[0,0,0]; + if(buf){ const b64=buf.split(',')[1]; const {data,info}=await sharp(Buffer.from(b64,'base64')).raw().toBuffer({resolveWithObject:true}); + let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set(); + for(let i=0;i>5},${data[i+1]>>5},${data[i+2]>>5}`); } + avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; } + return { key:key||'mesh', dataUrlHead:dataUrl, avg, distinct }; +} +console.log(JSON.stringify(await probe(),null,0)); +console.log(JSON.stringify(await probe('grain'),null,0)); +console.log(JSON.stringify(await probe('voronoi'),null,0)); +console.log(JSON.stringify(await probe('staticMesh'),null,0)); +await b.close(); diff --git a/demofull.mjs b/demofull.mjs new file mode 100644 index 0000000..36f5522 --- /dev/null +++ b/demofull.mjs @@ -0,0 +1,20 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +// прокрутим ровно к карточке через window.scrollTo по абсолютному смещению +const top = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return r.top+window.scrollY; }); +await p.evaluate((t)=>window.scrollTo(0, t-200), top); +await new Promise(r=>setTimeout(r,900)); +await p.screenshot({path:'/tmp/full2.png'}); +const {data,info}=await sharp('/tmp/full2.png').raw().toBuffer({resolveWithObject:true}); +const rect = await p.evaluate(()=>{ const r=document.querySelector('[data-shader-demo]').getBoundingClientRect(); return {x:r.x,y:r.y,w:r.width,h:r.height}; }); +const x0=Math.max(0,Math.floor(rect.x+20)), y0=Math.max(0,Math.floor(rect.y+20)); +const x1=Math.min(info.width,Math.floor(rect.x+rect.w-20)), y1=Math.min(info.height,Math.floor(rect.y+rect.h-20)); +let rs=0,gs=0,bs=0,n=0; const cols=new Set(); +if(x1>x0&&y1>y0){ for(let y=y0;y>5},${data[i+1]>>5},${data[i+2]>>5}`); n++; } } } +console.log('region avg', n?[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]:'empty', 'distinct', cols.size, 'rect', rect, 'imgsize', info.width+'x'+info.height); +await b.close(); diff --git a/demolast.mjs b/demolast.mjs new file mode 100644 index 0000000..3745aab --- /dev/null +++ b/demolast.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +async function probe(key){ + if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,1000)); } + const el = await p.$('[data-shader-demo]'); + await el.screenshot({path:'/tmp/demo.png'}); + const {data,info}=await sharp('/tmp/demo.png').raw().toBuffer({resolveWithObject:true}); + let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set(); + for(let i=0;i>5},${data[i+1]>>5},${data[i+2]>>5}`); } + return { key:key||'mesh', avg:[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)], distinct:cols.size }; +} +console.log(JSON.stringify(await probe())); +console.log(JSON.stringify(await probe('grain'))); +console.log(JSON.stringify(await probe('voronoi'))); +console.log(JSON.stringify(await probe('staticMesh'))); +await b.close(); diff --git a/demook.mjs b/demook.mjs new file mode 100644 index 0000000..20a0423 --- /dev/null +++ b/demook.mjs @@ -0,0 +1,26 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView()); +await new Promise(r=>setTimeout(r,700)); +async function probe(key){ + if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,900)); } + const buf = await p.evaluate(()=>{ const cv=document.querySelector('[data-shader-demo] canvas'); return cv?cv.toDataURL('image/png'):null; }); + let avg=[0,0,0], distinct=0; + if(buf){ const {data,info}=await sharp(Buffer.from(buf.split(',')[1],'base64')).raw().toBuffer({resolveWithObject:true}); + let n=info.width*info.height,rs=0,gs=0,bs=0; const cols=new Set(); + for(let i=0;i>5},${data[i+1]>>5},${data[i+2]>>5}`); } + avg=[Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)]; distinct=cols.size; } + return { key:key||'mesh', avg, distinct, hasCanvas:!!buf }; +} +console.log(JSON.stringify(await probe(),null,0)); +console.log(JSON.stringify(await probe('grain'),null,0)); +console.log(JSON.stringify(await probe('voronoi'),null,0)); +console.log(JSON.stringify(await probe('staticMesh'),null,0)); +console.log('errs:', errs); +await b.close(); diff --git a/demopic.mjs b/demopic.mjs new file mode 100644 index 0000000..276700a --- /dev/null +++ b/demopic.mjs @@ -0,0 +1,11 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300,deviceScaleFactor:1}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView()); +await new Promise(r=>setTimeout(r,800)); +await p.screenshot({path:'/tmp/sec-shader.png'}); +console.log('saved'); +await b.close(); diff --git a/demovis.mjs b/demovis.mjs new file mode 100644 index 0000000..573a639 --- /dev/null +++ b/demovis.mjs @@ -0,0 +1,28 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1200}); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +async function probe(key){ + if(key){ await p.click(`[data-shader-switch="${key}"]`); await new Promise(r=>setTimeout(r,800)); } + return await p.evaluate(()=>{ + const el=document.querySelector('[data-shader-demo]'); + const cv=el.querySelector('canvas'); + if(!cv) return {canvas:false}; + const gl=cv.getContext('webgl2')||cv.getContext('webgl'); + const px=new Uint8Array(4); + // читаем угол (0,0) — там обычно цвет градиента + gl.readPixels(2,2,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + return { canvas:true, w:cv.width, h:cv.height, corner:Array.from(px), key: el.dataset.shaderKey }; + }); +} +console.log('mesh:', JSON.stringify(await probe())); +console.log('grain:', JSON.stringify(await probe('grain'))); +console.log('voronoi:', JSON.stringify(await probe('voronoi'))); +console.log('staticMesh:', JSON.stringify(await probe('staticMesh'))); +console.log('errs:', errs); +await b.close(); diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 8d0edb5..0000000 --- a/deploy.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -e - -HOST="root@155.212.186.39" -SSH_KEY="$HOME/.ssh/beget" -SRC_DIR="$(cd "$(dirname "$0")" && pwd)/public/" - -echo "📦 Deploy mamadaevv.site..." -echo "Src: $SRC_DIR" - -REMOTE_INFO=$(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" ' - CONTAINER=$(docker ps --format "{{.Names}}" | grep "s9mrcps8xw02r1dak0fsznix" | head -1) - VOLUME_PATH=$(docker inspect "$CONTAINER" 2>/dev/null | python3 -c " -import sys,json -mounts=json.load(sys.stdin)[0].get(\"Mounts\",[]) -print([m[\"Source\"] for m in mounts if m[\"Destination\"]==\"/usr/share/nginx/html\"][0]) -") - echo "$CONTAINER|$VOLUME_PATH" -') - -IFS='|' read -r CONTAINER VOLUME_PATH <<< "$REMOTE_INFO" -echo "Container: $CONTAINER" -echo "Volume: $VOLUME_PATH" - -# Копируем файлы -cd "$SRC_DIR" -tar cf - . | ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" \ - "tar xf - -C $VOLUME_PATH" - -# Фикс MIME для .mjs (plyr.min.mjs модуль) -ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no "$HOST" \ - "docker exec $CONTAINER sh -c \" - if ! grep -q 'application/javascript mjs' /etc/nginx/mime.types 2>/dev/null; then - sed -i 's|application/wasm|application/javascript mjs;\\n application/wasm|' /etc/nginx/mime.types - nginx -s reload && echo 'MIME fix applied' - fi - \"" 2>/dev/null || true - -echo "" -echo "✅ Done! https://mamadaevv.site" diff --git a/diag.mjs b/diag.mjs new file mode 100644 index 0000000..ac131bb --- /dev/null +++ b/diag.mjs @@ -0,0 +1,30 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{ if(m.type()==='error'||m.type()==='warning') errs.push(m.type()+': '+m.text()); }); +p.on('pageerror',e=>errs.push('PAGEERR: '+e.message)); +p.on('requestfailed',r=>errs.push('REQFAIL: '+r.url()+' '+r.failure()?.errorText)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const diag = await p.evaluate(()=>{ + const sec=document.querySelector('#sec-shader'); + const demo=document.querySelector('[data-shader-demo]'); + const canvas=document.querySelector('#sec-shader canvas'); + const sw=document.querySelectorAll('[data-shader-switch]').length; + return { + secExists: !!sec, + secH: sec?Math.round(sec.getBoundingClientRect().height):0, + demoExists: !!demo, + demoH: demo?Math.round(demo.getBoundingClientRect().height):0, + canvasCount: document.querySelectorAll('#sec-shader canvas').length, + swCount: sw, + swVisible: sw?getComputedStyle(document.querySelector('[data-shader-switch]')).display:'n/a', + }; +}); +// проверим доступен ли модуль +const modOk = await p.evaluate(async()=>{ + try { const m = await import('/vendor/paper-shaders/index.js'); return 'OK keys:'+Object.keys(m).length; } + catch(e){ return 'IMPORT ERR: '+e.message; } +}); +console.log(JSON.stringify({diag, modOk, errs: errs.slice(0,10)}, null, 1)); +await b.close(); diff --git a/doccheck.mjs b/doccheck.mjs new file mode 100644 index 0000000..932d6c2 --- /dev/null +++ b/doccheck.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + // два render (drawArrays инициализируется лениво на 1-м) + m.render(performance.now()); m.render(performance.now()); + const W=m.canvasElement.width, H=m.canvasElement.height; + const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf); + let bright=0, maxc=0; + for(let i=0;i120) bright++; maxc=Math.max(maxc,mx); } + const cntLoc=gl.getUniformLocation(m.program,'u_colorsCount'); const cv=new Float32Array(1); gl.getUniform(m.program,cntLoc,cv); + const c0=new Float32Array(4); gl.getUniform(m.program,gl.getUniformLocation(m.program,'u_colors[0]'),c0); + return { brightPixels: bright, maxChannel: maxc, uColorsCountFloat: cv[0], uColor0: Array.from(c0).map(x=>+x.toFixed(2)) }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/docs/case-page-blocks.md b/docs/case-page-blocks.md index 3c7237b..595c550 100644 --- a/docs/case-page-blocks.md +++ b/docs/case-page-blocks.md @@ -141,6 +141,56 @@ **Сетка + отложенный `src`:** в ячейках можно задать реальный URL в **`data-src`** и плейсхолдер в `src` (крошечный `data:image/gif;base64,…`), чтобы [`initCaseSequentialImages`](public/js/modules/cases/sequential-images.js) подставлял картинки **по порядку в DOM** (в т.ч. строка за строкой в галерее). Пока у `img` есть `data-src`, просмотр по клику не открывается. Пример: [`src/pages/cases/lunacy.html`](src/pages/cases/lunacy.html). +### Слайдер изображений с точками (`case-media-slider`) + +Одна картинка во весь блок, навигация стрелками «пред/след» и пагинация точками (активная — акцентным цветом). Переиспользует маски стрелок `shevron-left/right.svg` и токены темы. Логика: [`public/js/modules/cases/media-slider.js`](public/js/modules/cases/media-slider.js) (`initCaseMediaSlider()` вызывается из [`public/js/main.js`](public/js/main.js)). + +**Возможности:** + +- стрелки `data-slider-prev` / `data-slider-next` — переключают соседний слайд, блокируются (`disabled`) на краях; +- точки `data-slider-dot` (в `data-slider-dots`) — переход на конкретный слайд, активная получает `is-active` + `aria-selected`; +- клавиатура: ←/→ внутри блока, Home/End на точках; +- тач/мышь-свайп (только горизонтальный); +- ленивая подгрузка соседних кадров: у `` можно задать `data-src` (временный `src` — прозрачный GIF), скрипт подставит при подходе, как у галереи; пока есть `data-src`, клик по viewer не открывается; +- клик по активному кадру открывает общий viewer кейса (`data-image-viewer`). + +**Ширина слайдера подстраивается под ширину картинок** (высота — автоматически по контенту). Модификаторы как у статичных `.case-media`: + +- базовый (без модификатора) / `.case-media--content` — ширина = `--slider-natural-w` (по умолчанию 100% колонки контента). Задайте на `
` inline-стиль `--slider-natural-w: <ширина картинки>px`, чтобы слайдер точно встал в размер кадров и не превышал его; `max-width` ограничен вьюпортом, поэтому на узких экранах сжимается; +- `.case-media--wide` — слайдер выходит за колонку контента (как одиночные wide-картинки), центрируется по вьюпорту; +- `.case-media--natural` — как базовый, но `max-width` по умолчанию `600px` (переопределите `--slider-natural-w`). + +Кнопки «пред/след» и точки находятся **внутри** `.case-media-slider__viewport`, поэтому не участвуют в расчёте ширины блока. + +**Разметка** (число `data-slider-dot` должно равняться числу `.case-media-slider__slide`; `aria-label` точек — «Перейти к изображению N»): + +```html +
+
+
+
+ … +
+
+ … +
+
+
+ + +
+ + +
+
+``` + +Живой пример: [`src/pages/cases/template-elements-demo.html`](src/pages/cases/template-elements-demo.html) (секция «Слайдер»). + ### Последовательная подгрузка (`data-case-sequential-images`) - Оберни фрагмент статьи с картинками (часто весь `.case-article`): **`data-case-sequential-images`** на контейнере. @@ -183,7 +233,7 @@ PNG/JPEG → WebP по папке: `docs/image-optimization.md`, скрипт `s Подключаемые через `{{> head-links}}` сценарии уже инициализируют: - последовательную подгрузку `data-src` внутри `[data-case-sequential-images]` (`initCaseSequentialImages`); -- просмотр картинок по клику (`data-image-viewer`, делегирование на документ); +- просмотр картинок по клику (`data-image-viewer`, делегирование на документ); при открытии из **галереи** (`.case-media-gallery`) или **слайдера** (`.case-media-slider`) в нижней панели просмотрщика появляются **кнопки-пилюли «пред/след»** и **точки-пагинация** — переключают только картинки этого блока (клавиши ←/→, свайп по горизонтали, клик по точке); у одиночной картинки кнопки и точки скрыты; - плеер видео кейса (`data-case-video-player`); - натуральный размер для `.case-media--natural img`; - сворачивание длинного скрина (`[data-case-tall-media]`, `initCaseTallMediaExpand`). diff --git a/docs/gitverse-sync.md b/docs/gitverse-sync.md deleted file mode 100644 index 06c21ad..0000000 --- a/docs/gitverse-sync.md +++ /dev/null @@ -1,57 +0,0 @@ -# Синхронизация `public` -> `gitverse` - -## Назначение - -Скрипт `sync-gitverse` копирует содержимое `public` в папку `gitverse`, затем выполняет коммит и пуш в репозиторий внутри `gitverse`. - -## Запуск - -- Через npm: - - `npm run sync-gitverse` -- Напрямую: - - `node scripts/sync-gitverse.mjs` -- С явными путями: - - `node scripts/sync-gitverse.mjs ` - -По умолчанию: - -- `sourceDir = public` -- `targetDir = gitverse` - -## Что делает скрипт - -1. Проверяет, что исходная папка существует. -2. В целевой папке удаляет всё, кроме: - - `.git` - - `.gitverse` -3. Копирует содержимое `public` в `gitverse` (файлы `.png` пропускаются — в деплое используются WebP и остальные форматы). -4. В репозитории `gitverse` выполняет: - - `git add .` - - `git commit -m "sync public YYYY-MM-DD HH:mm"` (только если есть изменения) - - `git push` -5. После успешного пуша открывает в системном браузере по умолчанию: `https://mamadaevv.gitverse.site/me/` - -Если изменений после копирования нет, коммит и пуш не делаются. - -## Инструкция для агента (Cursor) - -Когда пользователь просит **выложить на GitVerse**, **собрать/задеплоить gitverse**, **синхронизировать с gitverse** или аналогично: - -1. **Сборка `public/`:** если менялись `src/pages/**` или `src/partials/**` — `npm run build:pages`; если шрифты/Plyr/`package.json` или `scripts/copy-vendor.mjs` — `npm run build` (сначала `vendor:copy`, затем страницы). См. [`docs/vendor-assets.md`](vendor-assets.md). -2. **Синк и пуш:** выполни `npm run sync-gitverse` из корня. Нужны разрешения **network** и **git_write** (SSH на GitVerse, пуш из папки `gitverse`). -3. **Ожидания:** скрипт не копирует файлы `.png` в `gitverse`. После **успешного** `git push` открывается системный браузер с `https://mamadaevv.gitverse.site/me/`. Если изменений в `gitverse` после копирования нет — коммит/пуш не выполняются, браузер не открывается. -4. **Не вызывай** `sync-gitverse` без запроса пользователя: скрипт очищает целевую папку (кроме `.git` и `.gitverse`) и пушит в remote. -5. **Корневой git репозиторий** этого проекта считается **только локальным**: не пушь из корня в `origin` и не настраивай remote без явной просьбы пользователя с URL. Внешняя выкладка — через `gitverse`, не через `git push` в корне. -6. Деплой на VPS — отдельный сценарий, см. **`docs/vps-deploy.md`**. - -Подробности и ограничения — в разделах выше. - -## Требования - -- В `gitverse` уже должен быть инициализирован git-репозиторий (папка `.git`). -- Должен быть настроен доступ на push (SSH-ключ/права). - -## Важно - -- Скрипт удаляет в `gitverse` все файлы и папки, кроме `.git` и `.gitverse`. -- Не запускай скрипт, если в `gitverse` есть локальные несохраненные изменения, которые нужно оставить. \ No newline at end of file diff --git a/docs/local-server.md b/docs/local-server.md index 81f78af..29733a5 100644 --- a/docs/local-server.md +++ b/docs/local-server.md @@ -1,10 +1,11 @@ # Локальный просмотр сайта -Сайт в режиме разработки обслуживается из папки `**public/**` через [BrowserSync](https://browsersync.io/): при изменении файлов страница в браузере обновляется сама. +Сайт в режиме разработки обслуживается из папки `**public/**` через нативный HTTP-сервер (Node.js `http` модуль): статика + API правок контента. ## Требования -- Установлены **Node.js** и **npm** (идут вместе с Node). +- **Node.js 22** (см. `.node-version` в корне проекта). fnm/fvm переключают автоматически. +- **npm** (идёт вместе с Node). ## Первый раз в проекте @@ -23,18 +24,11 @@ npm run vendor:copy npm run serve ``` -Скрипт [`scripts/serve-dev.mjs`](../scripts/serve-dev.mjs): статика из `public/`, API правок контента, перезагрузка **только** при изменениях в `public/**` (запись в `texts/content-edits/` вкладку не перезагружает). - -Без API: `npm run serve:static`. +Скрипт [`scripts/serve-dev.mjs`](../scripts/serve-dev.mjs): статика из `public/`, API правок контента. Нет авто-релиоада — после правок в `public/` обновляй страницу вручную. ## Куда смотреть - **Сайт:** [http://localhost:4175](http://localhost:4175) — откроется `public/index.html` и остальные пути относительно `public/`. -- **Панель BrowserSync UI:** [http://localhost:4176](http://localhost:4176) (опционально, для синхронизации и настроек). - -## Что отслеживается - -Любые изменения в `**public/**`**: HTML, CSS, JS, картинки. Папка `texts/content-edits/` **не** в watch — autosave правок не сбрасывает режим редактирования. ## API правок контента @@ -48,4 +42,4 @@ npm run serve ## Остановка -В терминале: **Ctrl+C**. \ No newline at end of file +В терминале: **Ctrl+C**. diff --git a/docs/resume-pdf.md b/docs/resume-pdf.md new file mode 100644 index 0000000..3b2ace1 --- /dev/null +++ b/docs/resume-pdf.md @@ -0,0 +1,51 @@ +# PDF резюме + +## Генерация PDF + +Скрипт `scripts/generate-resume-pdf.mjs` открывает `resume/` через Puppeteer, применяет шрифт geologica (PT Sans), убирает подложку/обводку блока `.resume-doc` и сохраняет PDF. + +**Важно**: скрипт требует запущенный dev-сервер (`npm run serve` → `localhost:4175`). + +### Запуск + +```bash +npm run generate:resume-pdf +``` + +или вручную: + +```bash +node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica +``` + +Параметры: +- `[url]` — URL страницы (по умолчанию `http://localhost:4175/resume/`) +- `[font]` — ID шрифта: `geologica`, `merriweather`, `mono` (по умолчанию `geologica`) + +### Выходной файл + +`public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf` + +Кнопка «Скачать в PDF» на странице `resume/` — это `` ссылка на этот файл. + +### Когда перегенерировать + +- После правок в `src/pages/resume.html` (контент, стили) +- При смене шрифта по умолчанию в скрипте + +### Что скрипт делает с страницей + +1. Устанавливает шрифт через `localStorage` + `data-font` +2. Ждёт загрузки шрифтов (`document.fonts.ready`) +3. Удаляет `header`, `footer`, `nav`, `.fab-stack`, `.cookie-notice`, `.skip-link` +4. Убирает `max-width` с `.resume-doc`, ставит `width: 100%` +5. Убирает фон (`background: transparent`) и обводку (`border: none`) +6. Захватывает `.resume-doc` как A4 PDF с `printBackground: true` + +### Не попадает в PDF + +Из `.resume-doc` удаляются (но остаются на сайте): +- Фон блока +- Обводка блока + +Всё остальное (отступы, шрифты, сетка) — как на сайте. diff --git a/docs/translation.md b/docs/translation.md new file mode 100644 index 0000000..fd31134 --- /dev/null +++ b/docs/translation.md @@ -0,0 +1,104 @@ +# Локализация и перевод + +Сайт локализован **build-time**: для каждого языка собирается отдельная папка/URL (`/` — русский, `/en/` — английский). Клиентского переключателя нет — это сделано ради SEO, корректной генерации PDF (Puppeteer) и совместимости с content-edit-mode. + +## Архитектура + +- `LOCALES = ["ru", "en"]`, `DEFAULT_LOCALE = "ru"` — задаётся в `scripts/build-pages.mjs`. +- Дефолтная локаль собирается в корень `public/`, остальные — в `public//` (`public/en/`). +- Переключатель языка: `src/partials/lang-switch.html` (показывает RU/EN, `aria-current` на активном). + +## Как работает сборка (`build-pages.mjs`) + +1. **UI-строки** (навигация, футер, кнопки) вынесены в словари `src/i18n/{ru,en}.json` и вставляются токеном `{{i18n:key}}`. Для не-дефолтной локали значение берётся из `en.json`, при отсутствии ключа — фоллбэк на `ru.json`. Неразрезолвенный токен остаётся как `{{i18n:key}}` (признак пропущенного ключа). +2. **Тело страницы** (главная, cookie-policy, кейсы) живёт в per-locale исходниках: + - `src/pages/.html` — русский (дефолт). + - `src/pages/.en.html` — английский override. Если файла нет, берётся русский. + - В сборке базовый walk **исключает** `*.en.html`, чтобы они не собирались как отдельные страницы. +3. **Partials**: для не-дефолтной локали инклуд `{{> name}}` сначала ищет `src/partials/name.en.html`, иначе `name.html`. Так переводятся частично захардкоженные partials (например, `projects-section.en.html`). +4. **`assetPrefix`**: относительный префикс к ассетам. Для `en` к значению из `baseVars` добавляется один уровень `../`, потому что страницы лежат в подпапке `public/en/`. Используй только для путей к ассетам (css/js/изображения). +5. **`pagePrefix`**: префикс до **корня локали** (`/en/` для en), без en-сдвига. Используй его для ВСЕХ навигационных ссылок (лого, «назад», футер, якоря `#section`, ссылки на другие страницы), чтобы они вели в ту же локаль, а не в корень сайта. `assetPrefix` предназначен только для путей к ассетам — для навигации он «перепрыгивает» через `/en/` и ведёт на ru, поэтому в навигационных `href` его НЕ применяй. +6. **Статик-ассеты** (css/js/vendor/favicon) в partials (`head-links.html`, `head-scripts.html`, `theme-init-script.html`, `head-plyr.html`, `page-scripts.html`) прописаны **root-absolute** (`/css/...`, `/js/...`, `/vendor/...`, `/assets/shared/...`). Причина: dev-сервер (`serve-dev.mjs`) отдаёт `index.html` (200) для отсутствующих файлов, маскируя 404, поэтому относительные `{{assetPrefix}}css` на `/en/` ломались бы. Держи статик-ссылки root-absolute. +7. `` подменяется на текущую локаль (``). + +> Видимый контент страниц переводится через отдельные `*.en.html` исходники, а НЕ тысячами `{{i18n:}}`-токенов — так проще править большие тексты. + +## Перевод через apinex + +Бесплатный инструмент перевода: `POST https://api.apinex.bond/v1/tools/translator` (дока: `https://apinex.bond/developers/tools/translator`). + +- **Ключ** — в `/root/.hermes/.env`, переменная `HERMES_CUSTOM_APINEX_API_KEY` (или `APINEX_API_KEY`). **Никогда не выводи значение ключа в чат/логи.** +- Заголовок: `Authorization: Bearer $KEY`. +- Тело: `{ "text": "строка|массив", "target_lang": "EN", "source_lang": "RU", "tag_handling": "html", "preserve_formatting": true }`. Батч до 50 строк / 100k символов, лимит 60 запросов/мин. + +### Скрипт `scripts/translate.mjs` + +Обёртка над API (Node, без зависимостей). Автоматически **защищает** от перевода шаблонные токены `{{assetPrefix}}`, `{{> partial}}`, `{{i18n:…}}` и содержимое `` (через HTML-комментарии, восстанавливаются после). HTML-режим авто-включается, если во входе есть `<…>`. + +```bash +cd /root/.hermes/projects/site-portfolio +# ключ в окружение (без печати значения) +export HERMES_CUSTOM_APINEX_API_KEY="$(grep -iE 'HERMES_CUSTOM_APINEX_API_KEY' /root/.hermes/.env | head -1 | cut -d= -f2- | sed 's/^["'\''"]//;s/["'\''"]$//')" + +# список поддерживаемых языков +node scripts/translate.mjs --list-languages + +# одна строка +node scripts/translate.mjs --target EN --source RU --text "Политика cookie" + +# HTML из stdin (токены и защищены) +echo '

{{assetPrefix}} siteThemeV1

' | node scripts/translate.mjs --target EN --html + +# файл страницы -> per-locale override +node scripts/translate.mjs --target EN --source RU --file src/pages/foo.html --out src/pages/foo.en.html + +# пакет: одна строка на line +printf 'строка1\nстрока2\n' | node scripts/translate.mjs --target EN --batch + +# npm-алиас +npm run translate -- --target EN --text "..." +``` + +- `--target` обязателен; `--source` опционален (авто-детект). +- `--html`/`--no-html` — принудительно вкл/выкл HTML-режим (иначе авто). +- `--out` пишет в файл, иначе вывод в stdout. + +### Скилл `translate-apinex` + +Загружается агентом при запросе перевода: содержит API, где ключ, защиту токенов и воркфлоу. Используй его вместо ручного копирования curl. + +## Воркфлоу: перевести страницу + +1. Создать `src/pages/.en.html` (копия `.html`). +2. Прогнать через скрипт: + ```bash + node scripts/translate.mjs --target EN --source RU --file src/pages/.en.html --out src/pages/.en.html + ``` + Скрипт переведёт видимый текст, оставив `{{…}}` и `` нетронутыми. +3. Если страница инклудит partials с захардкоженным русским (`projects-section`, `contact-section`, `cookie-notice`, `fab-stack`, `mini-projects-section`), создать их `.en.html` и перевести так же — сборка подхватит `.en`-вариант для `en`. +4. UI-строки, оставшиеся как `{{i18n:key}}`, добавить в `src/i18n/en.json`. +5. `npm run build:pages`. +6. Проверить: + ```bash + curl -s http://localhost:4175/en// | grep -o '' + curl -s http://localhost:4175/en// | grep -cE 'остался русский текст' # ожидаем 0 + ``` + +## Добавить новый язык + +1. Добавить код в массив `LOCALES` в `scripts/build-pages.mjs`. +2. Создать `src/i18n/.json` (UI-строки). +3. Создавать `..html` / `..html` override для нужных страниц/партилов (или полагаться на фоллбэк ru). +4. Пересобрать. URL новой локали — `//`. + +## Где что лежит + +| Файл | Назначение | +|---|---| +| `scripts/build-pages.mjs` | Сборка локалей, резолв `{{i18n:}}`, override страниц/партилов, `assetPrefix`, `lang` | +| `src/i18n/ru.json`, `src/i18n/en.json` | Словари UI-строк | +| `src/pages/.en.html` | Переведённое тело страницы (override) | +| `src/partials/.en.html` | Переведённый partial (override) | +| `src/partials/lang-switch.html` | Переключатель RU/EN | +| `scripts/translate.mjs` | CLI-обёртка над apinex translator | +| `.env` (`/root/.hermes/.env`) | `HERMES_CUSTOM_APINEX_API_KEY` — ключ apinex (не коммитить) | diff --git a/docs/vendor-assets.md b/docs/vendor-assets.md index bccb662..ba9b972 100644 --- a/docs/vendor-assets.md +++ b/docs/vendor-assets.md @@ -1,6 +1,6 @@ # Локальные vendor-ассеты (шрифты и Plyr) -Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через `sync-gitverse`. +Сайт не использует Google Fonts и jsDelivr в рантайме. Файлы лежат в `public/` и попадают на хостинг через git push. ## Что где @@ -49,7 +49,3 @@ npm run build:pages 1. Обновить версию в `package.json` (`dependencies.plyr`). 2. `npm install` → `npm run vendor:copy`. 3. При смене API — правки в `video-player.js`. - -## Деплой - -Перед `npm run sync-gitverse` выполни `npm run build`, чтобы в `public/` были актуальные шрифты, Plyr и HTML. diff --git a/docs/vps-deploy.md b/docs/vps-deploy.md deleted file mode 100644 index cd6ad42..0000000 --- a/docs/vps-deploy.md +++ /dev/null @@ -1,59 +0,0 @@ -# Деплой на VPS (инкрементальный) - -Скрипт `scripts/deploy-vps.mjs` загружает содержимое `public/` на VPS, передавая только изменённые файлы. - -## Принцип работы - -1. **Манифест** (`.deploy-manifest.json`) хранит `mtime` и `size` каждого файла с прошлого деплоя. -2. При запуске скрипт сканирует `public/`, сравнивает с манифестом: - - Файлы с изменившимся `mtime` или `size` — загружаются. - - Файлы, которых нет локально, но есть в манифесте — удаляются с сервера. -3. Изменённые файлы пакуются в `tar` и передаются **одним SSH-соединением** (без сжатия, чтобы не тратить CPU на уже сжатые медиа). -4. Манифест обновляется после успешного деплоя. - -При первом запуске (манифест пуст) заливаются все файлы — ~3 минуты на ~400 файлов. Повторный деплой (1–10 изменившихся файлов) — 2–5 секунд. - -## Запуск - -```bash -npm run deploy:vps -# или напрямую -node scripts/deploy-vps.mjs [sourceDir] -``` - -По умолчанию `sourceDir = public`. - -## Рецепты - -### Только в Coolify - -Запрос пользователя: «задеплой в Coolify», «задеплой на VPS». - -```bash -npm run deploy:vps -``` - -Нужны разрешения: **all** (SSH-ключ `~/.ssh/beget`, доступ к файлам `public/`). - -### Везде (VPS + GitVerse) - -```bash -npm run deploy -``` - -Выполняет `deploy:vps`, затем `sync-gitverse`. - -## Конфигурация - -- **SSH-ключ:** `~/.ssh/beget` -- **Хост:** `root@155.212.186.39` -- **Целевая папка:** `/data/static-site` -- **Манифест:** `.deploy-manifest.json` в корне проекта (не коммитится) - -## Сброс манифеста - -Если нужно залить всё с нуля — удалить `.deploy-manifest.json`: - -```bash -rm .deploy-manifest.json -``` diff --git a/final.mjs b/final.mjs new file mode 100644 index 0000000..3616ac3 --- /dev/null +++ b/final.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; +p.on('pageerror', e=>errs.push('PE '+e.message)); +p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); }); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; + return { hasMount: !!m, speed: m?.currentSpeed, isVp: m?.isInViewport }; +}); +console.log('MOUNT', JSON.stringify(r)); +console.log('ERRS', errs.slice(0,5)); +await b.close(); diff --git a/final2.mjs b/final2.mjs new file mode 100644 index 0000000..537d022 --- /dev/null +++ b/final2.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; +p.on('pageerror', e=>errs.push('PE '+e.message)); +p.on('console', m=>{ if(m.type()==='error') errs.push('CE '+m.text()); }); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; }); +await new Promise(r=>setTimeout(r,2000)); +const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{speed:m.currentSpeed,frame:m.getCurrentFrame(),inVp:m.isInViewport}:null; }); +console.log('A',JSON.stringify(a),'C',JSON.stringify(c)); +console.log('ANIMATING:', c && a && c.frame>a.frame && c.speed>0); +console.log('ERRS', errs.slice(0,5)); +await b.close(); diff --git a/fixcheck.mjs b/fixcheck.mjs new file mode 100644 index 0000000..beb7677 --- /dev/null +++ b/fixcheck.mjs @@ -0,0 +1,12 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); // просто ждём, НЕ скроллим к секции +const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; }); +await new Promise(r=>setTimeout(r,2000)); +const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader')?.paperShaderMount; return m?{frame:m.getCurrentFrame(),speed:m.currentSpeed,inVp:m.isInViewport}:null; }); +console.log('A',JSON.stringify(a)); console.log('C',JSON.stringify(c)); +console.log('ANIMATING:', !!c && c.frame>a.frame && c.speed>0); +await b.close(); diff --git a/frametest.mjs b/frametest.mjs new file mode 100644 index 0000000..5d1659f --- /dev/null +++ b/frametest.mjs @@ -0,0 +1,20 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +// достанем mount через глобальный хук: ShaderMount сохраняет себя в parentElement.paperShaderMount +const r = await p.evaluate(async()=>{ + const wrap=document.querySelector('[data-shader-demo] .card-shader'); + const m=wrap.paperShaderMount; + if(!m) return {hasMount:false}; + const f1=m.getCurrentFrame(); + await new Promise(r=>setTimeout(r,600)); + const f2=m.getCurrentFrame(); + return { hasMount:true, frame1:f1, frame2:f2, rafRunning: f2>f1, speed: m.speed, inViewport: m.isInViewport, currentSpeed: m.currentSpeed }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/glerr.mjs b/glerr.mjs new file mode 100644 index 0000000..a134c0d --- /dev/null +++ b/glerr.mjs @@ -0,0 +1,19 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + const errs=[]; + // проверим статус программы и шейдеров + const vs = gl.getProgramParameter(m.program, gl.LINK_STATUS); + // проверим, залит ли canvas хоть чем-то: clearColor потом readPixels + gl.clearColor(1,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT); + const px=new Uint8Array(4); gl.readPixels(10,10,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + return { linkStatus: vs, afterClearRed: Array.from(px), glError: gl.getError() }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/glerr2.mjs b/glerr2.mjs new file mode 100644 index 0000000..64431aa --- /dev/null +++ b/glerr2.mjs @@ -0,0 +1,19 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + gl.getError(); // сброс + m.render(performance.now()); + const e = gl.getError(); + const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + // проверим a_position location + const aLoc = gl.getAttribLocation(m.program, 'a_position'); + return { glErrorAfterRender: e, centerPx: Array.from(px), aPosLoc: aLoc, clearC: (()=>{const c=new Float32Array(4); gl.getClearColor?.(c); return Array.from(c);})() }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/grid.mjs b/grid.mjs new file mode 100644 index 0000000..caf20d3 --- /dev/null +++ b/grid.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + const flat=[0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1]; + gl.useProgram(m.program); + gl.uniform4fv(gl.getUniformLocation(m.program,'u_colors'), new Float32Array(flat)); + gl.uniform1f(gl.getUniformLocation(m.program,'u_colorsCount'),4); + gl.drawArrays(gl.TRIANGLES,0,6); + const W=m.canvasElement.width,H=m.canvasElement.height; + const pts=[[100,100],[360,100],[620,100],[100,220],[360,220],[620,220],[100,340],[360,340],[620,340]]; + const out=pts.map(([x,y])=>{const px=new Uint8Array(4);gl.readPixels(x,y,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px);return [x,y,Array.from(px)];}); + // макс яркость по всему canvas + const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf); + let maxc=0; for(let i=0;isetTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const keys=['u_time','u_colors','u_colorsCount','u_distortion','u_swirl','u_grainMixer','u_grainOverlay']; + const out={}; + keys.forEach(k=>{ const l=m.uniformLocations[k]; out[k]= l===null?'NULL':(l===undefined?'UNDEF':typeof l); }); + // также посмотрим this.providedUniforms + const pu = m.providedUniforms ? Object.keys(m.providedUniforms) : 'none'; + return { locations: out, providedKeys: pu }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/manual.mjs b/manual.mjs new file mode 100644 index 0000000..a5bae2a --- /dev/null +++ b/manual.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + const loc0 = gl.getUniformLocation(m.program, 'u_colors'); + const locB = gl.getUniformLocation(m.program, 'u_colors[0]'); + // вручную зададим 4 цвета vec4 + const flat = [0.486,0.361,1,1, 0,0.761,0.78,1, 1,0.361,0.541,1, 1,0.702,0.278,1]; + gl.useProgram(m.program); + if (loc0) gl.uniform4fv(loc0, flat); else if (locB) gl.uniform4fv(locB, flat); + gl.drawArrays(gl.TRIANGLES,0,6); + const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + return { loc0null: loc0===null, locBnull: locB===null, centerAfterManual: Array.from(px) }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/matrix.mjs b/matrix.mjs new file mode 100644 index 0000000..13eeae9 --- /dev/null +++ b/matrix.mjs @@ -0,0 +1,23 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1400}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-shader-grid')?.scrollIntoView()); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const cards=[...document.querySelectorAll('#sec-shader-grid .neu-card--shader')]; + const info=cards.map(c=>{ + const sh=c.querySelector('.card-shader'); + const m=sh?.paperShaderMount; + const cs=getComputedStyle(c); + const beforeZ=getComputedStyle(c,'::before').zIndex; + return { hasShaderWrap:!!sh, mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null, bg:cs.backgroundColor, beforeZ }; + }); + return { count: cards.length, info }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,5)); +await b.close(); diff --git a/meshcheck.mjs b/meshcheck.mjs new file mode 100644 index 0000000..d16091f --- /dev/null +++ b/meshcheck.mjs @@ -0,0 +1,18 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView()); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const cards=[...document.querySelectorAll('.card-shader[data-shader]')]; + const info=cards.map(c=>({brand:c.dataset.shader, mounted:!!c.paperShaderMount, frag:c.paperShaderMount?.fragmentShader?.name||null})); + const f0=cards.find(c=>c.dataset.shader==='demo')?.paperShaderMount; + return { count: cards.length, info, demoFrame: f0?Math.round(f0.getCurrentFrame()):null, + demoAnimated: f0? (f0.getCurrentFrame()*0+ (f0.currentSpeed>0)) : null }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,6)); +await b.close(); diff --git a/mountcheck.mjs b/mountcheck.mjs new file mode 100644 index 0000000..bba84f0 --- /dev/null +++ b/mountcheck.mjs @@ -0,0 +1,12 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push('PE: '+e.message)); p.on('console',m=>{if(m.type()==='error')errs.push('CON: '+m.text());}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const r = await p.evaluate(()=>{ + const wrap=document.querySelector('[data-shader-demo] .card-shader'); + return { hasMount: !!wrap?.paperShaderMount, scriptLoaded: !!window.__mountLabShaders }; +}); +console.log(JSON.stringify({r, errs}, null, 1)); +await b.close(); diff --git a/package-lock.json b/package-lock.json index 3b30ea3..93182c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@fontsource/pt-mono": "^5.2.8", "@fontsource/pt-sans": "^5.2.8", "@fontsource/source-code-pro": "^5.2.7", - "browser-sync": "^3.0.4", + "puppeteer": "^25.7.0", "sharp": "^0.34.0" } }, @@ -580,394 +580,109 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "node_modules/@puppeteer/browsers": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.0.tgz", + "integrity": "sha512-LlBrE8oqGfU7b1Nk2d5Q1SbuPhZxTj0cJEMDPEws28OjNMELlflekmPPuf4FnK03x0ZRjKaYwJElUcKK4kyqJA==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "modern-tar": "^0.8.0", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" }, "engines": { - "node": ">= 0.6" + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/async-each-series": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/async-each-series/-/async-each-series-0.1.1.tgz", - "integrity": "sha512-p4jj6Fws4Iy2m0iCmI2am2ZNZCgbdgE+P8F/8csmn2vx7ixXrO2zGcuNsD46X5uZSVecmkEy/M06X2vG8KD6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-sync": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-3.0.4.tgz", - "integrity": "sha512-mcYOIy4BW6sWSEnTSBjQwWsnbx2btZX78ajTTjdNfyC/EqQVcIe0nQR6894RNAMtvlfAnLaH9L2ka97zpvgenA==", + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "browser-sync-client": "^3.0.4", - "browser-sync-ui": "^3.0.4", - "bs-recipes": "1.3.4", - "chalk": "4.1.2", - "chokidar": "^3.5.1", - "connect": "3.6.6", - "connect-history-api-fallback": "^1", - "dev-ip": "^1.0.1", - "easy-extender": "^2.3.4", - "eazy-logger": "^4.1.0", - "etag": "^1.8.1", - "fresh": "^0.5.2", - "fs-extra": "3.0.1", - "http-proxy": "^1.18.1", - "immutable": "^3", - "micromatch": "^4.0.8", - "opn": "5.3.0", - "portscanner": "2.2.0", - "raw-body": "^2.3.2", - "resp-modifier": "6.0.2", - "rx": "4.1.0", - "send": "^0.19.0", - "serve-index": "^1.9.1", - "serve-static": "^1.16.2", - "server-destroy": "1.0.1", - "socket.io": "^4.4.1", - "ua-parser-js": "^1.0.33", - "yargs": "^17.3.1" - }, - "bin": { - "browser-sync": "dist/bin.js" + "mitt": "^3.0.1", + "zod": "^3.24.1" }, "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/browser-sync-client": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/browser-sync-client/-/browser-sync-client-3.0.4.tgz", - "integrity": "sha512-+ew5ubXzGRKVjquBL3u6najS40TG7GxCdyBll0qSRc/n+JRV9gb/yDdRL1IAgRHqjnJTdqeBKKIQabjvjRSYRQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "etag": "1.8.1", - "fresh": "0.5.2", - "mitt": "^1.1.3" + "node": ">=20.19.0 <22.0.0 || >=22.12.0" }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/browser-sync-ui": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/browser-sync-ui/-/browser-sync-ui-3.0.4.tgz", - "integrity": "sha512-5Po3YARCZ/8yQHFzvrSjn8+hBUF7ZWac39SHsy8Tls+7tE62iq6pYWxpVU6aOOMAGD21RwFQhQeqmJPf70kHEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async-each-series": "0.1.1", - "chalk": "4.1.2", - "connect-history-api-fallback": "^1", - "immutable": "^3", - "server-destroy": "1.0.1", - "socket.io-client": "^4.4.1", - "stream-throttle": "^0.1.3" - } - }, - "node_modules/bs-recipes": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/bs-recipes/-/bs-recipes-1.3.4.tgz", - "integrity": "sha512-BXvDkqhDNxXEjeGM8LFkSbR+jzmP/CYpCiVKYn+soB1dDldeU15EBNDkwVXndKuX35wnNUaPd0qSoQEAkmQtMw==", - "dev": true, - "license": "ISC" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "peerDependencies": { + "devtools-protocol": "*" } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect": { - "version": "3.6.6", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.6.tgz", - "integrity": "sha512-OO7axMmPpu/2XuX1+2Yrg0ddju31B6xLZMWkJ5rYBu4YRmRVlOjvlY6kw2FJKiAzyxGwnrDUAG4s1Pf0sbBMCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.0", - "parseurl": "~1.3.2", - "utils-merge": "1.0.1" + "node": ">=18" }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", - "integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/core-js": { @@ -981,61 +696,12 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/custom-event-polyfill": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==", "license": "MIT" }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1046,159 +712,17 @@ "node": ">=8" } }, - "node_modules/dev-ip": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dev-ip/-/dev-ip-1.0.1.tgz", - "integrity": "sha512-LmVkry/oDShEgSZPNgqCIp2/TlqtExeGmymru3uCELnfyjY11IzpAproLYs+1X88fXO6DBoYP3ul2Xo2yz2j6A==", + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", "dev": true, - "bin": { - "dev-ip": "lib/dev-ip.js" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/easy-extender": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/easy-extender/-/easy-extender-2.3.4.tgz", - "integrity": "sha512-8cAwm6md1YTiPpOvDULYJL4ZS6WfM5/cTeVVh4JsvyYZAoqlRVUpHL9Gr5Fy7HA6xcSZicUia3DeAgO3Us8E+Q==", - "dev": true, - "dependencies": { - "lodash": "^4.17.10" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/eazy-logger": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eazy-logger/-/eazy-logger-4.1.0.tgz", - "integrity": "sha512-+mn7lRm+Zf1UT/YaH8WXtpU6PIV2iOjzP6jgKoiaq/VNrjYKp+OHZGe2znaLgDeFkw8cL9ffuaUm+nNnzcYyGw==", - "dev": true, - "dependencies": { - "chalk": "4.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" + "license": "BSD-3-Clause" }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/engine.io": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", - "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-client": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", - "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io-client/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -1212,120 +736,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz", - "integrity": "sha512-ejnvM9ZXYzp6PUPUyQBMBf0Co5VX2gr5H2VQe2Ui2jWXNlxv+PYZo8wpAymJNJdLsG1R4p+M4aynF8KuoUEwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.1", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.2", - "statuses": "~1.3.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-3.0.1.tgz", - "integrity": "sha512-V3Z3WZWVUYd8hoCL5xfXJCaHWYzmtwW5XWYSlLgERi8PWd8bx1kUHUk8L1BT57e49oKnDDD180mjfrHc1yA9rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^3.0.0", - "universalify": "^0.1.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1336,378 +746,53 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" + "node": ">=14" }, - "engines": { - "node": ">=8.0.0" + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immutable": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", - "integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-like": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/is-number-like/-/is-number-like-1.0.8.tgz", - "integrity": "sha512-6rZi3ezCyFcn5L71ywzz2bS5b2Igl1En3eTlZlvKjpz1n3IZLAYMbKYAIQgFmEu0GENg92ziU/faEOA/aixjbA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lodash.isfinite": "^3.3.2" - } - }, - "node_modules/is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/jsonfile": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", - "integrity": "sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==", - "dev": true - }, "node_modules/loadjs": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz", "integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==", "license": "MIT" }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isfinite": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", - "integrity": "sha512-7FGG40uhC8Mm633uKW1r58aElFlBlxCrg9JfSi3P6aYiWmfiWF0PgMd86ZUsxE5GwWPdHoS2+48bwTh2VPkIQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/mitt": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-1.2.0.tgz", - "integrity": "sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "dev": true, "license": "MIT" }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/modern-tar": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", + "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/opn": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/opn/-/opn-5.3.0.tgz", - "integrity": "sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=18.0.0" } }, "node_modules/plyr": { @@ -1723,29 +808,44 @@ "url-polyfill": "^1.1.13" } }, - "node_modules/portscanner": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/portscanner/-/portscanner-2.2.0.tgz", - "integrity": "sha512-IFroCz/59Lqa2uBvzK3bKDbDDIEaAY8XJ1jFxcLWTqosrsc32//P4VuSB2vZXoHiHqOmx8B5L5hnKOxL/7FlPw==", + "node_modules/puppeteer": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.7.0.tgz", + "integrity": "sha512-zLBIYuW66SGwY7JNqGmiqeKiM92CYOs6xPibSRBPIeYGIfM1nE/8VCE8G0fGot2EfXENC4PbhktxLrQ0EK5Thg==", "dev": true, - "license": "MIT", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "async": "^2.6.0", - "is-number-like": "^1.0.3" + "@puppeteer/browsers": "3.2.0", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.7.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" }, "engines": { - "node": ">=0.4", - "npm": ">=1.0.0" + "node": ">=22.12.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/puppeteer-core": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.7.0.tgz", + "integrity": "sha512-wgBBj7dU5ceGyoT2PCrJpkYOhxPY8mDOmcSKZP92Cj5GgqQ3kv/UxzawnOLxiYuupe4bDf/yiQm3bzs/1nI0rQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.0", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, "engines": { - "node": ">= 0.6" + "node": ">=22.12.0" } }, "node_modules/rangetouch": { @@ -1754,79 +854,6 @@ "integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==", "license": "MIT" }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resp-modifier": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/resp-modifier/-/resp-modifier-6.0.2.tgz", - "integrity": "sha512-U1+0kWC/+4ncRFYqQWTx/3qkfE6a4B/h3XXgmXypfa0SPZ3t7cbbaFk297PjQS/yov24R18h6OZe6iZwj3NSLw==", - "dev": true, - "dependencies": { - "debug": "^2.2.0", - "minimatch": "^3.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/rx": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz", - "integrity": "sha512-CiaiuN6gapkdl+cZUr67W6I8jquN4lkak3vtIsIWCl4XIPP8ffsoyN6/+PuGXnQy8Cu8W2y9Xxh31Rq4M6wUug==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1840,171 +867,6 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/server-destroy": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", - "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -2050,255 +912,37 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-client/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-client/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz", - "integrity": "sha512-wuTCPGlJONk/a1kqZ4fQM2+908lC7fa7nPYpTC1EhnvqLX/IICbeP1OZGDtA374trpSq68YubKUMo8oRhN46yg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stream-throttle": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/stream-throttle/-/stream-throttle-0.1.3.tgz", - "integrity": "sha512-889+B9vN9dq7/vLbGyuHeZ6/ctf5sNuGWsDy89uNxkFTAgzy0eK7+w5fL3KLNRTkLle7EgZGvHUphZW0Q26MnQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "commander": "^2.2.0", - "limiter": "^1.0.5" - }, - "bin": { - "throttleproxy": "bin/throttleproxy.js" - }, - "engines": { - "node": ">= 0.10.0" - } - }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" + "node": ">=12" }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/tslib": { @@ -2309,108 +953,66 @@ "license": "0BSD", "optional": true }, - "node_modules/ua-parser-js": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", - "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/url-polyfill": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { @@ -2429,15 +1031,6 @@ } } }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -2449,32 +1042,41 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^8.2.1", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index a86791a..395e48c 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,10 @@ "build:pages": "node scripts/build-pages.mjs", "build": "npm run vendor:copy && npm run build:pages", "serve": "node scripts/serve-dev.mjs", - "serve:static": "browser-sync start --server \"public\" --files \"public/**/*\" --no-notify --no-open --no-ghost-mode --port 4175 --ui-port 4176", + "translate": "node scripts/translate.mjs", "convert-videos": "node scripts/convert-videos.mjs", "optimize-images": "node scripts/optimize-images.mjs", - "deploy:vps": "node scripts/deploy-vps.mjs", - "deploy": "npm run deploy:vps && npm run sync-gitverse", - "sync-gitverse": "node scripts/sync-gitverse.mjs" + "generate:resume-pdf": "node scripts/generate-resume-pdf.mjs http://localhost:4175/resume/ geologica" }, "devDependencies": { "@fontsource-variable/geologica": "^5.2.8", @@ -20,7 +18,7 @@ "@fontsource/pt-mono": "^5.2.8", "@fontsource/pt-sans": "^5.2.8", "@fontsource/source-code-pro": "^5.2.7", - "browser-sync": "^3.0.4", + "puppeteer": "^25.7.0", "sharp": "^0.34.0" }, "dependencies": { diff --git a/pixeltime.mjs b/pixeltime.mjs new file mode 100644 index 0000000..50ba457 --- /dev/null +++ b/pixeltime.mjs @@ -0,0 +1,20 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +const r = await p.evaluate(()=>{ + const wrap=document.querySelector('[data-shader-demo] .card-shader'); + const m=wrap.paperShaderMount; const cv=wrap.querySelector('canvas'); + const gl=cv.getContext('webgl2',{preserveDrawingBuffer:true})||cv.getContext('webgl',{preserveDrawingBuffer:true}); + const read=()=>{ const a=new Uint8Array(4); gl.readPixels(cv.width/2|0,cv.height/2|0,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a); return Array.from(a); }; + m.render(0); const t0=read(); + m.render(3000); const t1=read(); + m.render(8000); const t2=read(); + return { t0, t1, t2 }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/probe.mjs b/probe.mjs new file mode 100644 index 0000000..15a3872 --- /dev/null +++ b/probe.mjs @@ -0,0 +1,26 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[],warns=[]; +p.on('pageerror', e=>errs.push('PE '+e.message)); +p.on('requestfailed', r=>errs.push('REQFAIL '+r.url()+' '+r.failure()?.errorText)); +p.on('console', m=>{ const t=m.type(); if(t==='error') errs.push('CE '+m.text()); if(t==='warning') warns.push('W '+m.text()); }); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'networkidle2',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const info = await p.evaluate(()=>{ + const cs=document.querySelector('[data-shader-demo] .card-shader'); + const m=cs?.paperShaderMount; + const bg=getComputedStyle(cs).backgroundColor; + const canvas=cs?.querySelector('canvas'); + return { + hasWrap: !!cs, hasMount: !!m, + wrapBg: bg, hasCanvas: !!canvas, + canvasW: canvas?.width, canvasH: canvas?.height, + speed: m?.currentSpeed, frame: m?Math.round(m.getCurrentFrame()):null, + }; +}); +console.log('INFO', JSON.stringify(info,null,1)); +console.log('ERRS', errs.slice(0,8)); +console.log('WARNS', warns.slice(0,8)); +await b.close(); diff --git a/probe_tmp.js b/probe_tmp.js new file mode 100644 index 0000000..4ec721e --- /dev/null +++ b/probe_tmp.js @@ -0,0 +1,52 @@ +const puppeteer = require('puppeteer'); + +async function probePage(browser, url, width) { + const page = await browser.newPage(); + await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await page.goto(url, { waitUntil: 'networkidle0' }); + await page.waitForSelector('[data-mini-projects-list] .mini-projects-grid__item', { timeout: 15000 }).catch(()=>{}); + await new Promise(r => setTimeout(r, 700)); + const data = await page.evaluate(() => { + const list = document.querySelector('[data-mini-projects-list]'); + if (!list) return { none: true }; + const inner = list.closest('.section__inner'); + const items = [...list.querySelectorAll('.mini-projects-grid__item')].filter(i => !i.hidden); + const rowCells = (() => { + const w = list.clientWidth; + if (w >= 1180) return 6; + if (w >= 880) return 4; + if (w >= 520) return 2; + return 1; + })(); + return { + listWidth: list.clientWidth, + innerWidth: inner ? inner.clientWidth : null, + rowCells, + itemCount: items.length, + items: items.map(i => ({ + cells: i.getAttribute('data-mp-cells'), + styleW: i.style.width, + x: i.style.transform, + })), + }; + }); + await page.close(); + return data; +} + +(async () => { + const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox','--disable-setuid-sandbox'] }); + const widths = [600, 760, 900, 1024, 1180, 1278, 1356, 1600]; + const caseUrl = 'http://localhost:4175/cases/ai-platform-primo-rpa/'; + const homeUrl = 'http://localhost:4175/'; + for (const w of widths) { + const c = await probePage(browser, caseUrl, w); + console.log(`CASE vw=${w} inner=${c.innerWidth} rowCells=${c.rowCells} items=${c.itemCount} ` + JSON.stringify(c.items)); + } + console.log('---'); + for (const w of widths) { + const h = await probePage(browser, homeUrl, w); + console.log(`HOME vw=${w} inner=${h.innerWidth} rowCells=${h.rowCells} items=${h.itemCount} ` + JSON.stringify(h.items)); + } + await browser.close(); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/prompt.json b/prompt.json deleted file mode 100644 index b0fba3e..0000000 --- a/prompt.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "model": "black-forest-labs/flux.2-klein-4b", - "messages": [ - { - "role": "user", - "content": "Минималистичный AI-промпт для портретов в стиле high-fashion. Мягкий студийный свет, чистый фон и акцент на естественной красоте. Такой визуал идеально подходит для тренировки моделей освещения и теста фотореализма. Подходит для: fashion-портретов, нейрофотосессий, коммерческих стоков. Стиль: минимализм, чистая палитра, editorial precision. 👉 Узнай больше AI-идей и вдохновляющих промптов в моём телеграм-канале «Нейропольза» — переходи по ссылке. Minimalist AI portrait prompt with diffused studio light and fashion-magazine aesthetics. Perfect for clean editorial visuals and AI-photo training." - } - ], - "modalities": ["image"], - "image_config": { "aspect_ratio": "9:16" }, - "_provider": "PLZ", - "_cost": 1 -} diff --git a/public/_lab/bake.html b/public/_lab/bake.html new file mode 100644 index 0000000..73d52ed --- /dev/null +++ b/public/_lab/bake.html @@ -0,0 +1,81 @@ + + + bake paper texture + +
+ + + diff --git a/public/_lab/index.html b/public/_lab/index.html new file mode 100644 index 0000000..e949b55 --- /dev/null +++ b/public/_lab/index.html @@ -0,0 +1,108 @@ + + + + + + + Песочница _lab + + + + + + +
+ + diff --git a/public/_lab/lab.css b/public/_lab/lab.css new file mode 100644 index 0000000..9a208e4 --- /dev/null +++ b/public/_lab/lab.css @@ -0,0 +1,404 @@ +/* Обвязка стенда. Сюда не пишем стили компонента — только рамки, сетки, + подписи и подложки. Стили neu-card живут в public/css/main.css. */ + +.lab { + max-width: 62rem; + margin: 0 auto; + padding: clamp(2rem, 5vw, 3.5rem) var(--space) 8rem; +} + +.lab__head { + margin-bottom: clamp(2.5rem, 6vw, 4rem); +} + +.lab__h1 { + margin: 0 0 0.5rem; + font-family: var(--font-accent); + font-size: clamp(1.8rem, 4vw, 2.5rem); + font-weight: 700; + line-height: 1.1; +} + +.lab__sub { + margin: 0 0 1.25rem; + max-width: 42rem; + color: var(--text-muted); + line-height: 1.6; +} + +.lab__nav { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.75rem; + font-size: 0.85rem; +} + +.lab__nav a { + color: var(--accent-text); + text-decoration: none; + border-bottom: 1px solid transparent; +} + +.lab__nav a:hover { + border-bottom-color: currentColor; +} + +.lab__sec { + padding-top: clamp(2rem, 5vw, 3rem); + margin-bottom: clamp(2rem, 5vw, 3rem); + scroll-margin-top: 1.5rem; +} + +.lab__h2 { + margin: 0 0 0.5rem; + font-family: var(--font-accent); + font-size: clamp(1.25rem, 2.5vw, 1.6rem); + font-weight: 700; + line-height: 1.2; +} + +.lab__h3 { + margin: 2.5rem 0 0.5rem; + font-family: var(--font-accent); + font-size: 1rem; + font-weight: 700; + line-height: 1.2; + color: var(--text); +} + +.lab__note { + margin: 0 0 1.5rem; + max-width: 46rem; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text-muted); +} + +.lab__caption { + margin: 0.75rem 0 0; + font-size: 0.78rem; + line-height: 1.4; + color: var(--text-muted); +} + +.lab code { + font-family: var(--font); + font-size: 0.92em; + padding: 0.1em 0.35em; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.lab__row { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: flex-start; +} + +.lab__row--wrap .neu-card { + flex: 0 0 auto; +} + +/* ── Токены ─────────────────────────────────────────────────────── */ + +.lab__tokens { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 0.75rem; +} + +.lab__token { + margin: 0; + padding: 0.9rem 1rem; + border-radius: var(--radius-md); + background: var(--bg-elevated); + border: 1px solid var(--line); +} + +.lab__token dt { + font-size: 0.82rem; + margin-bottom: 0.3rem; +} + +.lab__token dt span { + color: var(--text-muted); + margin-left: 0.35rem; +} + +.lab__token dd { + margin: 0; + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-muted); +} + +/* ── Фоны ───────────────────────────────────────────────────────── */ + +.lab__bg-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + gap: 1.5rem; +} + +.lab__bg-cell { + padding: 1.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--line); +} + +.lab__bg-cell[data-bg="page"] { + background: var(--bg); +} + +.lab__bg-cell[data-bg="elevated"] { + background: var(--bg-elevated); +} + +.lab__bg-cell[data-bg="surface"] { + background: var(--surface); +} + +.lab__bg-cell[data-bg="accent"] { + background: var(--accent); + color: var(--on-accent, var(--text)); +} + +.lab__bg-cell[data-bg="accent"] .lab__caption { + color: color-mix(in srgb, var(--on-accent, var(--text)) 78%, transparent); +} + +.lab__bg-cell[data-bg="media"] { + background-image: url("/assets/template-elements-demo/img-long.webp"); + background-size: cover; + background-position: center; + color: #fff; +} + +.lab__bg-cell[data-bg="media"] .lab__caption { + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +.lab__bg-cell[data-bg="media"] .neu-card { + background: var(--surface); + color: var(--text); +} + +/* ── Состояния ──────────────────────────────────────────────────── */ + +.lab__state { + display: flex; + flex-direction: column; +} + +.lab__depth { + gap: 1.25rem; +} + +.lab__depth .neu-card { + min-width: 150px; +} + +/* ── Сетка ──────────────────────────────────────────────────────── */ + +.lab__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.lab__grid--3 { + grid-template-columns: repeat(3, 1fr); +} + +.lab__grid--tight { + gap: 0.5rem; +} + +@media (max-width: 640px) { + .lab__grid--3 { + grid-template-columns: 1fr; + } +} + +/* ── Переключатель стенда ───────────────────────────────────────── */ + +.lab-switch { + position: fixed; + right: max(1rem, env(safe-area-inset-right, 0px)); + bottom: max(1rem, env(safe-area-inset-bottom, 0px)); + z-index: 200; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.35rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line); + box-shadow: 0 8px 24px hsla(0, 0%, 0%, 0.25); + font-size: 0.78rem; +} + +.lab-switch__btn { + appearance: none; + border: 0; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-family: var(--font-accent); + cursor: pointer; +} + +.lab-switch__btn:hover { + color: var(--text); + background: color-mix(in srgb, var(--text) 8%, transparent); +} + +.lab-switch__btn[aria-pressed="true"] { + background: var(--accent); + color: var(--on-accent, #fff); +} + +.lab-switch__sep { + width: 1px; + height: 1.25rem; + margin: 0 0.15rem; + background: var(--line); +} + +/* ── Карточка в карточке ────────────────────────────────────────── */ + +.lab__nest { + margin-bottom: 1.5rem; +} + +/* Внешняя карточка: увеличенные поля и минимум, чтобы вместить вложенные */ +.neu-card--nest { + --neu-pad: 1.75rem; + --neu-min-w: 0; + --neu-min-h: 0; + gap: 1rem; + justify-content: flex-start; +} + +.neu-card__nest-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.neu-card__nest-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +@media (max-width: 640px) { + .neu-card__nest-grid { + grid-template-columns: 1fr; + } +} + +/* ── Шейдер-карточка (прототип Paper Shaders) ───────────────────── */ +/* canvas шейдера — фоновый слой ВНУТРИ карточки, под контентом. + pointer-events:none — чтобы клик/hover доходили до самой карточки. */ +.contact-card--shader { + overflow: hidden; + isolation: isolate; +} + +.card-shader { + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + overflow: hidden; + pointer-events: none; + /* нейтральный фон-запас: если WebGL не отрисовал, карточка выглядит как + обычная neu-card (--neu-bg). Когда шейдер рисует — он перекрывает этот фон. */ + background: var(--neu-bg); +} + +/* ── neu-card + живой шейдер-слой под бликом ─────────────────────────────── + Обычная неуморфная карточка (тени снаружи + блик) с анимированным шейдером + ВНУТРИ, под бликом. Слои (снизу вверх): + 0 — .card-shader (canvas шейдера) + 1 — ::before (блик --neu-glow) И ::after (внутренний объём inset-теней) + 2 — контент карточки + Важно: box-shadow родителя рисуется НИЖЕ дочерних слоёв, поэтому шейдер (z:0) + перекрыл бы внутренние тени. Чтобы объём был виден ПОВЕРХ шейдера, внешние + тени (drop) оставляем на карточке, а внутренний объём (inset) выносим на + ::after с z-index:1 — он рисуется поверх шейдера. */ +.neu-card--shader { + background: transparent; /* фон уходит, шейдер снизу */ + isolation: isolate; + /* только внешний объём (приподнятость) */ + box-shadow: var(--neu-drop-1), var(--neu-drop-2), var(--neu-drop-3); +} +.neu-card--shader::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; /* поверх шейдера (z:0) */ + border-radius: inherit; + pointer-events: none; + background: var(--neu-glow); /* блик из левого верхнего угла */ +} +.neu-card--shader::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; /* поверх шейдера, рядом с бликом */ + border-radius: inherit; + pointer-events: none; + /* внутренний объём (выпуклость): белая кайма сверху-слева + тёмная снизу-справа */ + box-shadow: + var(--neu-in-light-1), var(--neu-in-light-2), + var(--neu-in-dark-1), var(--neu-in-dark-2); +} +.neu-card--shader > *:not(.card-shader) { + position: relative; + z-index: 2; /* контент над бликом/объёмом и шейдером */ +} + +.contact-card--shader .contact-card__icon, +.contact-card--shader .contact-card__body, +.contact-card--shader .contact-card__corner { + position: relative; + z-index: 1; +} + +/* отдельная демо-карточка шейдера */ +.shader-demo-card { + position: relative; + overflow: hidden; + isolation: isolate; + width: 100%; + max-width: 360px; + min-height: 220px; + border-radius: var(--radius-card); + background: var(--neu-bg); + box-shadow: var(--neu-shadow); +} + +.shader-demo-card--wide { + max-width: 420px; + min-height: 260px; +} + +.shader-demo-card__label { + position: absolute; + left: 1rem; + bottom: 0.85rem; + z-index: 1; + font-family: var(--font-accent); + font-size: 0.95rem; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + pointer-events: none; +} diff --git a/public/_lab/lab.js b/public/_lab/lab.js new file mode 100644 index 0000000..e8d852a --- /dev/null +++ b/public/_lab/lab.js @@ -0,0 +1,60 @@ +/** + * Стенд: переключение темы и скругления. + * Пишет в localStorage под тем же ключом, что и панель темы сайта, + * поэтому выбранный режим сохранится при переходе на сам сайт. + */ +import { STORAGE_KEY, readThemeState, writeThemeState } from "/js/modules/theme/theme-core.js"; +import { ensureThemeFontLoaded } from "/js/modules/theme/theme-fonts.js"; + +function apply(patch) { + const state = { ...readThemeState(), ...patch }; + writeThemeState(state); + + const root = document.documentElement; + + // Тема: только светлая/тёмная. Режим «system» на стенде не нужен — + // важно видеть обе темы принудительно. + if (state.mode === "system") state.mode = prefersDark() ? "dark" : "light"; + root.dataset.theme = state.mode; + + if (state.radius) root.dataset.radius = state.radius; + if (state.font) root.dataset.font = state.font; + + const labAcc = state[state.mode] || state.light || { h: 210, s: 90, l: 40 }; + root.style.setProperty("--theme-h", String(labAcc.h)); + root.style.setProperty("--theme-s", String(labAcc.s)); + root.style.setProperty("--theme-l", String(labAcc.l)); + + ensureThemeFontLoaded(state.font); + syncButtons(); +} + +function prefersDark() { + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function bind(attr, key) { + const btns = document.querySelectorAll(`[${attr}]`); + btns.forEach((btn) => { + btn.addEventListener("click", () => { + apply({ [key]: btn.getAttribute(attr) }); + }); + }); +} + +function syncButtons() { + const state = readThemeState(); + const theme = state.mode === "system" ? (prefersDark() ? "dark" : "light") : state.mode; + + document.querySelectorAll("[data-lab-theme]").forEach((btn) => { + btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-theme") === theme ? "true" : "false"); + }); + + document.querySelectorAll("[data-lab-radius]").forEach((btn) => { + btn.setAttribute("aria-pressed", btn.getAttribute("data-lab-radius") === state.radius ? "true" : "false"); + }); +} + +bind("data-lab-theme", "mode"); +bind("data-lab-radius", "radius"); +syncButtons(); diff --git a/public/_lab/live-reload.js b/public/_lab/live-reload.js new file mode 100644 index 0000000..3973e1b --- /dev/null +++ b/public/_lab/live-reload.js @@ -0,0 +1,20 @@ +/** + * Live-reload-клиент для стендов public/_lab. + * Подключается к SSE /_lab/events сервера serve-dev.mjs и перезагружает + * страницу при правке любого css/js/mjs/html в public/. + * + * Вставка в стенд: + * Работает только при запущенном `npm run serve` (нет на проде). + */ +(function () { + if (!window.EventSource) return; + const es = new EventSource("/_lab/events"); + es.onmessage = (e) => { + if (e.data === "reload") { + es.close(); + window.location.reload(); + } + }; + // Если сервер не поднят — молча уходим, F5 работает как раньше. + es.onerror = () => es.close(); +})(); diff --git a/public/_lab/neu-card.html b/public/_lab/neu-card.html new file mode 100644 index 0000000..c070b52 --- /dev/null +++ b/public/_lab/neu-card.html @@ -0,0 +1,662 @@ + + + + + + + Стенд: neu-card + + + + + + +
+
+

neu-card

+

+ Стенд для настройки вида. Сборка не нужна: правим public/css/main.css → + страница перезагружается сама (live-reload). Переключатель темы в правом нижнем углу. +

+ +
+ + +
+

Финальный вид

+

+ Принятые значения: сила объёма 0.5, блик 0.2, без текстуры. + Обычная неуморфная карточка на нейтральном фоне. +

+
+
+ Проект + Neutral card + Нейтральный фон, выпуклая +
+
+ Accent + --accent +
+ +
+
+ + +
+

Обычная карточка · mp-card

+

+ Твоя базовая карточка с сайта — для сравнения с неуморфной рядом. Разметка и стили + взяты из public/css/main.css без изменений. +

+ +
+ + +
+

Токены

+

Числа, которыми управляется вид. Значение по умолчанию — в скобках.

+
+
+
--neu-bg --bg-elevated
+
+ Нейтральная заливка: в тёмной теме тёмная, в светлой — светлая. Отличается от + --bg на 3%, поэтому объём читается тенями, а не контрастом. +
+
+
+
--neu-spread 0.5
+
Сила объёма. Масштабирует все семь теней разом. 0 — плоская карточка.
+
+
+
--neu-hover-spread 1.5
+
Множитель объёма при наведении.
+
+
+
--neu-glow-a 0.2
+
Сила блика из левого верхнего угла. 0 — выключен.
+
+
+
--neu-in-light-mult 0.55 / 0.4
+
+ Сила внутреннего блика (тёмная / светлая тема). На светлом фоне белый блик «горит», + поэтому там он слабее. +
+
+
+
--neu-in-dark-mult 0.75 / 0.5
+
Сила внутренней тени (тёмная / светлая тема).
+
+
+
--neu-press 1px
+
Смещение вниз при нажатии.
+
+
+
--neu-press-fade 0.5
+
+ Во сколько слабеют внутренние блики при нажатии. Блики НЕ инвертируются — карточка + остаётся выпуклой. +
+
+
+
--neu-lift -3px
+
Подъём при наведении.
+
+
+
--neu-radius --radius-card
+
Скругление. По умолчанию следует настройке темы.
+
+
+
--neu-pad 1.25rem
+
Внутренние отступы.
+
+
+
+ + +
+

Фоны

+

+ Неуморфизм читается только в паре с подложкой. Карточка построена от --bg, + поэтому на каждом фоне ведёт себя по-разному. +

+
+
+
+
+ Neutral + --surface +
+
+ Accent + --accent +
+
+

Страница · --bg

+
+ +
+
+
+ Neutral + --surface +
+
+ Accent + --accent +
+
+

Поверхность · --bg-elevated

+
+ +
+
+
+ Neutral + --surface +
+
+ Accent + --accent +
+
+

Карточка на карточке · --surface — худший случай

+
+ +
+
+
+ Neutral + --surface +
+
+ Accent + --accent +
+
+

Акцентная подложка · --accent

+
+
+
+ + +
+

Состояния

+

+ Наведи курсор и нажми — остальное показано принудительно, чтобы видеть их одновременно. +

+
+
+
+ Rest + Покой +
+

rest

+
+ +
+
+ Hover + Наведи курсор +
+

:hover

+
+ +
+
+ Pressed + Вдавлена +
+

:active / .is-pressed

+
+ + + +
+
+ Disabled + Недоступна +
+

.is-disabled

+
+ +
+
+ Flat + Без объёма +
+

.neu-card--flat

+
+
+
+ + +
+

Содержимое

+

Проверка наполнения: от пустой до переполненной.

+
+
+ Только мета +
+ +
+ Только заголовок +
+ +
+ Eyebrow + Полный набор + Подпись под заголовком +
+ +
+ Длинный заголовок в несколько строк + + И подпись тоже длинная, чтобы проверить, как карточка растёт по высоте + +
+ +
+ + С иконкой + Иконка сверху +
+ +
+ + С обложкой + Картинка на всю ширину +
+ +
+ Акцентная + --accent +
+
+
+ + +
+

Размеры

+

+ Базовый размер задаётся --neu-min-w / --neu-min-h, дальше + карточка растёт от содержимого. В сетке — .neu-card--fluid. +

+
+
+ Компактная + 160 × 96 +
+ +
+ Базовая + 200 × 128 +
+ +
+ Крупная + 280 × 180 +
+ +
+ Широкие поля + --neu-pad: 2rem +
+ +
+ Сильное скругление + --neu-radius: 28px +
+
+
+ + +
+

Сетка

+

+ Рядом друг с другом тени соседних карточек накладываются. Здесь видно, не слипаются ли. +

+ +

Три в ряд, gap 1rem

+
+
+ Проект один + Продуктовый дизайн +
+
+ Проект два + Исследование +
+
+ Проект три + Дизайн-система +
+
+ +

Смешанный рост

+

Разная высота содержимого — проверка на растяжение по сетке.

+
+
+ Короткая +
+
+ Средняя по объёму + + Здесь подпись занимает две строки, и карточка должна потянуться выше соседней + +
+
+ С обложкой + Ещё выше +
+
+
+ + +
+

Карточка в карточке

+

+ Нижняя карточка больше и включает в себя верхние. Оценка того, как объём читается, когда + карточка лежит поверх той же самой поверхности, а не на фоне страницы. Это худший + случай для неуморфизма: обе поверхности одинаковые по цвету. +

+ +
+
+ Внешняя + Карточка-контейнер + Поверхность второго уровня, вложенные карточки внутри + +
+
+ Вложенная + Тот же --neu-bg +
+
+ Вложенная + Тот же --neu-bg +
+
+ Вложенная + Тот же --neu-bg +
+
+ +
+ + +
+
+
+ +

То же в светлой теме

+

+ Внутренние блики настроены раздельно для тёмной и светлой темы — здесь видна разница. +

+
+ + +
+

Светлая тема · тень

+

+ На светлом фоне чистый чёрный 0.25 выглядит грязно. Варианты: цвет тени смещён в + холодный тёмно-серый, альфа внешних теней снижена через --neu-drop-mult. + Эта секция принудительно светлая. +

+
+
+
+ Текущий + 47,53,75 · drop 0.5 +
+

как в main.css

+
+
+
+ Мягче + 70,75,95 · drop 0.4 +
+

светлее тень

+
+
+
+ Глубже + 30,35,55 · drop 0.35 +
+

темнее, но меньше альфа

+
+
+
+ Чёрный + 0,0,0 · drop 0.5 +
+

было грязно

+
+
+
+ + +
+

Контактные карточки

+

+ Оригинальная .contact-card с сайта, теперь с неуморфной основой + (объём как у .neu-card). Бренд-цвета через модификаторы + --brand-tg / --brand-vk / --brand-ya / --brand-email. + Цветное наложение — радиальный градиент ::before из --contact-brand. +

+ +
+ + +
+

Матрица · neu-card + шейдер под бликом

+

+ Обычная неуморфная карточка (тени снаружи, блик из угла) с живым шейдером + внутри, под бликом. Слева — чистая neu-card для сравнения, + справа — та же карточка с анимированным mesh-слоем. Объём и блик сохранены, + шейдер только подсвечивает поверхность. +

+ +

Сетка 3 × 2

+
+ +
+ Проект + Обычная + только тени и блик +
+
+ + Проект + Шейдер · demo + mesh под бликом +
+
+ + Проект + Шейдер · email + mesh под бликом +
+ + +
+ + Проект + Шейдер · tg + mesh под бликом +
+
+ + Проект + Шейдер · vk + mesh под бликом +
+
+ + Проект + Шейдер · ya + mesh под бликом +
+
+ +

Одиночная крупная

+
+
+ + Кейс + Neu + shader + объём снаружи, анимация внутри +
+
+ + Кейс + Static + shader + блик и анимация, без hover-отклика +
+
+
+ + +
+

Mesh gradient

+

+ Единственный используемый шейдер — анимированный mesh gradient (цветные пятна + плавно перетекают). Ниже — карточка-пример только с ним. Контактные и матричные + карточки выше используют тот же шейдер с брендовыми цветами. +

+ +

Пример · mesh gradient

+
+
+ + mesh gradient +
+
+
+
+ +
+ + + + + + +
+ + + + + + diff --git a/public/_lab/noise-test.html b/public/_lab/noise-test.html new file mode 100644 index 0000000..d44965c --- /dev/null +++ b/public/_lab/noise-test.html @@ -0,0 +1,3 @@ + +
+ \ No newline at end of file diff --git a/public/_lab/paper-bundle.js b/public/_lab/paper-bundle.js new file mode 100644 index 0000000..6d8c788 --- /dev/null +++ b/public/_lab/paper-bundle.js @@ -0,0 +1,961 @@ +// node_modules/@paper-design/shaders/dist/vertex-shader.js +var vertexShaderSource = `#version 300 es +precision mediump float; + +layout(location = 0) in vec4 a_position; + +uniform vec2 u_resolution; +uniform float u_pixelRatio; +uniform float u_imageAspectRatio; +uniform float u_originX; +uniform float u_originY; +uniform float u_worldWidth; +uniform float u_worldHeight; +uniform float u_fit; +uniform float u_scale; +uniform float u_rotation; +uniform float u_offsetX; +uniform float u_offsetY; + +out vec2 v_objectUV; +out vec2 v_objectBoxSize; +out vec2 v_responsiveUV; +out vec2 v_responsiveBoxGivenSize; +out vec2 v_patternUV; +out vec2 v_patternBoxSize; +out vec2 v_imageUV; + +vec3 getBoxSize(float boxRatio, vec2 givenBoxSize) { + vec2 box = vec2(0.); + // fit = none + box.x = boxRatio * min(givenBoxSize.x / boxRatio, givenBoxSize.y); + float noFitBoxWidth = box.x; + if (u_fit == 1.) { // fit = contain + box.x = boxRatio * min(u_resolution.x / boxRatio, u_resolution.y); + } else if (u_fit == 2.) { // fit = cover + box.x = boxRatio * max(u_resolution.x / boxRatio, u_resolution.y); + } + box.y = box.x / boxRatio; + return vec3(box, noFitBoxWidth); +} + +void main() { + gl_Position = a_position; + + vec2 uv = gl_Position.xy * .5; + vec2 boxOrigin = vec2(.5 - u_originX, u_originY - .5); + vec2 givenBoxSize = vec2(u_worldWidth, u_worldHeight); + givenBoxSize = max(givenBoxSize, vec2(1.)) * u_pixelRatio; + float r = u_rotation * 3.14159265358979323846 / 180.; + mat2 graphicRotation = mat2(cos(r), sin(r), -sin(r), cos(r)); + vec2 graphicOffset = vec2(-u_offsetX, u_offsetY); + + + // =================================================== + + float fixedRatio = 1.; + vec2 fixedRatioBoxGivenSize = vec2( + (u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x, + (u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y + ); + + v_objectBoxSize = getBoxSize(fixedRatio, fixedRatioBoxGivenSize).xy; + vec2 objectWorldScale = u_resolution.xy / v_objectBoxSize; + + v_objectUV = uv; + v_objectUV *= objectWorldScale; + v_objectUV += boxOrigin * (objectWorldScale - 1.); + v_objectUV += graphicOffset; + v_objectUV /= u_scale; + v_objectUV = graphicRotation * v_objectUV; + + // =================================================== + + v_responsiveBoxGivenSize = vec2( + (u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x, + (u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y + ); + float responsiveRatio = v_responsiveBoxGivenSize.x / v_responsiveBoxGivenSize.y; + vec2 responsiveBoxSize = getBoxSize(responsiveRatio, v_responsiveBoxGivenSize).xy; + vec2 responsiveBoxScale = u_resolution.xy / responsiveBoxSize; + + #ifdef ADD_HELPERS + v_responsiveHelperBox = uv; + v_responsiveHelperBox *= responsiveBoxScale; + v_responsiveHelperBox += boxOrigin * (responsiveBoxScale - 1.); + #endif + + v_responsiveUV = uv; + v_responsiveUV *= responsiveBoxScale; + v_responsiveUV += boxOrigin * (responsiveBoxScale - 1.); + v_responsiveUV += graphicOffset; + v_responsiveUV /= u_scale; + v_responsiveUV.x *= responsiveRatio; + v_responsiveUV = graphicRotation * v_responsiveUV; + v_responsiveUV.x /= responsiveRatio; + + // =================================================== + + float patternBoxRatio = givenBoxSize.x / givenBoxSize.y; + vec2 patternBoxGivenSize = vec2( + (u_worldWidth == 0.) ? u_resolution.x : givenBoxSize.x, + (u_worldHeight == 0.) ? u_resolution.y : givenBoxSize.y + ); + patternBoxRatio = patternBoxGivenSize.x / patternBoxGivenSize.y; + + vec3 boxSizeData = getBoxSize(patternBoxRatio, patternBoxGivenSize); + v_patternBoxSize = boxSizeData.xy; + float patternBoxNoFitBoxWidth = boxSizeData.z; + vec2 patternBoxScale = u_resolution.xy / v_patternBoxSize; + + v_patternUV = uv; + v_patternUV += graphicOffset / patternBoxScale; + v_patternUV += boxOrigin; + v_patternUV -= boxOrigin / patternBoxScale; + v_patternUV *= u_resolution.xy; + v_patternUV /= u_pixelRatio; + if (u_fit > 0.) { + v_patternUV *= (patternBoxNoFitBoxWidth / v_patternBoxSize.x); + } + v_patternUV /= u_scale; + v_patternUV = graphicRotation * v_patternUV; + v_patternUV += boxOrigin / patternBoxScale; + v_patternUV -= boxOrigin; + // x100 is a default multiplier between vertex and fragmant shaders + // we use it to avoid UV presision issues + v_patternUV *= .01; + + // =================================================== + + vec2 imageBoxSize; + if (u_fit == 1.) { // contain + imageBoxSize.x = min(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio; + } else if (u_fit == 2.) { // cover + imageBoxSize.x = max(u_resolution.x / u_imageAspectRatio, u_resolution.y) * u_imageAspectRatio; + } else { + imageBoxSize.x = min(10.0, 10.0 / u_imageAspectRatio * u_imageAspectRatio); + } + imageBoxSize.y = imageBoxSize.x / u_imageAspectRatio; + vec2 imageBoxScale = u_resolution.xy / imageBoxSize; + + v_imageUV = uv; + v_imageUV *= imageBoxScale; + v_imageUV += boxOrigin * (imageBoxScale - 1.); + v_imageUV += graphicOffset; + v_imageUV /= u_scale; + v_imageUV.x *= u_imageAspectRatio; + v_imageUV = graphicRotation * v_imageUV; + v_imageUV.x /= u_imageAspectRatio; + + v_imageUV += .5; + v_imageUV.y = 1. - v_imageUV.y; +}`; + +// node_modules/@paper-design/shaders/dist/shader-mount.js +var DEFAULT_MAX_PIXEL_COUNT = 1920 * 1080 * 4; +var ShaderMount = class { + parentElement; + canvasElement; + gl; + program = null; + uniformLocations = {}; + /** The fragment shader that we are using */ + fragmentShader; + /** Stores the RAF for the render loop */ + rafId = null; + /** Time of the last rendered frame */ + lastRenderTime = 0; + /** Total time that we have played any animation, passed as a uniform to the shader for time-based VFX */ + currentFrame = 0; + /** The speed that we progress through animation time (multiplies by delta time every update). Allows negatives to play in reverse. If set to 0, rAF will stop entirely so static shaders have no recurring performance costs */ + speed = 0; + /** Actual speed used that accounts for visibility: we pause the shader if the tab is hidden or the element out of the viewport */ + currentSpeed = 0; + /** Uniforms that are provided by the user for the specific shader being mounted (not including uniforms that this Mount adds, like time and resolution) */ + providedUniforms; + /** Names of the uniforms that should have mipmaps generated for them */ + mipmaps = []; + /** Just a sanity check to make sure frames don't run after we're disposed */ + hasBeenDisposed = false; + /** If the resolution of the canvas has changed since the last render */ + resolutionChanged = true; + /** Store textures that are provided by the user */ + textures = /* @__PURE__ */ new Map(); + minPixelRatio; + maxPixelCount; + isSafari = isSafari(); + uniformCache = {}; + textureUnitMap = /* @__PURE__ */ new Map(); + ownerDocument; + constructor(parentElement, fragmentShader, uniforms, webGlContextAttributes, speed = 0, frame = 0, minPixelRatio = 2, maxPixelCount = DEFAULT_MAX_PIXEL_COUNT, mipmaps = []) { + if (parentElement?.nodeType === 1) { + this.parentElement = parentElement; + } else { + throw new Error("Paper Shaders: parent element must be an HTMLElement"); + } + this.ownerDocument = parentElement.ownerDocument; + if (!this.ownerDocument.querySelector("style[data-paper-shader]")) { + const styleElement = this.ownerDocument.createElement("style"); + styleElement.innerHTML = defaultStyle; + styleElement.setAttribute("data-paper-shader", ""); + this.ownerDocument.head.prepend(styleElement); + } + const canvasElement = this.ownerDocument.createElement("canvas"); + this.canvasElement = canvasElement; + this.parentElement.prepend(canvasElement); + this.fragmentShader = fragmentShader; + this.providedUniforms = uniforms; + this.mipmaps = mipmaps; + this.currentFrame = frame; + this.minPixelRatio = minPixelRatio; + this.maxPixelCount = maxPixelCount; + const gl = canvasElement.getContext("webgl2", webGlContextAttributes); + if (!gl) { + throw new Error("Paper Shaders: WebGL is not supported in this browser"); + } + this.gl = gl; + this.initProgram(); + this.setupPositionAttribute(); + this.setupUniforms(); + this.setUniformValues(this.providedUniforms); + this.setupResizeObserver(); + visualViewport?.addEventListener("resize", this.handleVisualViewportChange); + this.setupIntersectionObserver(); + this.setSpeed(speed); + this.parentElement.setAttribute("data-paper-shader", ""); + this.parentElement.paperShaderMount = this; + this.ownerDocument.addEventListener("visibilitychange", this.handleDocumentVisibilityChange); + } + initProgram = () => { + const program = createProgram(this.gl, vertexShaderSource, this.fragmentShader); + if (!program) return; + this.program = program; + }; + setupPositionAttribute = () => { + const positionAttributeLocation = this.gl.getAttribLocation(this.program, "a_position"); + const positionBuffer = this.gl.createBuffer(); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); + const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]; + this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW); + this.gl.enableVertexAttribArray(positionAttributeLocation); + this.gl.vertexAttribPointer(positionAttributeLocation, 2, this.gl.FLOAT, false, 0, 0); + }; + setupUniforms = () => { + const uniformLocations = { + u_time: this.gl.getUniformLocation(this.program, "u_time"), + u_pixelRatio: this.gl.getUniformLocation(this.program, "u_pixelRatio"), + u_resolution: this.gl.getUniformLocation(this.program, "u_resolution") + }; + Object.entries(this.providedUniforms).forEach(([key, value]) => { + uniformLocations[key] = this.gl.getUniformLocation(this.program, key); + if (value instanceof HTMLImageElement) { + const aspectRatioUniformName = `${key}AspectRatio`; + uniformLocations[aspectRatioUniformName] = this.gl.getUniformLocation(this.program, aspectRatioUniformName); + } + }); + this.uniformLocations = uniformLocations; + }; + /** + * The scale that we should render at. + * - Used to target 2x rendering even on 1x screens for better antialiasing + * - Prevents the virtual resolution from going beyond the maximum resolution + * - Accounts for the page zoom level so we render in physical device pixels rather than CSS pixels + */ + renderScale = 1; + parentWidth = 0; + parentHeight = 0; + parentDevicePixelWidth = 0; + parentDevicePixelHeight = 0; + devicePixelsSupported = false; + intersectionObserver = null; + isInViewport = true; + resizeObserver = null; + setupResizeObserver = () => { + this.resizeObserver = new ResizeObserver(([entry]) => { + if (entry?.borderBoxSize[0]) { + const physicalPixelSize = entry.devicePixelContentBoxSize?.[0]; + if (physicalPixelSize !== void 0) { + this.devicePixelsSupported = true; + this.parentDevicePixelWidth = physicalPixelSize.inlineSize; + this.parentDevicePixelHeight = physicalPixelSize.blockSize; + } + this.parentWidth = entry.borderBoxSize[0].inlineSize; + this.parentHeight = entry.borderBoxSize[0].blockSize; + } + this.handleResize(); + }); + this.resizeObserver.observe(this.parentElement); + }; + setupIntersectionObserver = () => { + const view = this.ownerDocument.defaultView; + if (!view?.IntersectionObserver) return; + this.intersectionObserver = new view.IntersectionObserver(([entry]) => { + this.isInViewport = entry?.isIntersecting ?? true; + this.updateCurrentSpeed(); + }); + this.intersectionObserver.observe(this.parentElement); + }; + // Visual viewport resize handler, mainly used to react to browser zoom changes. + // Resize observer by itself does not react to pinch zoom, and although it usually + // reacts to classic browser zoom, it's not guaranteed in edge cases. + // Since timing between visual viewport changes and resize observer is complex + // and because we'd like to know the device pixel sizes of elements, we just restart + // the observer to get a guaranteed fresh callback regardless if it would have triggered or not. + handleVisualViewportChange = () => { + this.resizeObserver?.disconnect(); + this.setupResizeObserver(); + }; + /** Resize handler for when the container div changes size or the max pixel count changes and we want to resize our canvas to match */ + handleResize = () => { + let targetPixelWidth = 0; + let targetPixelHeight = 0; + const dpr = Math.max(1, window.devicePixelRatio); + const pinchZoom = visualViewport?.scale ?? 1; + if (this.devicePixelsSupported) { + const scaleToMeetMinPixelRatio = Math.max(1, this.minPixelRatio / dpr); + targetPixelWidth = this.parentDevicePixelWidth * scaleToMeetMinPixelRatio * pinchZoom; + targetPixelHeight = this.parentDevicePixelHeight * scaleToMeetMinPixelRatio * pinchZoom; + } else { + let targetRenderScale = Math.max(dpr, this.minPixelRatio) * pinchZoom; + if (this.isSafari) { + const zoomLevel = bestGuessBrowserZoom(this.ownerDocument); + targetRenderScale *= Math.max(1, zoomLevel); + } + targetPixelWidth = Math.round(this.parentWidth) * targetRenderScale; + targetPixelHeight = Math.round(this.parentHeight) * targetRenderScale; + } + const maxPixelCountHeadroom = Math.sqrt(this.maxPixelCount) / Math.sqrt(targetPixelWidth * targetPixelHeight); + const scaleToMeetMaxPixelCount = Math.min(1, maxPixelCountHeadroom); + const newWidth = Math.round(targetPixelWidth * scaleToMeetMaxPixelCount); + const newHeight = Math.round(targetPixelHeight * scaleToMeetMaxPixelCount); + const newRenderScale = newWidth / Math.round(this.parentWidth); + if (this.canvasElement.width !== newWidth || this.canvasElement.height !== newHeight || this.renderScale !== newRenderScale) { + this.renderScale = newRenderScale; + this.canvasElement.width = newWidth; + this.canvasElement.height = newHeight; + this.resolutionChanged = true; + this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + this.render(performance.now()); + } + }; + render = (currentTime) => { + if (this.hasBeenDisposed) return; + if (this.program === null) { + console.warn("Tried to render before program or gl was initialized"); + return; + } + const dt = currentTime - this.lastRenderTime; + this.lastRenderTime = currentTime; + if (this.currentSpeed !== 0) { + this.currentFrame += dt * this.currentSpeed; + } + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + this.gl.useProgram(this.program); + this.gl.uniform1f(this.uniformLocations.u_time, this.currentFrame * 1e-3); + if (this.resolutionChanged) { + this.gl.uniform2f(this.uniformLocations.u_resolution, this.gl.canvas.width, this.gl.canvas.height); + this.gl.uniform1f(this.uniformLocations.u_pixelRatio, this.renderScale); + this.resolutionChanged = false; + } + this.gl.drawArrays(this.gl.TRIANGLES, 0, 6); + if (this.currentSpeed !== 0) { + this.requestRender(); + } else { + this.rafId = null; + } + }; + requestRender = () => { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + } + this.rafId = requestAnimationFrame(this.render); + }; + /** Creates a texture from an image and sets it into a uniform value */ + setTextureUniform = (uniformName, image) => { + if (!image.complete || image.naturalWidth === 0) { + throw new Error(`Paper Shaders: image for uniform ${uniformName} must be fully loaded`); + } + const existingTexture = this.textures.get(uniformName); + if (existingTexture) { + this.gl.deleteTexture(existingTexture); + } + if (!this.textureUnitMap.has(uniformName)) { + this.textureUnitMap.set(uniformName, this.textureUnitMap.size); + } + const textureUnit = this.textureUnitMap.get(uniformName); + this.gl.activeTexture(this.gl.TEXTURE0 + textureUnit); + const texture = this.gl.createTexture(); + this.gl.bindTexture(this.gl.TEXTURE_2D, texture); + this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); + this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); + this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR); + this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR); + this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, image); + if (this.mipmaps.includes(uniformName)) { + this.gl.generateMipmap(this.gl.TEXTURE_2D); + this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR_MIPMAP_LINEAR); + } + const error = this.gl.getError(); + if (error !== this.gl.NO_ERROR || texture === null) { + console.error("Paper Shaders: WebGL error when uploading texture:", error); + return; + } + this.textures.set(uniformName, texture); + const location = this.uniformLocations[uniformName]; + if (location) { + this.gl.uniform1i(location, textureUnit); + const aspectRatioUniformName = `${uniformName}AspectRatio`; + const aspectRatioLocation = this.uniformLocations[aspectRatioUniformName]; + if (aspectRatioLocation) { + const aspectRatio = image.naturalWidth / image.naturalHeight; + this.gl.uniform1f(aspectRatioLocation, aspectRatio); + } + } + }; + /** Utility: recursive equality test for all the uniforms */ + areUniformValuesEqual = (a, b) => { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) { + return a.every((val, i) => this.areUniformValuesEqual(val, b[i])); + } + return false; + }; + /** Sets the provided uniform values into the WebGL program, can be a partial list of uniforms that have changed */ + setUniformValues = (updatedUniforms) => { + this.gl.useProgram(this.program); + Object.entries(updatedUniforms).forEach(([key, value]) => { + let cacheValue = value; + if (value instanceof HTMLImageElement) { + cacheValue = `${value.src.slice(0, 200)}|${value.naturalWidth}x${value.naturalHeight}`; + } + if (this.areUniformValuesEqual(this.uniformCache[key], cacheValue)) return; + this.uniformCache[key] = cacheValue; + const location = this.uniformLocations[key]; + if (!location) { + console.warn(`Uniform location for ${key} not found`); + return; + } + if (value instanceof HTMLImageElement) { + this.setTextureUniform(key, value); + } else if (Array.isArray(value)) { + let flatArray = null; + let valueLength = null; + if (value[0] !== void 0 && Array.isArray(value[0])) { + const firstChildLength = value[0].length; + if (value.every((arr) => arr.length === firstChildLength)) { + flatArray = value.flat(); + valueLength = firstChildLength; + } else { + console.warn(`All child arrays must be the same length for ${key}`); + return; + } + } else { + flatArray = value; + valueLength = flatArray.length; + } + switch (valueLength) { + case 2: + this.gl.uniform2fv(location, flatArray); + break; + case 3: + this.gl.uniform3fv(location, flatArray); + break; + case 4: + this.gl.uniform4fv(location, flatArray); + break; + case 9: + this.gl.uniformMatrix3fv(location, false, flatArray); + break; + case 16: + this.gl.uniformMatrix4fv(location, false, flatArray); + break; + default: + console.warn(`Unsupported uniform array length: ${valueLength}`); + } + } else if (typeof value === "number") { + this.gl.uniform1f(location, value); + } else if (typeof value === "boolean") { + this.gl.uniform1i(location, value ? 1 : 0); + } else { + console.warn(`Unsupported uniform type for ${key}: ${typeof value}`); + } + }); + }; + /** Gets the current total animation time from 0ms */ + getCurrentFrame = () => { + return this.currentFrame; + }; + /** Set a frame to get a deterministic result, frames are literally just milliseconds from zero since the animation started */ + setFrame = (newFrame) => { + this.currentFrame = newFrame; + this.lastRenderTime = performance.now(); + this.render(performance.now()); + }; + /** Set an animation speed (or 0 to stop animation) */ + setSpeed = (newSpeed = 1) => { + this.speed = newSpeed; + this.updateCurrentSpeed(); + }; + /** Apply the target speed, pausing (0) while the tab is hidden or the element is out of the viewport */ + updateCurrentSpeed = () => { + this.setCurrentSpeed(this.ownerDocument.hidden || !this.isInViewport ? 0 : this.speed); + }; + setCurrentSpeed = (newSpeed) => { + this.currentSpeed = newSpeed; + if (this.rafId === null && newSpeed !== 0) { + this.lastRenderTime = performance.now(); + this.rafId = requestAnimationFrame(this.render); + } + if (this.rafId !== null && newSpeed === 0) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }; + /** Set the maximum pixel count for the shader, this will limit the number of pixels that will be rendered */ + setMaxPixelCount = (newMaxPixelCount = DEFAULT_MAX_PIXEL_COUNT) => { + this.maxPixelCount = newMaxPixelCount; + this.handleResize(); + }; + /** Set the minimum pixel ratio for the shader */ + setMinPixelRatio = (newMinPixelRatio = 2) => { + this.minPixelRatio = newMinPixelRatio; + this.handleResize(); + }; + /** Update the uniforms that are provided by the outside shader, can be a partial set with only the uniforms that have changed */ + setUniforms = (newUniforms) => { + this.setUniformValues(newUniforms); + this.providedUniforms = { ...this.providedUniforms, ...newUniforms }; + this.render(performance.now()); + }; + handleDocumentVisibilityChange = () => { + this.updateCurrentSpeed(); + }; + /** Dispose of the shader mount, cleaning up all of the WebGL resources */ + dispose = () => { + this.hasBeenDisposed = true; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + if (this.gl && this.program) { + this.textures.forEach((texture) => { + this.gl.deleteTexture(texture); + }); + this.textures.clear(); + this.gl.deleteProgram(this.program); + this.program = null; + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null); + this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null); + this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); + this.gl.getError(); + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + this.intersectionObserver = null; + } + visualViewport?.removeEventListener("resize", this.handleVisualViewportChange); + this.ownerDocument.removeEventListener("visibilitychange", this.handleDocumentVisibilityChange); + this.uniformLocations = {}; + this.canvasElement.remove(); + delete this.parentElement.paperShaderMount; + }; +}; +function createShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; +} +function createProgram(gl, vertexShaderSource2, fragmentShaderSource) { + const format = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.MEDIUM_FLOAT); + const precision = format ? format.precision : null; + if (precision && precision < 23) { + vertexShaderSource2 = vertexShaderSource2.replace(/precision\s+(lowp|mediump)\s+float;/g, "precision highp float;"); + fragmentShaderSource = fragmentShaderSource.replace(/precision\s+(lowp|mediump)\s+float/g, "precision highp float").replace(/\b(uniform|varying|attribute)\s+(lowp|mediump)\s+(\w+)/g, "$1 highp $3"); + } + const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource2); + const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); + if (!vertexShader || !fragmentShader) return null; + const program = gl.createProgram(); + if (!program) return null; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error("Unable to initialize the shader program: " + gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return null; + } + gl.detachShader(program, vertexShader); + gl.detachShader(program, fragmentShader); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return program; +} +var defaultStyle = `@layer paper-shaders { + :where([data-paper-shader]) { + isolation: isolate; + position: relative; + + & canvas { + contain: strict; + display: block; + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + border-radius: inherit; + corner-shape: inherit; + } + } +}`; +function isSafari() { + const ua = navigator.userAgent.toLowerCase(); + return ua.includes("safari") && !ua.includes("chrome") && !ua.includes("android"); +} +function bestGuessBrowserZoom(doc) { + const viewportScale = visualViewport?.scale ?? 1; + const viewportWidth = visualViewport?.width ?? window.innerWidth; + const scrollbarWidth = window.innerWidth - doc.documentElement.clientWidth; + const innerWidth = viewportScale * viewportWidth + scrollbarWidth; + const ratio = outerWidth / innerWidth; + const zoomPercentageRounded = Math.round(100 * ratio); + if (zoomPercentageRounded % 5 === 0) { + return zoomPercentageRounded / 100; + } + if (zoomPercentageRounded === 33) { + return 1 / 3; + } + if (zoomPercentageRounded === 67) { + return 2 / 3; + } + if (zoomPercentageRounded === 133) { + return 4 / 3; + } + return ratio; +} + +// node_modules/@paper-design/shaders/dist/shader-utils.js +var declarePI = ` +#define TWO_PI 6.28318530718 +#define PI 3.14159265358979323846 +`; +var rotation2 = ` +vec2 rotate(vec2 uv, float th) { + return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv; +} +`; +var textureRandomizerR = ` + float randomR(vec2 p) { + vec2 uv = floor(p) / 100. + .5; + return texture(u_noiseTexture, fract(uv)).r; + } +`; +var fiberNoise = ` +float fiberRandom(vec2 p) { + vec2 uv = floor(p) / 100.; + return texture(u_noiseTexture, fract(uv)).b; +} + +float fiberValueNoise(vec2 st) { + vec2 i = floor(st); + vec2 f = fract(st); + float a = fiberRandom(i); + float b = fiberRandom(i + vec2(1.0, 0.0)); + float c = fiberRandom(i + vec2(0.0, 1.0)); + float d = fiberRandom(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + float x1 = mix(a, b, u.x); + float x2 = mix(c, d, u.x); + return mix(x1, x2, u.y); +} + +float fiberNoiseFbm(in vec2 n, vec2 seedOffset) { + float total = 0.0, amplitude = 1.; + for (int i = 0; i < 4; i++) { + n = rotate(n, .7); + total += fiberValueNoise(n + seedOffset) * amplitude; + n *= 2.; + amplitude *= 0.6; + } + return total; +} + +float fiberNoise(vec2 uv, vec2 seedOffset) { + float epsilon = 0.001; + float n1 = fiberNoiseFbm(uv + vec2(epsilon, 0.0), seedOffset); + float n2 = fiberNoiseFbm(uv - vec2(epsilon, 0.0), seedOffset); + float n3 = fiberNoiseFbm(uv + vec2(0.0, epsilon), seedOffset); + float n4 = fiberNoiseFbm(uv - vec2(0.0, epsilon), seedOffset); + return length(vec2(n1 - n2, n3 - n4)) / (2.0 * epsilon); +} +`; + +// node_modules/@paper-design/shaders/dist/shaders/paper-texture.js +var paperTextureFragmentShader = `#version 300 es +precision mediump float; + +uniform vec2 u_resolution; +uniform float u_pixelRatio; + +uniform vec4 u_colorFront; +uniform vec4 u_colorBack; + +uniform sampler2D u_image; +uniform float u_imageAspectRatio; + +uniform float u_contrast; +uniform float u_roughness; +uniform float u_fiber; +uniform float u_fiberSize; +uniform float u_crumples; +uniform float u_crumpleSize; +uniform float u_folds; +uniform float u_foldCount; +uniform float u_drops; +uniform float u_seed; +uniform float u_fade; + +uniform sampler2D u_noiseTexture; + +in vec2 v_imageUV; + +out vec4 fragColor; + +float getUvFrame(vec2 uv) { + float aax = 2. * fwidth(uv.x); + float aay = 2. * fwidth(uv.y); + + float left = smoothstep(0., aax, uv.x); + float right = 1. - smoothstep(1. - aax, 1., uv.x); + float bottom = smoothstep(0., aay, uv.y); + float top = 1. - smoothstep(1. - aay, 1., uv.y); + + return left * right * bottom * top; +} + +${declarePI} +${rotation2} +${textureRandomizerR} +float valueNoise(vec2 st) { + vec2 i = floor(st); + vec2 f = fract(st); + float a = randomR(i); + float b = randomR(i + vec2(1.0, 0.0)); + float c = randomR(i + vec2(0.0, 1.0)); + float d = randomR(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + float x1 = mix(a, b, u.x); + float x2 = mix(c, d, u.x); + return mix(x1, x2, u.y); +} +float fbm(vec2 n) { + float total = 0.0, amplitude = .4; + for (int i = 0; i < 3; i++) { + total += valueNoise(n) * amplitude; + n *= 1.99; + amplitude *= 0.65; + } + return total; +} + + +float randomG(vec2 p) { + vec2 uv = floor(p) / 50. + .5; + return texture(u_noiseTexture, fract(uv)).g; +} +float roughness(vec2 p) { + p *= .1; + float o = 0.; + for (float i = 0.; ++i < 4.; p *= 2.1) { + vec4 w = vec4(floor(p), ceil(p)); + vec2 f = fract(p); + o += mix( + mix(randomG(w.xy), randomG(w.xw), f.y), + mix(randomG(w.zy), randomG(w.zw), f.y), + f.x); + o += .2 / exp(2. * abs(sin(.2 * p.x + .5 * p.y))); + } + return o / 3.; +} + +${fiberNoise} + +vec2 randomGB(vec2 p) { + vec2 uv = floor(p) / 50. + .5; + return texture(u_noiseTexture, fract(uv)).gb; +} +float crumpledNoise(vec2 t, float pw) { + vec2 p = floor(t); + float wsum = 0.; + float cl = 0.; + for (int y = -1; y < 2; y += 1) { + for (int x = -1; x < 2; x += 1) { + vec2 b = vec2(float(x), float(y)); + vec2 q = b + p; + vec2 q2 = q - floor(q / 8.) * 8.; + vec2 c = q + randomGB(q2); + vec2 r = c - t; + float w = pow(smoothstep(0., 1., 1. - abs(r.x)), pw) * pow(smoothstep(0., 1., 1. - abs(r.y)), pw); + cl += (.5 + .5 * sin((q2.x + q2.y * 5.) * 8.)) * w; + wsum += w; + } + } + return pow(wsum != 0.0 ? cl / wsum : 0.0, .5) * 2.; +} +float crumplesShape(vec2 uv) { + return crumpledNoise(uv * .25, 16.) * crumpledNoise(uv * .5, 2.); +} + + +vec2 folds(vec2 uv) { + vec3 pp = vec3(0.); + float l = 9.; + for (float i = 0.; i < 15.; i++) { + if (i >= u_foldCount) break; + vec2 rand = randomGB(vec2(i, i * u_seed)); + float an = rand.x * TWO_PI; + vec2 p = vec2(cos(an), sin(an)) * rand.y; + float dist = distance(uv, p); + l = min(l, dist); + + if (l == dist) { + pp.xy = (uv - p.xy); + pp.z = dist; + } + } + return mix(pp.xy, vec2(0.), pow(pp.z, .25)); +} + +float drops(vec2 uv) { + vec2 iDropsUV = floor(uv); + vec2 fDropsUV = fract(uv); + float dropsMinDist = 1.; + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 neighbor = vec2(float(i), float(j)); + vec2 offset = randomGB(iDropsUV + neighbor); + offset = .5 + .5 * sin(10. * u_seed + TWO_PI * offset); + vec2 pos = neighbor + offset - fDropsUV; + float dist = length(pos); + dropsMinDist = min(dropsMinDist, dropsMinDist*dist); + } + } + return 1. - smoothstep(.05, .09, pow(dropsMinDist, .5)); +} + +float lst(float edge0, float edge1, float x) { + return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); +} + +void main() { + + vec2 imageUV = v_imageUV; + vec2 patternUV = v_imageUV - .5; + patternUV = 5. * (patternUV * vec2(u_imageAspectRatio, 1.)); + + vec2 roughnessUv = 1.5 * (gl_FragCoord.xy - .5 * u_resolution) / u_pixelRatio; + float roughness = roughness(roughnessUv + vec2(1., 0.)) - roughness(roughnessUv - vec2(1., 0.)); + + vec2 crumplesUV = fract(patternUV * .02 / u_crumpleSize - u_seed) * 32.; + float crumples = u_crumples * (crumplesShape(crumplesUV + vec2(.05, 0.)) - crumplesShape(crumplesUV)); + + vec2 fiberUV = 2. / u_fiberSize * patternUV; + float fiber = fiberNoise(fiberUV, vec2(0.)); + fiber = .5 * u_fiber * (fiber - 1.); + + vec2 normal = vec2(0.); + vec2 normalImage = vec2(0.); + + vec2 foldsUV = patternUV * .12; + foldsUV = rotate(foldsUV, 4. * u_seed); + vec2 w = folds(foldsUV); + foldsUV = rotate(foldsUV + .007 * cos(u_seed), .01 * sin(u_seed)); + vec2 w2 = folds(foldsUV); + + float drops = u_drops * drops(patternUV * 2.); + + float fade = u_fade * fbm(.17 * patternUV + 10. * u_seed); + fade = clamp(8. * fade * fade * fade, 0., 1.); + + w = mix(w, vec2(0.), fade); + w2 = mix(w2, vec2(0.), fade); + crumples = mix(crumples, 0., fade); + drops = mix(drops, 0., fade); + fiber *= mix(1., .5, fade); + roughness *= mix(1., .5, fade); + + normal.xy += u_folds * min(5. * u_contrast, 1.) * 4. * max(vec2(0.), w + w2); + normalImage.xy += u_folds * 2. * w; + + normal.xy += crumples; + normalImage.xy += 1.5 * crumples; + + normal.xy += 3. * drops; + normalImage.xy += .2 * drops; + + normal.xy += u_roughness * 1.5 * roughness; + normal.xy += fiber; + + normalImage += u_roughness * .75 * roughness; + normalImage += .2 * fiber; + + vec3 lightPos = vec3(1., 2., 1.); + float res = dot(normalize(vec3(normal, 9.5 - 9. * pow(u_contrast, .1))), normalize(lightPos)); + + vec3 fgColor = u_colorFront.rgb * u_colorFront.a; + float fgOpacity = u_colorFront.a; + vec3 bgColor = u_colorBack.rgb * u_colorBack.a; + float bgOpacity = u_colorBack.a; + + imageUV += .02 * normalImage; + float frame = getUvFrame(imageUV); + vec4 image = texture(u_image, imageUV); + image.rgb += .6 * pow(u_contrast, .4) * (res - .7); + + frame *= image.a; + + vec3 color = fgColor * res; + float opacity = fgOpacity * res; + + color += bgColor * (1. - opacity); + opacity += bgOpacity * (1. - opacity); + opacity = mix(opacity, 1., frame); + + color -= .007 * drops; + + color.rgb = mix(color, image.rgb, frame); + + fragColor = vec4(color, opacity); +} +`; + +// node_modules/@paper-design/shaders/dist/get-shader-noise-texture.js +function getShaderNoiseTexture() { + if (typeof window === "undefined") { + return void 0; + } + const img = new Image(); + img.src = noiseSrc; + return img; +} +var noiseSrc = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAADAFBMVEUCAQMBAf7/AgMD/wID//7+/wT+A/4FAmYIAqIKnw7+//4EAisEAUgGBIYIewkFVhEJjAoFAuEFA8GWAv6T/gz+AzER/25z/wu1/w1nAggL/049BQUC/y39BrckAQQp/wr+AZYNOvx9AQkN/pELUvMFaAZTBAgIRgsO/7cJNQT+YgkLwRELIf5O/wlP/v79/q4IGAYLK4+kAQ1tAv4IdMpc/4xNMBF2/lQN2vTFAws9BLf9/3kJJgsMRF3+HwkLxfv9BVL8BHEN/9gMsg7cA/13/vv9OAqWA0sOofP9TAsIe/4FQqoF4Q/aAgsQwnKQAwa5BP0JW21NqgmY/f3Z/wkI7whGjAr7oAkLrGGf/JH8jg4zAj4R0Qr+xQ8VZv1Y/8O6//wfA/5bAT79/lQ1AGn8egkKdom0BgYOsfjtBAVDBoz9/zG0A238P/tsbQ/+A9rIig/HCEtvIgrM/1lwBWgIlmr62Q5qA5FndnEIXa+PthUMrqiRfw6SAodE/0cQm6UOirP5swuMCrEOjvo/dBVSA/79KvCgSBL9M1E/TwjUag/e//2WdPZ2TQ9ZMvfPxRD7aPpmOFqXSPu3pww5B/wR00wTgVf3y6dXW137ffv3c7GNj/icJG+4xvYQ61++CZOVll8p//uXzgyTKg6m/1L47w3cAY8EI1T7xvgKbkr7UsGBJPNsB7xL2wuvd5z3svmDmgipcGT8jez8oP0R6bNYuVpUxRn9LZVkqIijYxK7K/dZBtjH/71ZT/1myfz52fVm2WBfk0vxUFj+Vfv9/9plbfz3yl6VUl+flbNijrpfpfz5TZSGRKAI15X14pSt4vwQKMHOTQlKifz1sKW6A9u2A7R65waprffGcfeY/8iyUsFh3rn4lGERMUHJolveAs+PBdb5iZFuX8S8SH7Ekfe8Lwy0t5cLwsD3s2TzbHXa/478nLtNQ6NtstW15QvaKgr25FJm4vyXwFlPInIPId79dUr77fmr18BGdLHIS/mGx6dKw64L7v6k32XMJrWl8ELA3C70AAAgAElEQVR42gTBCTyUeQMA4P97zIx3ZjDvHGaMYQxjhhm33BGTY8h95sodkaNkXVGhKGdUri+SIxQ6nG36VUhS0rnZ6tsVfR2ibKlta7/d5wH7kMaTxlOVozEoHgU29/ayNC9YlrZdyVT+Lf/dAsDDc/xfzX+MLBa2LK23goK0aXhCxZ8qIAdXYj+c8zviDOtRkhEtRxNajHWLuCtdcfQqV2mgRlpDD6wJpKpBrGON27qa4nNeQOU8ViU0pZ2eCMN5mWO7bfR17Q9ItpsqgZJNJcJSq6cSWiV4q1zIDMmkqzAdpqT8gI5G3qm3YEyliPPG9kiwF7P99ghNn7zLs9EXFvFdLmlOdKBAp2ZyGTcI4JuBPYrWyGCYwgFwOhTmHeYC0zEDSp1iX3W71cqoW332M++OAYJUrEySVX0c5lzmDgLcAQ1yFVVOgQ5l+j1k6TEBidTUek7OF4T2kDYo2eVGwOrglKyGBXYyBrxFv9ptR16B+BJ0IFCsryJve0ZEuzNjLeEcw/0aK/kyku6JW0BiicnCBFptKAQRRNRrtmUV/YOn6GNMHXddsFf1YZCHMnFWgcyp2gnLOWTTBcVQVvM/FTgJAHl0NWHHzL0eqzuRXTDCEO03DoThV3kezhrtpNqKW0Bb3MSSAJMmmVnLEpexS8JrmYOr4KXz1cUmByty3N/sbEzBSP8tfGSCJ3caYDhymsPdGbwO4HAl/+PYDCZNf+H6kofkNk4N4Zn6NM4y1lJD7Tt2gyklnrR48dgbfHXgd9uzHvpamm3wKhcaLcawXWxL5T97dL7MeW3aZ7NDWksVZyZv8VQyjm94CDU7UjtbedqOCvB2DdE+wFC6a5JcEIgkKRJ8cfTGmW/2jMS5LEWWKiGY0BFaDNQ++2+sOifPMQ7CcHeFx+PPpcbzRoy4IKmVwHg/1842BwoGc2qlRVoNjCF59oXsrcBgVEP4u1GIX7jshIMqqPdbGTRJzMXcyyyiNG5fr5qFrUVntrktt4QdJugkr1kzNJCK1roWpTraix9JVMpZcsxGYsJlGiSyEgOFZzHy6YVlilnicmxUVkdX/PetzMBk92PNJNkIaLhmA30XPCrMuncWxOZK9kpLnqpYOOsLFFmaf2Mk8OH+BbwPH7HBX2KGI0Ns80gleH+Y6k0YZcF0sWgpoJA30BBbG59XaKyBHoxFtc2p9sFvyXqo2v2aRKN+1HLPshCibfZESAESYsLXmz3tT4wNMp0Wali+VPN93JIJaQ0AcXGrNMnSS0YASPcaNh32NhO0sWHKPhrNVpCBzyk4EWR/PnmKE+3s2cDO+YF6OddPNx7G4AIrZBPldw6tcss4bqzb6hBy6ccf3YaBSNRBFELueRFp7DXWNMFVAT9J1LNTntEyEI2gJS64oyKMKvSRrbpPQGE0rEEmHyqCl2oQravq51FwJXG0m/pPdRA6Xp3sSLdwGwNytaLg3g3VEE2eFESy/GijQPwmYPjwJT+bH/ax0dNT0NZAFQxyIqKzET00vUDuJ+T25QGCclaGZiJBxsjtz3YMZ0PPsq751h0ldwbZstMgHfnauk/7n1eZxEmYIPf5wPt0KJvg2V9bcYWGgua/Lvn/xG5q98tPLcGzHaac2+Cbs3niyPtGgfYgBT2OHgxvhGxzApoPxPoCOtUNCXX+ojW0ug7DOuyrOOG5GkWhaAzx6ZyGE8qbCPS1oxzPjcWSrG/ICNaNMKsra8bIlQVvmRQ/FY4WiHhnrVz/VfdOiOu6u66gG3NKogJ/0rGdbC+iPN1pbZ4HQAZODS+mC2z9dNBqSzd6mTQWKq+EI3fXgJQdqfqz6jY6Fbs4sWT/QkaLUOBnMhWRmSdrpTy769BcCql1UOmaqtFbDA9d7qEox8Lpa+TPXX+xm40jrB7EBK1lwu6IMud9xh7NBZCbq6PNN/QdTu0BVa2neF+s8b1dGns5tMGxQIP/+fiY60jZNp9n5D9MLm4NLWO2gXVG4xwDXHeHXMFEAITOVUGJRoBUwOV3miiTEPPzLrwDm74zFsW9zkfCASQvPi2RaF9qJ2HHWMJNxCHzDym6tNfXiEe28ZnjmHVGwlSvfgBo4afqcoTh4NNq7QQ1KrPJW+1uHEK1VvTghGa0DAePo8D6D1NCYgEPY239D/RQSUMxWJsAIi5KEp/3/9LH1wSTwl8/mfekwWyIhAwMPErzWxVSL7sFnFT1NqJ+Zb8hX4cqwyucXdUVkaqNeVL7abNtJV++aASn/d+Fw9qlVwplz4SqpVw5CBK7nq483nxbZ8p/8TtFwr8oD5uhq+lxfovd0x4+MHo1Wv14SJzqBo9Un1KCZ8NWfbA7jLeoMjnCcS8bjtKuxii0+0RPZlLS6NdhNKHeN2NSdCswa+K+aGFUTD9MLW9R7mhPT5i88TZvV5rWtuek07W/vBev9eJznPGkM8FrCZ53AB8+Ig7vKms99yRb5fpyoQssijTwz0i22O+HvjsjyGXpqseb4t4j6YW86PfJF2cnjmy8EKVF8sIomGUdVGBquOIDIlHsrgPkJEzw7KovqHB/kS+NPgs9nG9FkG1MJiA0GNwTyj5dRS0uiWTfSLf7jpL0ioLExajL/OJPkUbA6CIdKjpU6XrSY/6mE5Z1IDBoHX7tGx9fFkJZQPrPIW49pj9oUEykkiolzaein8mBh/C/0eAzYoFXHWJxYZWrv/ayPmcWsjfWyDy8ndnmPTldcJ05MaxOoIHWPcND2SOan44Wc1Oxyk59KHbiXwbrxB3qvAEA+Pd3zc3MkDFmxjG3K4ZxjHHfFXKNI691kyRLjmRCUmTQWnQo6XS8JNFBsTkqiRQpijalraTe1VPbpa1394/4PM+naUIl5jb9OQw4tXHsFyAoD/x8vmlYJu23hfowcTnJOXSMUdKum4IqKUd4HJguRiprd/Etw9K/NJ+UKE+T2v39ms2JRGhtNDxShw6kmZEdsr6fwVSzZUCgj/xK8CaD46MMqjtVmEE0DTPS7yo7so402lkAAr5A9TA8YbapYO+4tLHK+uBAqCsdrmkNB/tSNQxgrZRiBjhVSt904TQbBmEDW36UhZEwZN9TbWh1vtrLVYdkQKayJHgjO5aVftyaOhbtIVFjq0gImWcFJbXqPp+aGTaOzHzPptvWbli/tEz5BHs2WdU4y01sOWIdG+CPWbxSDnQ/KbYgddG1ggtPPUFvXeLdNH2EoslAveJl8GUVaLs6WWsoo3G2Q8KnvSkrNV13rJm4fF2jG2NKE3FMgjWPyCyVVZXDxk0WKQyzIcdGvhovfXwvS237WZN3PvX9Dh50V1CMuemc5AkPWBJzzlg8giqz/M3mICBajNsO3PSuByw3zV51gCTybHlfu/R+zXwVekhzN1C0gZCgqc3x8EUR5Mt8LndPRv3AbLnf2ZMLJ2TZBapthY8hSsIET5/vpH1T7/l1IKZl4pTp2eMVFT8J+1JyElnizM32GmBQTaTDJOwuvPCV3QDonD/6xjwgR6SA92MF+v+Xlo/BDyOZJpkM7QFh73uKxzX9hlDol/x5HVESyPM/HNyF6MwCg866UWXm9Jd2xsjrXyEKgjl11K41nEwzFzjyP0V9T87dStAustB/MkOwBaQoOCNG0+6dfSw2YIL2d+aAFbtewoPIATWJC+6il2nDFDx8Vlxg2a22oZG4My48gnrQEcDxOuE71wz51mkfvC3B8gjF04baNRpg6SGoHIAc+zB2Qqqn9yEzCXfpmpdN2kxdkiMQ/W/X7iT/RzkpBGvlGrx2Bs4pl3s8Akl3mRTsubk3x+CQH47r1ZNgECzf7IP0nV8lRUj1XqsW9+wNI0+oAx/lOGVsHcmalqdAqT/Rb+rp3wthEPxjXI6irxhTZc9U20OHSbYAJCX6MKHYW/P8XRlyam7KHfk5VTu8Tmebd889NmQ7hiuPb6bQu8inM/FOXkO7iEWd9hgyBVEErR+8P+Om2lFcXGp8DGe734LHfS2Pk7/pzSwPvdrkd7/NgVo0V8s5ir4NYME0CzGbOVoiygQKh+vexBN5PkUBa1bYInKhFqBi7f3FP9xdy5wmH5ByEL6YmlsN4H+lvQJBG8TSvwBmhcGUafV9uPlIYlkx7S81YuG+rzfC3Eb07PGLSnvKO1ujlkiGMoliWkYJ6XYpHzhP4z5odeImZqKxZT1hFN+arPz5Dw2e00ODXsBCGrf4jB+45ZT7UrN7VBRUYgrUJx0WkxNyMCSxRCIYwgyqxP8Zv9VC+6aiUgB0eIt08YI0fh2ZFRqSilUuRRvmt5jejdoSCjfaRFSca6RXh9kVAjX/OeC8Fbgdo+Ffx9K0zF8p4sLEk27kG2vWNThL82M/h1BScI2Kr8fOKkYdh+WXxAYVPhsD11sx5SDIEyx5CGwE1cQ3osdYdlEP3/AZPwvH8oc1WdqXU/OM6fdPELtY9JRSNHEepmC3ZWgsLZss2H2qwq00xxA81SAexVdwbL1ektQlJeVMZAGObIMXLK5lkb95dhjMzkc/Lq17iiAPa1uAovfIZZLe/kaNzRCUCr39gjN5YW18DwBEKdQkVriaJc5BKEHi5s3DEMukQIe9bStXDHyciJ0Xv84FSgb6OW6WuhFqtyjdjWTw/jt87MnpqzC9LTP5d6vqhMo3Y4u6dwfNAzL++6ah0G8ahltlcWiZPeGtcG104UJ67f4QMwOqq/jMIFw8leQ9VsbOhuOtjYqx9cXIaiBcng3fueAQPIz7hl+NJ2ltWAECQIyl81LAaRwlbECUyuuxtH/i/nb25kFilIsdm9q0qzIVxbO2/dyBPwsOdwI/A1NIhXctIgDDfKCMOLIhEHXE0TYiDRDEMkzWtQ9aBbO3WRIhTdI8MGpPh+xE3SEvZM3TsaSkSwo8aIp7vcBPSpNIUWc9dx2ihGIUfcCMA6h6H0sgzlYo2LzwzsSBG/vPLUKBRAIDClNo2hylJMPNHUF6/FyCi7vsPpUBU5f1Zryco/9dyqeIEYzdzRL4fhRqyDTW1lv0jlQjuBtfaUaKBPI7Hr/G7RcawKWd8xytCCHq0tGrABFlLf+tFnXvcFRUS9SdsaU+DOI67yy47KiS86yVHnkbvbnhw7R5+QMX6efQ0ueOVdVkKZ5o+0GzRYPc72WXnZ220/EEPvQ2mJs9umccvaJ9JQDlWujkWdH+bCuOl6OBriPwtt/6D57aofIHy0JVbraWRZDo7xiUeThF4JL+APjur4ftrBDOoDbMmJGGRvnl0iv71YPgcPgMSa8PT1ZvFkRgx3zPM6BFff0dTJbRNIHNd92hlQTTuYNVd2W6Pu7Myx+NgVOiFPeih7aHHc/Dn2tVtPIQZTLWhr1BSVJzNpZo72uzoDQW1D6KG7aCPz+193FdMxFtZ/hYE8idJqfsq7jHo6USnTep5tp8D4LWtSPqIJS9+U4cc8Ym8lJ94wuv8uj5DlIsflhtItJUoeNhAnkdEmUMIsLbGt6thjaw5suLGIwXg96aII8ttrigpcKpcdmqmOegLraj5h8AAQj+90zF3YhqscELTAFaWZuUAQMThYiUb/FNHAlDUttdbQAyP0iCmwvBlXj3bwwGkEZxh7Y8fY1TB+UUdVfjDXKAaoLYaWGWCmVzzxQxUQK7wSFq7btNyjcmKx2vXgKNSocDI3W0q3gacABoST1YfO0NC0OZ3VJ2PUAwXIcsOj7fJ6GGGw3hkT0GAMOIASUuHGB1NI2BNAAuhQtFj2vT4FWOBwA8AZQCJQw8v+fPYq97G8tFNng/7Ieg+y8KHAcI5wACkQOUMBG9bgUsiYNGzPHqgpWonRw8Fzw7aDForw4oGUkSvQQ4H18ev2sHhEVc+aMCAykFFh8LmGKQVJKhIlOdALmkAKIDBkf5txoCxwKdUAz0ToWOJaUGAeneA3pOjwFyZwApO7V3akpwjkl8oyOFoQqEjYfUC0cBHVCoAzuMMH42EggBKSJqxhsQWwBEu1doBqQKAktnbzMzwTSck8w4yPZwGjYeKiAjDxSHIz0HE3EjHAUOAk5RLXQHqIsOrysqUAHM8BmGZRVNw6Mi1QOeAQRaLLABABIkQAM0yABTbYCxYAC+HWBJ00xdN0r3YZU7ubbjAi0CrjFHxLMzaNEjFLz+4ScStCg4r358a5kbAtifbaHcTY18qVrMIdEEISdanHgWFdkBnM8/SEkTKfoHaS1aNTmZvNwAflsqqgZLAjBXyAMFyrIpbAVGV6oAKrCcPqAr45KYS/sfi9mObGiSlB0D+wALckOOCGOriDK83ywNfxUfTw5tHzwDGiJaJ4SU9holF5fx3X6qZhsRAQeNjT8E/kvHIKvUY1sAUZAea4Onlj9sE68EoEUB458HLCDmAB8MIw6JSiQAN73SPLEOfGU31KMYEYrTousmiyRtBTQ7ClaT3ANP6uFYKL84ahsIP6ssogAAK2ks+AYESgB6V3UYAypGWgKVqngClwwJ4MMim9fqCAHJWh0U5DQ7OVAdSk8dtdOMDCrNkgSBo/c0qyIuBDEFbkh0SUHxE+47GQEo0sga4YD6zesDkgAXwjKzLArVShiyFFWSYXkS3iSlNQsBUb4kAQKUESNv4bFLCMoBtfxJAAAACsmEpW4PjIM0DDK2ZbpZmBCz6FoZBgXsbtnLKab9EAxgAVmSeUimBgihp8IvMSfWAwTyz2AE0IhEJxVzmmrwNT0PncoCGQXQtXwua50xk3uPDI1DfqKHdklTBVYAioGcInu/CGIX1GcrkE1cTAHQHxBAprY2Ib/AxT4WBxZveQAd5CwBQsaMPgkdmgYbVQpqCW6JAP29BmFQDW+aDAMuXCMvfT9WrGXn00cmaaaXZvgDOV/4nwXQKgfTiEmisC6eemBCMrpfiElpnHRef3auBiVEA0qLWeFLEAUBBa5BCblqmQV/CgAZ1UEFS2EgCvpyuAMpGyc9BVooZsCBADmIoACXkboDAEwGNNmnABevAQcGNhceIVFDux3uWIIEPQAsjr5l1g8ClQpMAwJsOVsOFi0Uvq4cDl8PEVl0AAdaC6mFaVQiDNeeA9ECv47hpTZ7Qk1VRRwbdRax8vFXryTiYolAIwprBlZ0pa+KKl5wBU1lQRMCjFIw0l0YdXYDC6i9MgDUC6kp3+A48fLH86hBDQILLQBhZJ5hWwInm3QIHgYZEWvbV70xWqoFLAPERDLK4HM5/cWVKbX8bAMEE7o/Am2aue5ZF6OcLqqvVu8EC6f8aJbYBZOWXW5xKyBANEqjA6AskyIoAf5MBQGnKBpoPTABR+0/oFUHAU1VAKsOqV5NYgBBHwZZh1rUncwDCp7sSWwDQTYKBQdpCzmIrMgNN5QDEbEvW2QFgmmkKFOns0WDQamWLPHDNVGTniIfRQ5HqfKsg8Uue/ER8pZHd+ebUSOm7KgF63WiTIhrWg6oJYgEMYc0LhWELTvncXdcgScC3S+BnrjLYYsZK1PXQ4GJZugCuQAClGncjGcMCJwGMHx8c7mRwoVCQAMJPQO/MQBbcs68Zz2lDQgs/R85PVvPAzRJwGkC7MYIF/UDBRoHd1GhwYuAEoXDO6sFqIIUr3wOHGmZFK1zH11Bh8iGFWc8HgEoQwXvQRxHJDEUBTF/AplEfWUmWSMJpiEUvAcghlFGEQtETwA/BxQAeDBBt1IYKa4cADo6WpUuAAMg0w4DBroB1hgTiAJ/RN9REX0qcIM3Fb7b2AEEm+mOawIEXgFg1ne8ByE6fvMKVpI3IjdsAQETBiWUmjZGDQhjQTF8FgldAgNRNiACM16kCBXhkWoUp+4SP+hEEghL9k9wZjlmc6scT6cUqAASj5U5aTAbAwOEl3ICCG25JR4ffsEKYfUNKIkoY2UMcAkXDqEhrGQ2b2RrqaXjAx81CAUWeXVrAI4mGDm6bXtoAwYVMi4GSk5PUVtclscH8gIhvXQ9UiUA1unQH3gHBwkwq/5SRAaUD0GYbE0QL2MAiQbzlasuGxcYAwE0vhmvfgAe3CW/9BQfAiZ8Tnxx5COM3BRtf6U+K/tpYA+lJQO+LQPteW4WmCHRYyCQALcpWAIX8w0S5CQPI1seMBmCcEAegczCb/8FJpCzbAWD3H5NorMaMENXbcyM+SqnzMa1KAA9KRESUQB+C5mbhqFe5lVYhRtCGAK/a7AxcRIgu2O0PwDuLixjUViaEgz3FA0zqDci2tBRCSARPgRBM/NkGRlZeCFnHlEiyaQrgIgQyl66REcXNJslVzwimlyANCOKfrhClEyKOdFL7hiibMlFBQQg1jaLPAADCPz3BFXbRsbE1+oiTTkKCl8XnvRMQbUbRUgqR+ICSw/lJnACx3kIAhaIfB8W/BnkAGo4MoPAYEEA7RTnB5Sg3RinVnQRBQYS8wR+CaYzXT07BdYMDs8Gu44ABtULIyJHDl9wejIEAGo6jg0VoCpEOI0/YewzCgIzcEmGYDY8+rhtRfEyZQblSwUeDSI/X7sFhPM8FQbc4nCqKe0BtEIkeVqJcscyajxYOUfpyk2ANDYfAOmZD6zJTRSBDpgL/N5wnUqyClKcYB05MI1UBooALCvUhuAcyf9sJiv8GyJRzX/IQQCyC3ZBSzwcO9sXB4AIlRE2vh0HBpcF5grsAQPnqAA7obcALildiZ92TM224bdMmAwPQINWrPd+RCgHJxgDfwMv0YKRlEBHJnpxkJytDXXpANUtIEdWWmUSBAcJCSPkZZ0GEy8MDKof72cdh+oTQjqaLH0McSmDa3cQnJ6lQ0N/+aitLGabIwgrEzCvmmp/o49p5V0GNlRLPRbu2UehI31oa8rgCQhEB6mYuZpU0KMCA2URBW47L4EFCEEgFz8IC8xlQBN3t0iRJY+oxFKsIMEPAMBxbQZ5ChYjF24zfKVBA5UGcHmAAsQ3Zgwn9mMueQ53L9/rahkcB2PJEpl5AIasYhP/UBsSETYp00xgawArAIQDBEgPegICAY7xP353eEuT/Ty9fCWnKMRFNQQACMlLA661MINMsM2jlS7bJr8GyFo0bmasanYGCDqsgIONKQqkAGeBYAkHowDYzhhEM59lCAFQLOH9SCzwQAl9AQZI8AdUPFsoFXJbAAEoFp1vvyL6CQ8nDsdymYQNX0B+FM0EBi+IBmIX5R0i5ed+S0/eRBB2EQBmGBUDWLTLNyEHJKJOPiJaTmkSDpwQNgYCGQqA1LUHqtAwOYMi/of0CMIHTBipAIYEO2MKkkC1BQPDFD4Ax8nmll9bNkZ7bmwv1wIH6qkQQndEHQYPeXxUrLUnE28cVsctUWoZGjYVKWe9VAI7RFHZnmsoBWVmYD4xTWNtGZ9wFawr+wAASdAIf6sAjAbfucWuRAx4jNliQHDSAII30QYUYqZ4xSGTct2+WT1bCnw+AJcbNXKKSE8ZFR+fPATWLFkeHQcVH4CxT9sDtA1cAFADBk8ZBBaRRpJovyFHBAEoMwPaXYvvOh8bfQxDvxShtHKe4KQeeg/AXhcIJKBkjxwgXgB+PCAtPifdTwusJGdXJibqGQzCPyySkBZJpz9En7iGYiCX83wDeQbt1TdkV6IAAGxhL0wERTmBBzESBRUdFRMctnmVblQLazgBAsJXtHhcHCclXRoeywgpDynhVqyFWAZBYTWCEviIXzaHwMxdN05xDT5FAwDkBC0TbBYFo2ssKCNOTQkodAEG0uYMXix5sMvSBZxfQ3Egc5k+AjwvJQOEN9rFpuYXv4oFPCULWRr5AKprOYWuCATtAAlKBrcGkIICAd6cnwxqtl0lfz/5+hUR6q/mHdbFA68Qz8syO8Gibp8LetHFNF8tRAV0bEYORkJhTRQFxAMdPwUJMicmXlQKBmMsZwKoAMA1DGAAEQEnMhcBtQZgNggLxcHiAoCFFYEMAd91E7K+4vHKXBbOfJrOAG1E1YEkqxGsNwUr0w0pR2MitIQ5BlqXAA1atwMCSgBYnTuUtAxxNg0ApC4fgrhL7D5sQQM+pLcGg2RmHwIZNZPGC/cI+3Dbb8WlBSCJ/uO2txmjCBULLyHgqeRjEBLnACxYAkBvBQE2owNsMXy0kzWqADm6Oh7HbSK2kQ53AIoKAFWwN02IAuhiBIQgP30OBTUCcpQr5T2fJjB+bUd/2g5Go9sMv5CrnFlpfAWsi+mamCLtIz5VFsBrbb4AM42rGna4cyoQ2eMO3z8NN8BeNKCKBQp3jFrOL+zqP9WWCQukQGBjmPsTAChybv4zgnVctaQ+ynQlaFQJtTPSxEAsRLwRAK0pStgs2M0EBQtIBmKomNWHKHU1uDIsAg2kEHvlUc5/AgICJ34VcpskFZHSgGFydLhFCo6nCXFfWXgIGgY6R9CKIkFdswK6euK1SRkYAxdXV1Z+9UWpQQOzIqloZy0FIoAZfxX7FAEasEKHC04pAAbnGP4CkFFkEZniWC3xBD13ADNArAFjkW8nICQKAOvmzBI8y+QwMBUgcrY0WJdtSxl0hFiiptgP3hDTlmpdVwDTCwZ0BDrZS0eTQt5GALQLQQJcPsQNOkguZZwCIMTEeadTAyR+ijoz4Qo4VzZZAAAlkSVs6VUcZJepUq0Svzx14BNIbWLpMC7XFJGvfVpoWr+cAI4twmWi2I9wqgwAaiwDPtB9E7z2SlYSA4hvaKQ1nAZ/MnZ2kRZ5P60FIq16lCYDVwVsKAx1BqPRgzsOZvKTPIoBn9kCKTDuDtMFqtp2nRYWNRw6ZBc0MvZ2DYu0CLhiWBeCK9jSZwBQ2CySAafnVwKo3rdJXGWGUQv5gHlWsQQUAFUmWXi4AQNX/oqvEnkEUKG6tlZ9QkzDT1jLpmR9fWCg4wByAi0AWeNCBgYJ12ItvmMCNwrVZkYzcU5GBs8aT0XcqZ04IN6FTgQuL9dZDbIa1W0ER64dUb07oB0eE80fZ8/do84xBFGBcwGbppkJq530TW9GuGMsjLJLNAWrBU0KAKYedUoDH3QB0iGTAE7OOxuOVL8BIAMPUxKLA7HUBjHBHEQvFD87HYE40ZqAAXEF3+EI/FQAACAASURBVAA5VAcYSqwlTR4TFY8AFHwtHQXQhYMABwj490xjbrxCQRY1FA0MBmQdfy8KK5JQK5jIhiNb0AgjOAP7zB0TqcsihQUwRXSdVE4CD0RhWQx6EEYLhhYAeoE3P05iEwbgIiTEHEUiq1SOJcmGFl7Xv0dlavCgAliw5QDiemOUAuaucf5lhTXGhc5AoiqoZFu0WZDr+oQYAoJy3YAB2FsNETiWuCXLoc1tIQasfWYAMgQUTgYARFslHwpiRDUs1hBRoB0bQ7+s0NKTRd1E/RCeHiCeUK9JN5EAdJfznAEq8htHb5ADuUQCf8tY/UgQKaRCDSYrhAiA7UateS9WPksK2cYTfUrVpCTmA0SUrFBkXh0Am/veTf7P7Lb4DU8aKbKXz0zdwW3XchzRimAwkx59hHaKO2GnMbYaFW0YBYkNxWp1SEXiNNCm5g3DNIMgtw+ShZNpOpYq/Q8AswmkIiOEHX99N+JMMAC+JKYI7yrXvJWhZgcNbtz2wQA+bk7APAHTMxnOjSWcrcbzX+OZWahITJEaSlVq6X0QGs2kD7jsDlU8ixd3KQOKAgHdAVMANmNMOIuMjEusSjd7Aw4HHBUmlmJgCkxWYk4Veq5jVQ9CFDiuddoVjHF4dDYARDwtTkEhkSROFdWSdDsWaCj4BExuaA8OTiCxBNJIORyAAoMOTk1iT5wDLiZJBrs7VV4uAKKQCxESEKAfymPGhzOP0pVhBGA8ol5iCxpyOoZZFCJJRRXFTm8sA7PfEnuAEgFx0kBskwNQZhyzMLaesB4SdgBuQAKmhMetRhYAICQAP7EL9S9J8rk7xDAYgIxMIlDWBG0DAW8BYAdGkayHGwwrAi4b/r5sA0rCezgdXjtnijaFR5eSBAz/aVQ+mggCDxmYem6hDQtN369pqjuUEgAYD0BSUCT2CaA0BkkSSiDM6jOEQDOFjTDiIQAVX1TPI7bMwK6hF1sFT16bBoFTnVAAFcgndTYODzc/52xpHRZyNxDDkQBPhGMNhklGAbYDJLs3NFGGnC8lCpbuAl06ZWbRM0QQJgfnBAVVCyqR6L9SLIHQDAVNGpYiAIc1AJk8AIAA0TfDOzNArLrhf7hEtVMnMAEBCT81VCmAL7wJ+AKFpQS0Xx0tbQDcQgEJZzcdBW4AOQB2yAAFEeGWwhWAatIHABBbsCfCPlQAikYBjxdYEHgjNAUNL8OWdGkAXgMfOQDJ05gDZyTItT4pIibKF7+xXSp4Shfkxy9Vylsra8P4h50uKHAGw0KZJbkH2GZs1xvMPI3ddzg1sNxcsWHdA6IsCN0GeRJtVDCuDUWwaQAlQj0Ad2Ca6wMJA8+cfEoKOwP0EoXGHg6EdQUZaed7cUveOVMeswMfGy++GDwFsSsb6S9ehSIqVZF71JbZh6LBFLIRDiAACUrQGh3yN1sIIYIkUOeTKl1MTeQYCiMBFATQgh+ynTsCSAOav9AxNUF/AClE0gY7BIsUJiVNABBFJRT2FwgAslkF4mtM9lMDI6AGHrsDBEMhcPQBAnwmdg8o7YkIzxJYkJ77A35vQ2M8AOfeGivv6N1CumQj+RUGPQOXLeEAqgIp1Ig6o3nGdRl8PTUJyQFDEAJ/KNdr3gkIBywcNHDoiAfNW0CHClyw+AbbsU+ruOwbBAncmpU0WePmFgtJd4UAHD+zLgBSQQAugirUKWA8ERwyAjfDPLchDh3EdJRQgbHANWS4bDX2QWzJ2mJZh18YFTBxVgJsBe9gFSoE7VZXKLlzBo5G6q7l1hLxmQMMA6MLWH9PJUb3QgGZC4SBAx0BINreFj822QBjNwMgk00EK/kAtPUvcwxhc8cPRQBSsLgAbRwSGiMBLa5gDN0OekNWCnc1aV9sqeReuiznCC+PLMjJAh4xhq9iAwgOI3IvvyBg2TibaC5IlpM0Lkp8BdcGL9/LB3D9u3oJVwBZDSkkPQIITsjVS5NtqzukBoSUItLaLUeGQlRph9bxmRwAOCK8upGsTd/aP9AhFkwjBnErDQYAAT28k+5LG8IaPTLcvCciEHIbDW8PS3F7ZABuCV2xjgQ+9MHk5jktIvwbTCddCpWOGVBD4QIOfa+MURkdX70FKoRNAA08ttApUKfTq7tHm6YZAJYNRtEWHxgn4AKWIzQrKipAgSK8tk9aOQpky24DUkQGZnVQoRUBP0NDRI/UwgIAMfAoEBSLZDEgLRO1Br6SV38EF7rXIx/JAQ8E3EALBQcSgN0AFFDXMM+Lcw4EFpWDb2knRW/mRYYdfAUdfQLwWhkUCJQyms1ksgTMpHhbAHil+gEBS7anHDTwiRpCrmULHlgkaWl2VL1GDsrg1apysgeLQcKytiGpZUOcDMqz7zAAQwIiuAc+MjjuBK+JmoanK95NcXD4JyZd2Nh5dmU8IRLLDQdeCTYLvtBn6g+P6dw9JTYeVpoGi4ogu1N/K1HYkQC/YBpZAtrEZABeIfY1qIPPzFLFqQ4DDANRwxLNOQFjDca2WfiWsYh/pDePNz8H8AwduiJsSFkTWQRoen8WGw4Ahh81nyQBP5AGhR0E26ZwQ6DHcrwHTrJhA8yogTgLH9PiAFsgFGUJZgB2SLsyWzN9ASa5CB0yXwEJCam2WKEPNT54YlMBn+0OZwAdDwgEA9SnqxNDFoEDQT0NGaOFEHRADFm8F23JWUQQGhMCArWvLhNCfHChBBcNC6QNK40boQEAO+lRHA2CUxLhZyStpJ7pkDc/Cj5S9VMYHgC1PkR/KyVZmwEdKqJACDEcjSYbdxq+AKHVJUhxUMLPdHUdbAACCP33H9UAA8AELkYySGs1NZFvoAsnLu86CBTGMDtrpS3xOIHVHOVVSwUjxA3XFS3diDMPLbOzB9k7Wc9QwVJ5rhsB6E8S1AAGLXom2BIGMhblrl1bFXIYjQSmRiUtBVEKRbNsx4GKS0NiJC+HPpi9LQ76mjyf6OVwqBcGUmYEXgMTd2A6HWqzv7eGEQxBjkcBU/NVLCeshKpDLHJlq2tKGXeSSwFCJS0yAwEd0QEQYULiWW5o1uMgCv2UbVQVInoFKCv7FzYEEgB+31t4HjUs6mheCcGtRwxkMsMlBBHf1b0ADh8dZLtXOJM2kDUSjgxbWZmpAjISVgRbC4sCJugEjdR31gAp7hMAnkgTM5YXSQOZPGsHOAKwefkwknwPEBMqfn0NhJUI15ICbM0TWmmseAWuYeBQiaoWCRAA1AKbxAo92wPXEUQw7wDfnSIrnG4CGV3YXaBnPavwW4OXApQBfZxDwQ1iC6MENCEJAOKZqDFUARg48iFDTDLhNwWjqH4WHAE7PALJFQV7EwMBmYl4Mx4WDqsCAVgA3AQC/Ncp2LMA2aotBnxeNApPDKe9EVSiGS9JMEtKwJUIlwMUDac5oIEPRnapEikLMwAhzQUgJ3QiA/CiOgqWe23hYA0ZAglKDSQZOAEOC72KBJoavjfOPF3IWRciaEYtEzhLKwC2bklkNZgpRwI6WBtPAw+npsDsD6wU0TJ18JCbBy4aNIHPCstFAhRbFzkDOiYSlyULWoWJuUmHMaMPQhe5B3kbXkVL5bZfW0cOMzb+WAAAkGLfDwBkZAAVpGI4umrpsOchSIGKAzcBIjSXoBNokAlDLAFxFpsCbPTQTw5xswgtiyR9QVUGBDzWTAaVDqEAbCsATiO9za1IUezkU2NfcW/LHFaJ0Z8ACSpJVAV9AnL57hOjBs+jBFaPVyvne8dqLUfbF8GOEKVCDVsBLgxdJgBoClkAqUMmZS9cZrUUCgko/DTSHhYGPC75Dm1CIhnzGV44TgJ57DncEMTOEBWMAIEzFCASqi8BMQDtz2WwAChwVFEFYF5qEVJU837Uyx7fUGxE1YBGgu1N0nEsGiYBARCJGiv7nw4CCctmfyoGrnruhwzdwJUyHQMCWypq8T6caAAE20uVHZAlymbvOgSEAwDthEIcfAVjEQBvBRkXkhxrAm2ikI8RNt45FNuOoFokRRdegaaQOtexKJK1HiUAJWEDJgZz22IINjqFaReWG/QEzfsCRBPGyDdYRgcCrzIksE9ZRSXiAdKtH2VYAuzuqgMa3rADi5QGUH9vDzLeOQIEWwAJV4ubXVPDh5EkEzIVBjBkdMcxmAdVxQcDjxzkZr7HeTUzAQ3p9AaLaZGNHWb007EKkvOzc+9NfzgpIllL5myLFbQLygM4XgYF1J2Tvk0uFwIOEtlkSmFFA/yLJ80NAoMAXcbeHgxwl1jcouxbixCh2lPHTFx3qtaG2fp20wrwOgAL5yMrCgRJvQQtg38vXwf6doIW284PZBpHpsBJPzedw5AHCAEMS7YabRQzbkW6L7ndADPqNCkhAZiLdAMYfiZIPOYjGAwGD9Y6vGuiItqzLShPPJ6nT1V7ZoqepyOwL/dvFVxifBwAiHaMARYTQUxgAgACKxRvBh4kjk4AAwUq3gAAEeZC8yAMw5i22C0+GDtgBDwBXg98AwkROUA8S8YCBF903leViZjUa90cdTEOBrwDXHw1Bg8SIAD9EsSgIQwFDEcasGfBcl/3AGhtMD6YjLVaO7gLSl0BA32wU8o5AecqKYOtbh4BdQNIjo0geknWgXWS7wGzHxZ0A3NqHQEBcwCtNqlyt+c0AOkASngGAApBSYNSsGARwxoqz0NA/ggLh2AmkXEAlkauySUDu3QbBNpQUzkdYm+uYokbAjUmTZkCjHh5Zg4uAQ1OY2Z3mUl9vCwNoKYnFjSlbmiP4RmPUKK7eZ0DPgnn0ZqDmJDuA98yAQ+aL1PCSm9NBjcyE3BMmwCmEOyvBOilD8z03gZJS04dEK5yxwBKUnLULgA795xy0+1MXWEPe0MSTWdOSllnH4JfHofxViJmgMVAnbIMYSY+wAUMGScQ1g8AYqARnwEBAwBI5pMFeFOj84MHBNMeuweIjvkDExPKh9omslGCSVgAiN7YEB44Qpp2LiBjPdarEADOBIQdaOdMeA1XMJ8TpvwQ2tGMe61kiAcdEAoCrtBNJ2/Rhs5WfILCBiM/lIG64B5EVH5MfuQS8x03Za2ACu7cEw7NMQ8fIgA9EhYzJYmjV4svwhdqDI+guRTTWvBAXB1UdpDG1QI4DIY3NMjq48cHAg/PbAeQEFlY8rE5ClIACwBx5RxSJp0jQxFhGENVSjUQBQw2iMOKTHxkGjWS9SnbArELcrY0rwyMZT8ShykQV+FwUJMuUgaIWSeyRBZdbRACRCCiiSAml2AEGGImDUh7HGwsHG5KaxaGKsADQ18qC6KJsaYtDUsAATMPnDFfNa8EAH09YH2HsN5GykhFWAxNkwAGCSh0Vh/nMSOlhmUY7RVMBADQmDc6QPpXOVQoBbAMOyECuunUyxPgsQ0ETnBwRXQBAD4Z9IYX3tRMpbUBBbEOtydiCAIYue+9ssJjHgR/2AeVIIGbAmlLYUymQyRwZQTXBlCWmgNl48hVM7QSIL0CdJNSu2lFnk8fiZUZPRFODQCEH0ExjxJKSHJHTWlhSvJmIZZqczI+ADBfRQ6D4Q78UtkAAwsBw2I4MWsZlxhDLwD/BwD4WAUGCne4shiGGyeronSUAQXP5UkAOZ+BfwIRRANQS2eyNSEDcP67cPQAAA5dPwTl5Eg5FHSFGiQZF6BZBxttv2GoyEQFB0xSNBUW/EssG1aRABX0L0oXTk9w9P/nm+ZVMmhBQhcIGxhYOHHoHwNzJldxFQB0KHapYgBDkY+WKIQBBS3cJQYOvmYAR0qKAE8GApuhVQDTKawrE0mPBQG0gt28GoU0YHBDwfqHHhjbkDpoSWVWA6kEs0e1jAIvmkyegpM6G1IBXUzELwUOM2kAISwmADRsQ0MwYxeYL/A6RQABzliwKBgSK4MIxgogDTzGA86dDMa+XUMCLkazOuVDGApvbCfg4CQac2iJU8SvkQMoMrD+PQICV+oinEEdBm0iJT4MyAhTZgFYEnkWnG9xn0y74ilvXe25Jbli4UIJQAJDDjXiA4QDDSiVdiMi/rXIbh7VAPAPxA4UU/bFj9kDQwQKkZtHAlmRGwAt1n4c5uKmg4kORgd5WBq/V17bNiFuAu4AXIauVmwyb1tJ3gLMkljMvYJpCGEM79RBkhofAX06o1gaLwLwTDaMDQEFuzw6UlE9ASVc4VhyijlwMBC8q5TXBwY+MsgHe0VJoAJjlgAUvh8zAAcyNgUYl0e7u2JdGR5GbEOPBQRZBIQBZnrZAvJGzYKVQg8nTwskXgRp1hvgBRwEizz0V35fMqtosBADNwJ5EsGJBAriES8rADV+1ohgBwcBL3YBFAiISgIAAaiaHtpdDgh2Oj1Dg8G1gzdxdGkYQwW7CQCTNDW1GGtT5qJptqfhAAM2bhqP/YwZCWvDU8wVZmt9qQ2yMo6+KHLZ/dslAgWy5BanAIcBnb5hcjI7WBZ6AqTuASP9LHZRiHh0WQ1dJzgqMXGNqSWF7duSohXEqt3EAck4ZwUVVX45ChZEIBYeFnpOC5wPIwA/Gt0cIcKsoqTJPZ1UTRMBWA9OMqWcK8/YAIvfnzBhEwXifwgthgYgEecXBAsQZSVfVQ0ER3w4TgE8iE6ZEIwoFTYzUwGwt2El03Wp4Q2IALsOJnVYBGZdKCUBwQAqAFqlQEZJRbtrwqcgXlIIUx2NcEShuvIBbgq0XVCNBAKhUT4JQB/OBgqIf3FzY6V7OyKAOAoBASg2GU9GAA4AfSMKojG0m5gyqAe3MXWTUgDAAgxFtBcbx3gCmAYBRCEIaWdBmXYDgQdPhQMSeVkjt+IFTuC6Ij8N8+cIOhMxFvN0DJU7rf6eCTpJ9QNR1LoQQQMgEY26fApxVC5HOGr9sKU9GORpdSRjAW4rUEs3GgRFo9IJvYmKIxn3EuAwADMMjc+dCqyePSGpQbkhEXoVHwb9SJ5eMR3zbXZ4JW2BqZVw2l7pIXRrAhSAEAVRS84yK4rNO2l2wNVcCFW7FQwbADpohDhH+ALV5AgD4rQpGReMQ9tkmLIzbxPPHStlIdXCbS1hCEj4yktcH8cO9QspuSFFc2sfFMjhw8WBfwH4AL00SwUDOthSQB54xEsG0i0ACE7WuddaHtLJZxcCSUEYrDRF7xRceFE3AC2x0k8HnShj+8mn1AICDQvHh7yrNLLpdSMBOF7XG0MIKTpg3XePZSgxj4EUDQW6ERczAmkHACMqRzp7jwLBHE1J+9rgGE0jMKR9eAC3iUeONakBJAvMALJ5jyVnHDpo4HcqIQQqJDKFNBhoGQpAAb6m34tpMCwA0p2et1pv9wIkr2yOkSgpxQLKc1IqDDsWJgQWiFnICOdG5B2pQ1FQEqBk2k0FSQ8oLkFGe38tCE61lDAABt0AMaACES7m5uDMWkOQJp0/Hg41dp5mhRNyv+xrYjkRExpXAACXB7ToUYIOVBcRGpltVbe8OYgfXFsByY4hGhkpkyoB7hcF6K0uvEqfZ3griUwBA1c/lD66CQFPcuK8UwRxQHrjeyZEa4w1vRQqYTgxzxgQEhpdGRUUHRNnf4vqR4ObYGCWlrtDMwhWI0ZhExohPDYcfbYDowruYrcukRU+j0IGABZOTatOWA6DbwRHWnODFRc4PImVa24k7ATGb0kbQpcSsL4YFbkgARWhBHl6vFpBPRSyVmOdTmIXefPQCLgLUWUpNV+MAwdW3p10p0eu5BxC504BVIXy9c4JWFeJA2BjBxPZAnIBVQAZhQU1ADH4DjnMGeNHLOhzGY0L6yQtbYoXAJyb6u1PF7UZ5yAt4JwGYldYBd0VembYLQBnVTpvhSA/ckID5KwqDCHKBp0YAiR0oOcfXFD5GQY+oUJH5JqHAR8UBB9QqIcTPwQDE/cukJsaOVIbAuUBaxEVKvd3i2+Q8BAfV8nGOwKY/DtMAgkLMOnoHpCTARcGXgIUhPyYDnVrAExDQSJ1gGIMGgtYAytm5mAuUxtoB58TXTtv6wUAa0NdRSmbkMUEc15QPzEmWRQCSiw5cA1VoRQfWtxc+T0F03kr1T9b7QirrbwAXiw9TpIQLwMRz1BPIlLVz2C9KLQez0US9jMGnUkwCDWWKKWkjQlmXDZjQFxL7nsoey5VQwonAARTHV+7T2o2FlIjAghKc4pLVFWlP5YBH+iWBrccMUpWvxfLgF2Uc3GlpxBgKSA1C26DD6lECOuPBZ1vBhzxaoJkOfOGBXEfH4SpqLmcqQgHLqpA2FJvoLGFBTTtEVwPgIAWD5czgF1YKwbKK0omhid9pnsG3sdBFgMCnWEwrAt/AAxsDcl3PWYuBXYZt/VAEHZFRyu9ERMlZA7aGdcCBgAJCPb3D2AtAxKrHCcRQEh3PMxxSgZzhpKkABTYngRSabRPLwAEwOdIZ7q4CXUDSQBW4y0NAs3GAJEzApI+A3ch8L5wJxDHl31utHwtomsfuOkYFHczQFQ9YpEkspI90XQaQREGQDYArfYUTT1n+WnEVRlkMK0YFEehewNFXB9Qf7NnPPRJozTB8ggFWhokACEeqsVTFD4NFOtfQSlGkYutE1BndA5zBjM1zCAsKWfDYBYCKsZanqqU8mgF3ANrEAI/HOsHDjgi8oycUYmlahbDEym+E2RZoJ7CuZQvFIZ+Jo+CNsk+dvgAXSsCovgCRS0tyH+aFYaA2V8ApQLIFAW2ZfgiAlIEuwIO4Ap2I1xnL9wAdig3UgIGf6YE6DbBBHsBdxUYPHjSAHNWkIRV4yToTJo9fHKeIa32X0luKS0KMxP3Ko1eRBJCWkIMxCT0QmGFVau4JCE8fyjMBrtGXRFQD0ey3ylvRggAFQMds0jrARM9SsnGPBPwES6Nxm00yQBywllTABaqCdwPMUoO5Qd85Skqddq+OgvwnB0cAXVO92EWHA4IdbRkNjHKtgz1P9igRVKWJTcjwZrR8wLfBG0HCOFOoHq8bxdTQkAxKg8nE1DGHtA3kQgro0sY9PUYwjnZqgN5FQeHiEMAFRkElNIELGVYpCzs7psuagceOx6VnFMNPy/MDQe9BwEqPVUNBAhc0tpXAFewAxZ+AKsGSriss+52JIsIOj6JVHuNtiQnblFpaV8ED8LHvw4EmBgHL1UP5gNrBQ0SQdz+AxUBqnMDNuBtmgbCMweoGxIq9AbOQIyvOd0DVEUOXzQAcJCuFF52j5Jz5aHRQ5YwMny8QQJcFYgAF1sGkRMQBTDDzDdfK4SKytaorCm44gSOswA1lc1IVWqFuh+6x3LnBSUAE2QIWigFHb3YC1BVDwWdb4eIFzrNRimjqSKpwzltIIWEdI49Mh06XQYKBw41oWjUAHwgEoKXEKItKQEDAAsANWhxAN8K2QR2g1UjAts3mDkh2jA/LHK7BM5OEQ6oBqLLHj0aA3U3MX2Kb1wEBNIHNul/ogAnOGEERQWVVxvZA01dshtiBA9sUJqjJEs0APzrxA5TLhld+ImbOIIBSAJ5CsWQ9nwDE4EAmwYAFsoF28p6D1uFMYMFfgYtE6qkNwAATiwqvE9QADoAAQBqF4wG3QAumBeeN0klpFMCJGmFA9QrBAiYUiAsAFvNnm/HCXOBHKIZXyFlQikDC34xeT4IqQES+kh8NAMYAUEAvgB0HiVoCiMIbI4DGSYNQndiOymW01MRHDwWzs/FkmNBosBbZlMJj0LSAQJUiguvPQAHSxcATgAEbkceKlAmA966PQGGvYaul2NcZG64cOS55stIjxIVAZyuYlwBAVoJLrV6cSQeOwLpDQQb3gMFBUOMOKCAHgTAJd/0fsZGRCZz9eoBhQZ9Lx+BmQgjUNWgNZEbkzIzJz7Kn22XMHV5p49UihqXk6EAeqS6kDqzQcAcjElhAwsAIw4bkjXuBXHmkwJFAT8NLgCQSA9fAmoWAII8yBinKIFM5qNFDVITCBY3q1P2BKNnIPIJoA1wSGtOVkMVL0wuW3qGmRItFEJdIwMNRwI4VlZyFA5ntqYu3bk8FuzvX73m+0e8MiSObrkfXIS3PqwgW30csgKb+sNWNAqkAUAHHBcAHisPF8KyNVwdjib4CQEEqB8BBk3RmxoOcAYqEdnBQnikHk+GCzazSTmuSQXIjV1IPVWWBJEz61wSEA0AQA89r+DVIWexHfEtWzwaxWhXkAxh4jFolqsEVsMROEk9ijfAAR5jTmj6exsBtYRyIiMoZ/4tVhPlPMTKWBfLMQIxUwEAmQxJGCMFSwPjJwj2GUxYFhcWg5u0ntEASB9dCwNnhlcp7wADVo2t9ZEqG8wJWw3bW4IBpoWxDiGWcPxTjgYaN78JGGW0oA4BFsFpqTAKAAQ80REueg8DlcPFnx1jXTAK5NnxwgEb60cNmUb1gDo4IDUGyQgCAW8uBE8AClg+kQEACiJyVT5uW8RBG87AFApFlOwHAicmhoIYJ5YKAQzVZCfCeuuSnEUSeZckEiordDgJUX3LlPazKnfNjiIeqMxVZAZZADTEEkZ8EXGL+gFGwrjaTHyCEb//H6AY7NQKJgsWLAEZPFuLZnZGRnQtp1EuJRVuJTGdca2pHwCthB51+ZgAuXp+lRMyJ2SAgrYB6m0Q+/4YDM6aKGi/fSuVCQVuWtMBKztbqWEoa85PVdo7zihmsFxiXjnaYQAUn5bbKOh6s08RBhjdaU82QD8htgUalV8OGmIHAFTgUJyiMgTgxg8fON4ZAaBIgnxJeaqd1gRvBBMITAdGJWRKWx0lAVHR0j4AdvYAdQNaQJUDRHlHml5cSLMjaYxAqHmbAaTZAZcZ5s6JLJGip7sCXaw2LCRnK1YMO4sFRAgVWgfXMfc+zt038JeI6lkCDQU5yCGeZRBOA9aMG3e0AZ7cmQmKjgeCWvmJnn7yAwY8uoEEL1wLBADizps1VFIzm5UYtBHFT5Qy46UAsQTBZCwPgljNPekNGEwdic0FR1JmP5AAhShTl4MCWwq2By1NKlUqzQQGAidkywDoSgYGtQ8JRdefJLqPjw5YsD85GiBWlRsDZ2GzVDkCvRSyUzIq16YUXEBLd2kGn+rLIwAAAK1JREFUf54DD3C0WwmGPi9OSjpCA0A7fFwUZTm0ktDZLl5VXmbFDDQACl7+QSry5QCM2bfNC+WAFj1LAzLsiwEBaQCW/1EGcMN/tG8OViQtylulBUxRADYm5SEBRAcAARkeMC5iRNgZhOoxnz4oHApa6gD3ASdbmF188wxpDZVKUL4RUhTSSRvrQAZLDcgauImabgJzkXIaALePAXot1j6Bdwe3AXoQAnXMFVuCApGWbjuRvTu7AAAAAElFTkSuQmCC"; +export { + ShaderMount, + getShaderNoiseTexture, + paperTextureFragmentShader +}; diff --git a/public/_lab/render.html b/public/_lab/render.html new file mode 100644 index 0000000..43e6607 --- /dev/null +++ b/public/_lab/render.html @@ -0,0 +1,56 @@ + + + bake + +
+ + + diff --git a/public/_lab/shader-demo.js b/public/_lab/shader-demo.js new file mode 100644 index 0000000..46bcb50 --- /dev/null +++ b/public/_lab/shader-demo.js @@ -0,0 +1,83 @@ +// Живой прототип Paper Shaders на стенде _lab (без правки прод-кода). +// Пакет скопирован локально в /vendor/paper-shaders (zero-dep, ESM). +import { + ShaderMount, + meshGradientFragmentShader, + getShaderColorFromString, + defaultObjectSizing, + ShaderFitOptions, +} from '/vendor/paper-shaders/index.js'; + +// Цвета по бренду (как у .contact-card / .neu-card--shader) +const BRAND = { + tg: ['#0088cc', '#00bcd4', '#0a66c2'], + vk: ['#0077ff', '#4a90ff', '#0033aa'], + ya: ['#f8604a', '#ffae00', '#ff3d00'], + email: ['#7c5cff', '#b06cff', '#5a3fd6'], + demo: ['#7c5cff', '#00c2c7', '#ff5c8a', '#ffb347'], +}; + +// Официальная конвертация hex/HSL/RGB -> [r,g,b,a] (0..1), как ожидает u_colors (vec4[]). +const c4 = (arr) => arr.map(getShaderColorFromString); + +// Sizing uniforms обязательны (vertex-шейдер делит на u_scale — при 0 будет NaN +// и весь шейдер чёрный). Берём дефолты из пакета, как в React-обёртке. +const sizing = () => ({ + u_fit: ShaderFitOptions[defaultObjectSizing.fit], + u_scale: defaultObjectSizing.scale, + u_rotation: defaultObjectSizing.rotation, + u_offsetX: defaultObjectSizing.offsetX, + u_offsetY: defaultObjectSizing.offsetY, + u_originX: defaultObjectSizing.originX, + u_originY: defaultObjectSizing.originY, + u_worldWidth: defaultObjectSizing.worldWidth, + u_worldHeight: defaultObjectSizing.worldHeight, +}); + +// Единственный используемый шейдер: mesh gradient, анимированный. +const MESH = { + frag: meshGradientFragmentShader, + speed: 1.0, + uniforms: (c) => ({ + ...sizing(), + u_colors: c4(c), u_colorsCount: c.length, + u_distortion: 1.2, u_swirl: 0.8, u_grainMixer: 0.08, u_grainOverlay: 0.04, + }), +}; + +const mounts = new WeakMap(); + +function mountShader(host, colors) { + const wrap = host.querySelector('.card-shader'); + if (!wrap) return; + if (mounts.has(host)) { try { mounts.get(host).dispose(); } catch {} mounts.delete(host); } + while (wrap.firstChild) wrap.removeChild(wrap.firstChild); + try { + const m = new ShaderMount(wrap, MESH.frag, MESH.uniforms(colors), + { preserveDrawingBuffer: true, antialias: true }, MESH.speed); + // отключаем IntersectionObserver: иначе вне вьюпорта ставит currentSpeed=0 + // и рендер замирает (у пользователя карточка внизу страницы -> пауза). + try { m.intersectionObserver?.disconnect(); } catch {} + mounts.set(host, m); + } catch (e) { + console.error('ShaderMount failed', e); + } +} + +function mountAll(root = document) { + // любые карточки с шейдером: у .card-shader есть data-shader (бренд), + // сама карточка — его родитель (contact-card, neu-card--shader, shader-cell...) + root.querySelectorAll('.card-shader[data-shader]').forEach((wrap) => { + const host = wrap.parentElement; + const brand = wrap.dataset.shader; + mountShader(host, BRAND[brand] || BRAND.demo); + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mountAll); +} else { + mountAll(); +} + +window.__mountLabShaders = mountAll; diff --git a/public/_lab/template.html b/public/_lab/template.html new file mode 100644 index 0000000..313ac00 --- /dev/null +++ b/public/_lab/template.html @@ -0,0 +1,74 @@ + + + + + + + Стенд: <название элемента> + + + + + + + +
+
+

<название элемента>

+

+ Стенд для настройки вида. Сборка не нужна: правим public/css/main.css → + страница перезагрузится сама. Переключатель темы в правом нижнем углу. +

+ +
+ + +
+

Демо

+

Опиши, что смотрим. Здесь — сам элемент во всех состояниях.

+
+ +
+ Состояние + Подпись +
+
+
+ +
+

Токены

+

Числа, которыми управляется вид. Значение по умолчанию — в скобках.

+
+
+
--neu-bg --bg-elevated
+
Нейтральная заливка карточки.
+
+
+
+
+ +
+ + + + + + +
+ + + + + diff --git a/public/ai.txt b/public/ai.txt new file mode 100644 index 0000000..824bc76 --- /dev/null +++ b/public/ai.txt @@ -0,0 +1,46 @@ +# ai.txt — mamadaevv.site +# Описание сайта для ИИ-агентов и ассистентов. +# Формат: https://github.com/dvetter/ai.txt + +name: Портфолио Николая Мамадаева +description: > + Личный сайт продуктового дизайнера Николая Мамадаева. Кейсы UX/UI-проектов, + интерфейсов и дизайн-систем, резюме и контакты. Сайт доступен на русском и + английском языках. +author: Николай Мамадаев +contact: https://t.me/mamadaevv +canonical: https://mamadaevv.site/ +sitemap: https://mamadaevv.site/sitemap.xml + +# Разрешения для ИИ-агентов +policy: allow +allow: / +disallow: /_lab/ + +# Разрешённые сценарии использования +use: index +use: summarize +use: cite +use: reference + +# Требование атрибуции +attribution: required +attribution_url: https://mamadaevv.site/ + +# Языки +languages: ru, en +default_language: ru + +# Структура сайта +sections: + - path: / + title: Главная + description: Обо мне, портфолио и активность. + - path: /resume/ + title: Резюме + description: Опыт работы, навыки и контакты. + - path: /cases/ + title: Кейсы + description: Разборы UX/UI-проектов и дизайн-систем. + - path: /cookie-policy/ + title: Политика cookie diff --git a/public/assets/fonts/geologica.css b/public/assets/fonts/geologica.css index 493ec13..ffad318 100644 --- a/public/assets/fonts/geologica.css +++ b/public/assets/fonts/geologica.css @@ -1,139 +1,139 @@ -/* geologica-cyrillic-ext-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-cyrillic-ext-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* geologica-cyrillic-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-cyrillic-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* geologica-greek-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-greek-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; -} - -/* geologica-vietnamese-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-vietnamese-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* geologica-latin-ext-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-latin-ext-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* geologica-latin-wght-normal */ -@font-face { - font-family: 'Geologica'; - font-style: normal; - font-display: swap; - font-weight: 100 900; - src: url(./files/geologica-latin-wght-normal.woff2) format('woff2-variations'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* pt-sans-cyrillic-ext-400-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-sans-cyrillic-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* pt-sans-cyrillic-400-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-sans-cyrillic-400-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* pt-sans-latin-ext-400-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-sans-latin-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* pt-sans-latin-400-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-sans-latin-400-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* pt-sans-cyrillic-ext-700-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/pt-sans-cyrillic-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* pt-sans-cyrillic-700-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/pt-sans-cyrillic-700-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* pt-sans-latin-ext-700-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/pt-sans-latin-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* pt-sans-latin-700-normal */ -@font-face { - font-family: 'PT Sans'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/pt-sans-latin-700-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} +/* geologica-cyrillic-ext-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-cyrillic-ext-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* geologica-cyrillic-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-cyrillic-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* geologica-greek-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-greek-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; +} + +/* geologica-vietnamese-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-vietnamese-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* geologica-latin-ext-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-latin-ext-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* geologica-latin-wght-normal */ +@font-face { + font-family: 'Geologica'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(./files/geologica-latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* pt-sans-cyrillic-ext-400-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-sans-cyrillic-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* pt-sans-cyrillic-400-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-sans-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* pt-sans-latin-ext-400-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-sans-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* pt-sans-latin-400-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-sans-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* pt-sans-cyrillic-ext-700-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/pt-sans-cyrillic-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* pt-sans-cyrillic-700-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/pt-sans-cyrillic-700-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* pt-sans-latin-ext-700-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/pt-sans-latin-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* pt-sans-latin-700-normal */ +@font-face { + font-family: 'PT Sans'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/pt-sans-latin-700-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} diff --git a/public/assets/fonts/merriweather.css b/public/assets/fonts/merriweather.css index f58c961..642eb84 100644 --- a/public/assets/fonts/merriweather.css +++ b/public/assets/fonts/merriweather.css @@ -1,139 +1,139 @@ -/* alice-cyrillic-ext-400-normal */ -@font-face { - font-family: 'Alice'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/alice-cyrillic-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* alice-cyrillic-400-normal */ -@font-face { - font-family: 'Alice'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/alice-cyrillic-400-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* alice-latin-ext-400-normal */ -@font-face { - font-family: 'Alice'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/alice-latin-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* alice-latin-400-normal */ -@font-face { - font-family: 'Alice'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/alice-latin-400-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* merriweather-cyrillic-ext-400-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/merriweather-cyrillic-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* merriweather-cyrillic-400-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/merriweather-cyrillic-400-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* merriweather-vietnamese-400-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/merriweather-vietnamese-400-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* merriweather-latin-ext-400-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/merriweather-latin-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* merriweather-latin-400-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/merriweather-latin-400-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* merriweather-cyrillic-ext-700-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/merriweather-cyrillic-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* merriweather-cyrillic-700-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/merriweather-cyrillic-700-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* merriweather-vietnamese-700-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/merriweather-vietnamese-700-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* merriweather-latin-ext-700-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/merriweather-latin-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* merriweather-latin-700-normal */ -@font-face { - font-family: 'Merriweather'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/merriweather-latin-700-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} +/* alice-cyrillic-ext-400-normal */ +@font-face { + font-family: 'Alice'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/alice-cyrillic-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* alice-cyrillic-400-normal */ +@font-face { + font-family: 'Alice'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/alice-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* alice-latin-ext-400-normal */ +@font-face { + font-family: 'Alice'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/alice-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* alice-latin-400-normal */ +@font-face { + font-family: 'Alice'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/alice-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* merriweather-cyrillic-ext-400-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/merriweather-cyrillic-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* merriweather-cyrillic-400-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/merriweather-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* merriweather-vietnamese-400-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/merriweather-vietnamese-400-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* merriweather-latin-ext-400-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/merriweather-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* merriweather-latin-400-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/merriweather-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* merriweather-cyrillic-ext-700-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/merriweather-cyrillic-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* merriweather-cyrillic-700-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/merriweather-cyrillic-700-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* merriweather-vietnamese-700-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/merriweather-vietnamese-700-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* merriweather-latin-ext-700-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/merriweather-latin-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* merriweather-latin-700-normal */ +@font-face { + font-family: 'Merriweather'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/merriweather-latin-700-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} diff --git a/public/assets/fonts/mono.css b/public/assets/fonts/mono.css index e5f64fa..3e50fc7 100644 --- a/public/assets/fonts/mono.css +++ b/public/assets/fonts/mono.css @@ -1,319 +1,319 @@ -/* pt-mono-cyrillic-ext-400-normal */ -@font-face { - font-family: 'PT Mono'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-mono-cyrillic-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* pt-mono-cyrillic-400-normal */ -@font-face { - font-family: 'PT Mono'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-mono-cyrillic-400-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* pt-mono-latin-ext-400-normal */ -@font-face { - font-family: 'PT Mono'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-mono-latin-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* pt-mono-latin-400-normal */ -@font-face { - font-family: 'PT Mono'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/pt-mono-latin-400-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* source-code-pro-cyrillic-ext-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-cyrillic-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* source-code-pro-cyrillic-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-cyrillic-400-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* source-code-pro-greek-ext-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-greek-ext-400-normal.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} - -/* source-code-pro-greek-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-greek-400-normal.woff2) format('woff2'); - unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; -} - -/* source-code-pro-vietnamese-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-vietnamese-400-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* source-code-pro-latin-ext-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-latin-ext-400-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* source-code-pro-latin-400-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url(./files/source-code-pro-latin-400-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* source-code-pro-cyrillic-ext-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-cyrillic-ext-500-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* source-code-pro-cyrillic-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-cyrillic-500-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* source-code-pro-greek-ext-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-greek-ext-500-normal.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} - -/* source-code-pro-greek-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-greek-500-normal.woff2) format('woff2'); - unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; -} - -/* source-code-pro-vietnamese-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-vietnamese-500-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* source-code-pro-latin-ext-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-latin-ext-500-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* source-code-pro-latin-500-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: url(./files/source-code-pro-latin-500-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* source-code-pro-cyrillic-ext-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-cyrillic-ext-600-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* source-code-pro-cyrillic-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-cyrillic-600-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* source-code-pro-greek-ext-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-greek-ext-600-normal.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} - -/* source-code-pro-greek-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-greek-600-normal.woff2) format('woff2'); - unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; -} - -/* source-code-pro-vietnamese-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-vietnamese-600-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* source-code-pro-latin-ext-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-latin-ext-600-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* source-code-pro-latin-600-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url(./files/source-code-pro-latin-600-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} - -/* source-code-pro-cyrillic-ext-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-cyrillic-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; -} - -/* source-code-pro-cyrillic-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-cyrillic-700-normal.woff2) format('woff2'); - unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; -} - -/* source-code-pro-greek-ext-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-greek-ext-700-normal.woff2) format('woff2'); - unicode-range: U+1F00-1FFF; -} - -/* source-code-pro-greek-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-greek-700-normal.woff2) format('woff2'); - unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; -} - -/* source-code-pro-vietnamese-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-vietnamese-700-normal.woff2) format('woff2'); - unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; -} - -/* source-code-pro-latin-ext-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-latin-ext-700-normal.woff2) format('woff2'); - unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; -} - -/* source-code-pro-latin-700-normal */ -@font-face { - font-family: 'Source Code Pro'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url(./files/source-code-pro-latin-700-normal.woff2) format('woff2'); - unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; -} +/* pt-mono-cyrillic-ext-400-normal */ +@font-face { + font-family: 'PT Mono'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-mono-cyrillic-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* pt-mono-cyrillic-400-normal */ +@font-face { + font-family: 'PT Mono'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-mono-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* pt-mono-latin-ext-400-normal */ +@font-face { + font-family: 'PT Mono'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-mono-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* pt-mono-latin-400-normal */ +@font-face { + font-family: 'PT Mono'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/pt-mono-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* source-code-pro-cyrillic-ext-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-cyrillic-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* source-code-pro-cyrillic-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-cyrillic-400-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* source-code-pro-greek-ext-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-greek-ext-400-normal.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +/* source-code-pro-greek-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-greek-400-normal.woff2) format('woff2'); + unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; +} + +/* source-code-pro-vietnamese-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-vietnamese-400-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* source-code-pro-latin-ext-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-latin-ext-400-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* source-code-pro-latin-400-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url(./files/source-code-pro-latin-400-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* source-code-pro-cyrillic-ext-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-cyrillic-ext-500-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* source-code-pro-cyrillic-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-cyrillic-500-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* source-code-pro-greek-ext-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-greek-ext-500-normal.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +/* source-code-pro-greek-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-greek-500-normal.woff2) format('woff2'); + unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; +} + +/* source-code-pro-vietnamese-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-vietnamese-500-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* source-code-pro-latin-ext-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-latin-ext-500-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* source-code-pro-latin-500-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 500; + src: url(./files/source-code-pro-latin-500-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* source-code-pro-cyrillic-ext-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-cyrillic-ext-600-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* source-code-pro-cyrillic-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-cyrillic-600-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* source-code-pro-greek-ext-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-greek-ext-600-normal.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +/* source-code-pro-greek-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-greek-600-normal.woff2) format('woff2'); + unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; +} + +/* source-code-pro-vietnamese-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-vietnamese-600-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* source-code-pro-latin-ext-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-latin-ext-600-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* source-code-pro-latin-600-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url(./files/source-code-pro-latin-600-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* source-code-pro-cyrillic-ext-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-cyrillic-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F; +} + +/* source-code-pro-cyrillic-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-cyrillic-700-normal.woff2) format('woff2'); + unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; +} + +/* source-code-pro-greek-ext-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-greek-ext-700-normal.woff2) format('woff2'); + unicode-range: U+1F00-1FFF; +} + +/* source-code-pro-greek-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-greek-700-normal.woff2) format('woff2'); + unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF; +} + +/* source-code-pro-vietnamese-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-vietnamese-700-normal.woff2) format('woff2'); + unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB; +} + +/* source-code-pro-latin-ext-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-latin-ext-700-normal.woff2) format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +/* source-code-pro-latin-700-normal */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url(./files/source-code-pro-latin-700-normal.woff2) format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} diff --git a/public/assets/lab/grain-l.webp b/public/assets/lab/grain-l.webp new file mode 100644 index 0000000..01d36c9 Binary files /dev/null and b/public/assets/lab/grain-l.webp differ diff --git a/public/assets/lab/grain-m.webp b/public/assets/lab/grain-m.webp new file mode 100644 index 0000000..30ef71e Binary files /dev/null and b/public/assets/lab/grain-m.webp differ diff --git a/public/assets/lab/grain-s.webp b/public/assets/lab/grain-s.webp new file mode 100644 index 0000000..7bdad6b Binary files /dev/null and b/public/assets/lab/grain-s.webp differ diff --git a/public/assets/lab/grain-xl.webp b/public/assets/lab/grain-xl.webp new file mode 100644 index 0000000..167c6d2 Binary files /dev/null and b/public/assets/lab/grain-xl.webp differ diff --git a/public/assets/lab/grain-xs.webp b/public/assets/lab/grain-xs.webp new file mode 100644 index 0000000..7036bb7 Binary files /dev/null and b/public/assets/lab/grain-xs.webp differ diff --git a/public/assets/liquid-glass/displacement.png b/public/assets/liquid-glass/displacement.png new file mode 100644 index 0000000..60b7bff Binary files /dev/null and b/public/assets/liquid-glass/displacement.png differ diff --git a/public/assets/misc/github_logo.webp b/public/assets/misc/github_logo.webp new file mode 100644 index 0000000..2edbcd7 Binary files /dev/null and b/public/assets/misc/github_logo.webp differ diff --git a/public/assets/og/og-default.png b/public/assets/og/og-default.png new file mode 100644 index 0000000..9a20d85 Binary files /dev/null and b/public/assets/og/og-default.png differ diff --git a/public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf b/public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf new file mode 100644 index 0000000..52c4fe2 Binary files /dev/null and b/public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf differ diff --git a/public/assets/shared/icons/printer.svg b/public/assets/shared/icons/printer.svg new file mode 100644 index 0000000..8ff44cd --- /dev/null +++ b/public/assets/shared/icons/printer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/assets/shared/icons/whatsapp.svg b/public/assets/shared/icons/whatsapp.svg new file mode 100644 index 0000000..ae5ca23 --- /dev/null +++ b/public/assets/shared/icons/whatsapp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/template-elements-demo/slider-demo-1.webp b/public/assets/template-elements-demo/slider-demo-1.webp new file mode 100644 index 0000000..66ec9a9 Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-1.webp differ diff --git a/public/assets/template-elements-demo/slider-demo-2.webp b/public/assets/template-elements-demo/slider-demo-2.webp new file mode 100644 index 0000000..bf53716 Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-2.webp differ diff --git a/public/assets/template-elements-demo/slider-demo-3.webp b/public/assets/template-elements-demo/slider-demo-3.webp new file mode 100644 index 0000000..e48474a Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-3.webp differ diff --git a/public/assets/template-elements-demo/slider-demo-4.webp b/public/assets/template-elements-demo/slider-demo-4.webp new file mode 100644 index 0000000..586ac54 Binary files /dev/null and b/public/assets/template-elements-demo/slider-demo-4.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-1.webp b/public/assets/template-elements-demo/slider-portrait-1.webp new file mode 100644 index 0000000..b0829e8 Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-1.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-2.webp b/public/assets/template-elements-demo/slider-portrait-2.webp new file mode 100644 index 0000000..56c4d5d Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-2.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-3.webp b/public/assets/template-elements-demo/slider-portrait-3.webp new file mode 100644 index 0000000..c9db02c Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-3.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-4.webp b/public/assets/template-elements-demo/slider-portrait-4.webp new file mode 100644 index 0000000..fb6eb40 Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-4.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-5.webp b/public/assets/template-elements-demo/slider-portrait-5.webp new file mode 100644 index 0000000..84d6332 Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-5.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-6.webp b/public/assets/template-elements-demo/slider-portrait-6.webp new file mode 100644 index 0000000..6e4b136 Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-6.webp differ diff --git a/public/assets/template-elements-demo/slider-portrait-7.webp b/public/assets/template-elements-demo/slider-portrait-7.webp new file mode 100644 index 0000000..6d9886f Binary files /dev/null and b/public/assets/template-elements-demo/slider-portrait-7.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-1.webp b/public/assets/template-elements-demo/slider-wide-1.webp new file mode 100644 index 0000000..3fc9f41 Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-1.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-2.webp b/public/assets/template-elements-demo/slider-wide-2.webp new file mode 100644 index 0000000..9a1bef3 Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-2.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-3.webp b/public/assets/template-elements-demo/slider-wide-3.webp new file mode 100644 index 0000000..e2a37b8 Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-3.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-4.webp b/public/assets/template-elements-demo/slider-wide-4.webp new file mode 100644 index 0000000..b55f39a Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-4.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-5.webp b/public/assets/template-elements-demo/slider-wide-5.webp new file mode 100644 index 0000000..860ca13 Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-5.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-6.webp b/public/assets/template-elements-demo/slider-wide-6.webp new file mode 100644 index 0000000..b88651a Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-6.webp differ diff --git a/public/assets/template-elements-demo/slider-wide-7.webp b/public/assets/template-elements-demo/slider-wide-7.webp new file mode 100644 index 0000000..df25fbf Binary files /dev/null and b/public/assets/template-elements-demo/slider-wide-7.webp differ diff --git a/public/cases/ai-assistant-figma/index.html b/public/cases/ai-assistant-figma/index.html new file mode 100644 index 0000000..18cec64 --- /dev/null +++ b/public/cases/ai-assistant-figma/index.html @@ -0,0 +1,544 @@ + + + + + + + AI assistant — Плагин для Figma — кейс + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Figma · Plugin

+

AI assistant

+

Плагин для Figma с AI-функциями

+ +

+ +

+ +

Контекст

+

+ +

+ +

Задача

+

+ +

+ +

Решение

+

+ +

+ +

Результат

+

+ +

+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/cases/ai-platform-primo-rpa-2.html b/public/cases/ai-platform-primo-rpa-2/index.html similarity index 76% rename from public/cases/ai-platform-primo-rpa-2.html rename to public/cases/ai-platform-primo-rpa-2/index.html index 418520c..fa4e59f 100644 --- a/public/cases/ai-platform-primo-rpa-2.html +++ b/public/cases/ai-platform-primo-rpa-2/index.html @@ -11,9 +11,9 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Product Design · AI

+

Упростил настройку Primo RPA AI Server: клиенты настраивают ИИ-сервис без команды внедрения

+

+ Платформа интеллектуальной обработки документов
+ и оркестрации ИИ-агентов. Обращения к команде внедрения упали на 40%, настройка первого проекта ужалась с ~3 дней до ~4 часов. +

+ +
+ Обновлённый редактор разметки датасета — главный экран сервиса после редизайна +
+ +
+
Продукт
+
AI Server. Нейросети во внутреннем контуре компании, настраивается через веб-интерфейс
+
Роль
+
UX/UI-дизайнер (единственный на проекте)
+
Задача
+
Снизить порог входа, чтобы клиент настраивал сервис сам
+
Стек
+
Ant Design + React, стилизация Hope UI
+
Формат
+
Редизайн живого продукта, релизы не останавливались
+
+ +
+

С настройкой справлялись только эксперты. Это тормозило продажи

+

+ AI Server разворачивается на внутренних серверах клиентов. Продукт активно тестировали, и обратная связь показала неприятное: настроить его могли только эксперты и команда внедрения Primo RPA. Каждый новый клиент съедал часы техподдержки. Продукт терял привлекательность и плохо масштабировался. +

+ +

+ Начал со знакомства с командой и экспертами по настройке. Разделил сценарии на главные и вспомогательные, собрал визуализацию того, как ведёт себя пользователь, и вместе с разработкой искал, где интерфейс можно упростить. Я уже работал с ИИ и похожими веб-платформами, поэтому быстро разобрался в домене и говорил с разработкой на одном языке. +

+
+ +
+

Ограничения

+
    +
  • Продуктом уже пользовались клиенты. Остановить разработку и пересобрать интерфейс целиком было нельзя.
  • +
  • Изменения приходилось вписывать в план релизов, отдельного «редизайн-спринта» никто не выделял.
  • +
  • Жёсткий стек: Ant Design под React со стилизацией под Hope UI. Новые компоненты рисовать только в крайнем случае, сначала выжимаем всё из готовой библиотеки.
  • +
+
+ + + +
+

Редактор датасета: экран, где пользователи живут

+

+ Здесь проводят больше всего времени. Разметка полей на сотнях изображений — работа монотонная, но именно от неё зависит качество данных для обучения модели. Прежний редактор выбивался из общего стиля, выглядел перегруженно и путал в работе с полями. +

+

+ Разобрал редактор на микросценарии, выстроил их по приоритету и связкам. Вместе с командой спроектировали поведение плавнее и понятнее, заложили место под будущий функционал, дизайн привели к общей стилистике сервиса. +

+
+ Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам +
Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам
+
+

+ Страница стала симметричной. Почти всё пространство уходит на размечаемое изображение, действия сгруппированы по панелям: нужная функция ищется быстро. +

+
+ Схематичный каркас редактора — центральная рабочая область и боковые панели +
Схематичный каркас редактора — центральная рабочая область и боковые панели, режим по умолчанию
+
+

+ Для продвинутых пользователей добавил режим «Во весь экран». Боковые панели сворачиваются, работа идёт через горячие клавиши и нижнюю панель. На небольших мониторах это спасает. +

+
+ Полноэкранный режим редактора с раскрытыми боковыми панелями +
Полноэкранный режим редактора с раскрытыми боковыми панелями
+
+
+ Полноэкранный режим без панелей — только размечаемый документ и нижняя панель +
Полноэкранный режим без панелей — на экране только размечаемый документ и нижняя панель инструментов
+
+
+ Четыре варианта каркаса полноэкранного режима +
Четыре варианта каркаса полноэкранного режима — обе панели, без панелей, только левая, только правая
+
+
+ Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки +
Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки
+
+
+ +
+

Десятки полей на одном скане, и место под документ не страдает

+

+ Смежная задача: настроить уникальные параметры полей датасета. Большая область нужна под скан документа, полей десятки, у каждого свои типы и зависимые параметры. +

+
+
+ +
+
Прототип сопоставления полей — пользователь выбирает поле на скане документа и настраивает его параметры в компактной панели
+
+

+ Интерфейс вышел компактным. Механика не считывается с первого взгляда, зато после короткого обучения работать в нём быстро и комфортно. +

+
+ +
+

Карточки обучения: паттерн, который разошёлся по всему продукту

+

+ После разметки датасета модель дообучают (fine-tuning). Разобрали все действия пользователя, перебрали несколько компоновок страницы. Остановились на ленте из карточек. +

+
+ Раздел обучения моделей — вертикальная лента карточек +
Раздел обучения моделей — вертикальная лента карточек, сверху карточка настройки нового обучения
+
+

+ В начале ленты карточка настройки: результат разметки активного датасета, параметры, запуск fine-tuning. +

+
+ Карточка настройки обучения — матрица качества датасета и блок параметров запуска +
Карточка настройки обучения — матрица качества датасета и блок параметров запуска
+
+ +

+ Под каждый запущенный процесс появляется своя карточка. Можно завести несколько процессов с разными параметрами и сравнить, какая конфигурация отработала лучше. +

+
+ Карточка запущенного процесса обучения — статус, прогресс и метрики +
Карточка запущенного процесса обучения — статус, прогресс и метрики, разнесённые по логическим блокам
+
+
+ Каркас карточки процесса обучения +
Каркас карточки процесса обучения — схема расположения информационных блоков и действий
+
+

+ Карточки сворачиваются, в шапке остаются название и статус. +

+
+ Свёрнутая карточка обучения — одна строка с названием и статусом процесса +
Свёрнутая карточка обучения — одна строка с названием и статусом процесса
+
+

+ Паттерны из этих карточек потом разошлись по другим разделам продукта. Единый стиль стало проще держать. +

+
+ +
+

Долгие процессы — в соседнюю вкладку

+

+ Часть задач идёт параллельно с остальной работой. Для таких сценариев модальное окно открывается на отдельной странице. +

+ +
+ Импорт датасета в модальном окне на отдельной странице +
Импорт датасета в модальном окне на отдельной странице — форма загрузки на весь экран
+
+

+ На странице проекта в это время висит заглушка с кнопкой перехода к процессу. +

+
+ Заглушка активного импорта внутри проекта +
Заглушка активного импорта внутри проекта — индикатор процесса и кнопка перехода
+
+
+ +
+

Что я решил не делать, и почему

+
    +
  • Не стал блокировать кнопки на отдельных страницах во время активных процессов. Модалка на отдельной странице технически проще, а прогресс виден в соседней вкладке.
  • +
  • Не рисовал новые компоненты, кроме крайних случаев. Готовая библиотека Ant Design ускоряла разработку и держала интерфейс консистентным.
  • +
  • Не убрал боковые панели редактора совсем, а сделал сворачиваемыми. Новичкам нужны подсказки на экране, продвинутым — максимум места под документ.
  • +
  • Не заводил отдельную дизайн-систему. В продукте уже был фреймворк, в разработку передавал ссылки на готовые юзерфлоу в Figma.
  • +
+

Передача в разработку

+

+ Раз в неделю обсуждали дизайн с командой: список задач, приоритеты, сроки под планы релизов. С верстальщиком уточнял детали макетов тет-а-тет и давал фидбэк после вёрстки, чтобы результат не разошёлся с задуманным поведением. +

+
+ +
+

Результат

+ +
    +
  • Обращения к команде внедрения на этапе настройки сократились на 40%. Базовую настройку клиенты проходят сами.
  • +
  • 7 из 10 новых клиентов настроили первый проект без техподдержки. До редизайна — 1 из 10.
  • +
  • Настройка первого проекта: с ~3 дней до ~4 часов.
  • +
  • Переработано 30+ экранов: навигация, редактор датасета, обучение моделей, импорт данных.
  • +
  • Заложены паттерны, на которых функционал наращивается без больших переделок.
  • +
  • В компанию наняли второго дизайнера. Я ввёл её в курс дела и курировал результат, после адаптации проект целиком перешёл под её ответственность.
  • +
+
+ +
+

Выводы

+
    +
  • Жёсткий фреймворк оказался не ограничением, а ускорителем: готовая библиотека компонентов позволила думать о сценариях, а не о пикселях.
  • +
  • Самые ценные инсайты дали эксперты по настройке. В следующий раз вовлёк бы команду внедрения ещё раньше.
  • +
  • Передача проекта новому дизайнеру подтвердила: заложенные паттерны работают, их можно объяснить и поддерживать без автора.
  • +
+
+
+
+ +
+
+

Смотрите также

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    Интерфейс для обучения и использования AI‑моделей в бизнес‑процессах: работа с данными, сценариями автоматизации и Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Работа над интерфейсом Lunacy в команде Icons8: user flow, сложные прототипы, улучшение функциональности и интерфейсной логики.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Менторство и наставничество
    • +
    +

    Веб-кабинет оркестратора Primo RPA: проекты, запуск и контроль RPA-роботов. Редизайн ключевых экранов под продакшен без остановки релизов.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + diff --git a/public/cases/ai-platform-primo-rpa.html b/public/cases/ai-platform-primo-rpa/index.html similarity index 76% rename from public/cases/ai-platform-primo-rpa.html rename to public/cases/ai-platform-primo-rpa/index.html index 1e9c811..223a5b2 100644 --- a/public/cases/ai-platform-primo-rpa.html +++ b/public/cases/ai-platform-primo-rpa/index.html @@ -11,9 +11,9 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Obsidian Bases · Plugin

+

+ Канбан-вид для Obsidian Bases,
который не трогает ваши заметки +

+

+ Card Columns: заметки как карточки по колонкам,
+ отбор по тегам И/ИЛИ за два клика +

+ + + +
    +
  • РольПродуктовый дизайн и разработка
  • +
  • ПлатформаObsidian Bases
  • +
  • СтатусРабочий плагин, open source (MIT)
  • +
  • КомандаСоло, пет-проект
  • +
  • СрокЧерновой ответ, заменить
  • +
+ +
+ Вопрос к автору +

+ Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных? +

+

+ Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу + собственной работы. Заменить на реальное. +

+
+ +

+ В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а + в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он + плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их + раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns. +

+ +
+

Контекст

+

+ Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь + заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть + встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет + нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель + Bases наравне со штатными. +

+

+ Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что + выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика + выборки остаётся на Bases, а моя задача, только представление и навигация по нему. +

+ +
+ +
+

Почему не готовое

+

+ Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо: +

+

+ Штатный вид карточек Bases. Рисует ровный поток карточек без группировки. + Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки. + Отпал первым. +

+

+ Канбан-плагины из комьюнити. Большинство тянет за собой свою систему + хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база, + которую надо синхронизировать. Мне это было не нужно. +

+

+ Соседний bases-kanban. Ближайший по духу: цепляется к Bases, делает + канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина + это риск, о котором ниже. +

+ + +
+ Вопрос к автору +

+ Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие + именно смотрел? +

+

+ Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban + смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать. +

+
+
+ +
+

Задача

+

+ Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет + быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит + своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны + работать как раньше. +

+ +
+ +
+

Решения

+

+ Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса, + группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов, + которые определили результат. В каждом есть то, от чего я отказался. +

+ + +

Группировка по любому свойству

+

+ Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому + frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна + и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по + приоритету. +

+ +

Фильтр тегов по И и ИЛИ

+

+ Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они + отсеивают карточки по всем колонкам сразу: +

+
    +
  • Левый клик по тегу, показать только его (сбрасывает остальные фильтры).
  • +
  • Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).
  • +
  • Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.
  • +
  • Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.
  • +
  • All, снять все фильтры.
  • +
+

+ Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной: + быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку, + отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано. +

+

+ Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо + отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы + с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил. + Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда. +

+ + +

Карточки и обложки

+

+ По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно + показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки + полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание + (cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего). +

+ +

Решение: настройки вынесены из кода

+

+ Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать + для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид + подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров. +

+
+ Показать полный список настроек +

General

+
    +
  • Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.
  • +
  • Ширина карточки (px).
  • +
  • Колонок в группе (1–6), сетка внутри колонки.
  • +
  • Зебра колонок (чередование фона для сканирования).
  • +
  • Masonry-раскладка (карточки заполняют вертикальные пустоты).
  • +
+

Title

+
    +
  • Перенос заголовка, жирный заголовок, размер шрифта (px).
  • +
+

Properties

+
    +
  • Раскладка чипов: стек (вертикально) или сетка (компактно в строку).
  • +
  • Перенос многострочных значений, размер шрифта.
  • +
  • Формат даты и даты-времени + локаль (Moment.js).
  • +
+

Cover

+
    +
  • Источник: нет / первая картинка в заметке / свойство-обложка.
  • +
  • Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.
  • +
+
+ +
+ Вопрос к автору +

+ Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса + в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно. +

+

+ Черновой ответ: да, первая версия была под мою базу, настройки появились, когда + понадобилось открыть плагин наружу. Проверить и поправить. +

+
+ + +

Работает внутри штатного тулбара

+

+ Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из + тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не + дублируются и не зависят от отдельного хранилища. +

+ +
+ +
+

Результат и как я это мерял

+

+ Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента + карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному + виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не + ломают. +

+ +

Замер: сколько действий на отбор

+

+ Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную + карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В + Columns, два клика по пилюлям и колонка перед глазами. +

+ +
+ Вопрос к автору +

+ Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный + вид против Columns. Или посчитать точное число действий. +

+

+ Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и + требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные, + заменить на замеренные. +

+
+ +

Замер: живучесть в ежедневной работе

+

+ Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я + пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует + вспоминать, как им управлять. +

+ +
+ Вопрос к автору +

+ Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая + цифра, подтверждающая, что плагином пользуются не только ты. +

+

+ Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так + что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда, + это сильнее любого-self-report. +

+
+

+ Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно, + это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных. + Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А + пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что + мешает им пользоваться сходу. +

+ +
+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/cases/design-system.html b/public/cases/design-system/index.html similarity index 74% rename from public/cases/design-system.html rename to public/cases/design-system/index.html index 933ea8a..56af9dc 100644 --- a/public/cases/design-system.html +++ b/public/cases/design-system/index.html @@ -11,9 +11,9 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Разработка · AI IDE

+

Этот сайт

+

Разработка сайта с использованием AI IDE

+ +

+ +

+ +

Контекст

+

+ +

+ +

Задача

+

+ +

+ +

Решение

+

+ +

+ +

Результат

+

+ +

+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/cases/task-distribution-concept.html b/public/cases/task-distribution-concept/index.html similarity index 67% rename from public/cases/task-distribution-concept.html rename to public/cases/task-distribution-concept/index.html index bfdec40..58d3483 100644 --- a/public/cases/task-distribution-concept.html +++ b/public/cases/task-distribution-concept/index.html @@ -11,9 +11,9 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + +
@@ -193,7 +330,7 @@ - + @@ -232,8 +369,8 @@

Кто отвечает за сайт

Оператором сайта является Николай Мамадаев. По вопросам обработки данных используйте контакты в блоке «Контакты» на - главной странице - (Telegram, ВКонтакте, Яндекс и почта через копирование адреса кнопкой). + главной странице + (Telegram, ВКонтакте и почта через копировение адреса кнопкой).

@@ -288,28 +425,44 @@

Изменения

- Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу /cookie-policy.html относительно корня сайта. + Текст политики может обновляться при изменении технологий на сайте. Актуальная версия публикуется на странице «Политика cookie» по адресу /cookie-policy.html относительно корня сайта.

diff --git a/public/css/main.css b/public/css/main.css index aa6ca2e..5d36f7a 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -38,6 +38,8 @@ html { --radius-card: 14px; --radius-btn: 14px; --space: clamp(1rem, 4vw, 2.75rem); + /* Единый отступ fixed-элементов (FAB, плавающее меню, header) от краёв вьюпорта */ + --edge-pad: clamp(0.5rem, 2vw, 1.4rem); /* Контент на всю ширину экрана, поля только от --space и safe area */ --max: none; --font: "Alice", system-ui, -apple-system, serif; @@ -48,6 +50,7 @@ html { --icon-nav-overflow: 1.125rem; --icon-contact: 1.5rem; --icon-contact-meta: 1.125rem; + --card-border: hsla(210, 10%, 55%, 0.15); } html[data-theme="dark"] { @@ -111,6 +114,20 @@ html[data-theme="dark"] { inset 0 -1px 0 color-mix(in srgb, hsl(var(--theme-h) calc(var(--theme-s) * 1%) calc(var(--theme-l) * 1%)) 18%, transparent); --btn-primary-backdrop: blur(16px) saturate(135%); --btn-primary-backdrop-hover: blur(18px) saturate(140%); + /* Liquid Glass (Хабр): искажение + лёгкое матирование — только Chromium */ + --btn-primary-backdrop-lg: url(#liquid-glass-displacement-btn) blur(2px); + --btn-primary-backdrop-hover-lg: url(#liquid-glass-displacement-btn) blur(2px); + /* Полупрозрачный акцентный фон, чтобы displacement был виден (Liquid Glass) */ + --btn-primary-bg-lg: radial-gradient( + circle at 0% 0%, + color-mix(in hsl, var(--accent), white 10%) 0%, + color-mix(in srgb, var(--accent) 82%, transparent) 100% + ); + --btn-primary-hover-bg-lg: radial-gradient( + circle at 0% 0%, + color-mix(in hsl, var(--accent), white 14%) 0%, + color-mix(in srgb, var(--accent) 86%, transparent) 100% + ); --btn-primary-sheen: linear-gradient( 155deg, hsla(0, 0%, 100%, 0.1) 0%, @@ -203,6 +220,20 @@ html[data-theme="light"] { inset 0 -1px 0 color-mix(in srgb, var(--accent) 32%, hsl(0 0% 0%) 68%); --btn-primary-backdrop: blur(10px) saturate(142%); --btn-primary-backdrop-hover: blur(11px) saturate(148%); + /* Liquid Glass (Хабр): искажение + лёгкое матирование — только Chromium */ + --btn-primary-backdrop-lg: url(#liquid-glass-displacement-btn) blur(2px); + --btn-primary-backdrop-hover-lg: url(#liquid-glass-displacement-btn) blur(2px); + /* Полупрозрачный акцентный фон, чтобы displacement был виден (Liquid Glass) */ + --btn-primary-bg-lg: radial-gradient( + circle at 0% 0%, + color-mix(in hsl, var(--accent), white 10%) 0%, + color-mix(in srgb, var(--accent) 82%, transparent) 100% + ); + --btn-primary-hover-bg-lg: radial-gradient( + circle at 0% 0%, + color-mix(in hsl, var(--accent), white 14%) 0%, + color-mix(in srgb, var(--accent) 86%, transparent) 100% + ); --btn-primary-sheen: linear-gradient( 155deg, color-mix(in srgb, var(--accent) 55%, hsl(0 0% 100%) 45%) 0%, @@ -249,24 +280,37 @@ body { z-index: -1; pointer-events: none; overflow: hidden; + opacity: var(--mesh-opacity-current, 1); +} + +.page-mesh-bg canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + opacity: 0.4; /* приглушаем яркость живого шейдера */ +} + +/* Вертикальный градиент-оверлэй: снизу фон закрыт цветом страницы (--bg, + 100% непрозрачности), кверху растворяется — фон становится виден на 100%. + --bg берётся из темы, поэтому на светлой теме верх светлый, на тёмной — тёмный. */ +.page-mesh-bg::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to top, + var(--bg) 0%, + color-mix(in srgb, var(--bg) 65%, transparent) 35%, + transparent 70% + ); } html[data-mesh-bg="off"] .page-mesh-bg { visibility: hidden; } -.page-mesh-bg::before { - content: ""; - position: absolute; - inset: 0; - background-image: var(--page-bg); - background-repeat: no-repeat; - background-size: 100% 100dvh; - background-position: top center; - background-blend-mode: normal; - opacity: var(--mesh-opacity-current); -} - .mesh-panel { position: fixed; left: max(0.75rem, env(safe-area-inset-left, 0px)); @@ -336,10 +380,6 @@ html[data-mesh-bg="off"] .page-mesh-bg { margin-top: 0; } -.header--scrolled ~ #main { - margin-top: 3.75rem; -} - img { max-width: 100%; height: auto; @@ -435,11 +475,11 @@ a:hover { /* Только «текстовые» ссылки: .btn / .fab / .logo задают цвет сами, иначе :visited перебивает --on-accent */ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link) { - color: color-mix(in hsl, var(--accent) 10%, var(--text) 90%); + color: color-mix(in hsl, var(--accent) 40%, var(--text) 60%); } a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { - color: color-mix(in hsl, var(--accent-hover) 14%, var(--text) 86%); + color: color-mix(in hsl, var(--accent-hover) 50%, var(--text) 50%); } ::selection { @@ -474,10 +514,16 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { position: relative; top: 0; z-index: 50; + height: 0; background: transparent; transition: background 0.25s ease; } +/* Обычный (не плавающий) header спрятан — виден только при скролле (.header--scrolled) */ +.header:not(.header--scrolled) .header__inner { + display: none; +} + .header--scrolled { background: transparent; height: 0; @@ -488,7 +534,7 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { max-width: var(--max); width: 100%; margin: 0 auto; - padding: 0.8rem max(var(--space), env(safe-area-inset-right, 0px), env(safe-area-inset-left, 0px)); + padding: 0.8rem; display: flex; align-items: center; justify-content: space-between; @@ -498,9 +544,9 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { .header--scrolled .header__inner { position: fixed; - top: max(0.42rem, env(safe-area-inset-top, 0px)); + top: var(--edge-pad); left: auto; - right: max(var(--space), env(safe-area-inset-right, 0px)); + right: var(--edge-pad); width: fit-content; max-width: min( 980px, @@ -555,6 +601,47 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { margin-left: 0; } +/* Переключатель языка в футере: лаконичная текстовая группа RU / EN */ +.lang-switch { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: inherit; + line-height: 1; +} + +.lang-switch__btn { + appearance: none; + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + font-weight: 400; + letter-spacing: 0.04em; + color: var(--accent); + text-decoration: none; + cursor: pointer; + transition: color 0.15s ease; +} + +.lang-switch__btn:hover, +.lang-switch__btn:focus-visible { + color: var(--text); +} + +.lang-switch__btn[aria-current="true"] { + color: var(--accent); +} + +/* разделитель между языками */ +.lang-switch__btn:not(:last-child)::after { + content: "/"; + margin-left: 0.45rem; + color: var(--border); + font-weight: 400; +} + .header__cluster { display: flex; flex-wrap: nowrap; @@ -668,6 +755,7 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { right: 0; z-index: 90; background: var(--bg-elevated); + border-color: var(--v12-glass-neutral-border); } /* Плавающая панель: fixed + координаты из JS; фон — токены v12-glass внизу файла */ @@ -811,8 +899,8 @@ a:visited:not(.btn):not(.fab):not(.logo):not(.skip-link):hover { .fab-stack { position: fixed; - right: max(var(--space), env(safe-area-inset-right, 0px)); - bottom: max(1rem, env(safe-area-inset-bottom, 0px)); + right: var(--edge-pad); + bottom: var(--edge-pad); display: flex; flex-direction: column; align-items: flex-end; @@ -954,6 +1042,41 @@ a.fab--secondary:visited { outline-offset: 2px; } +/* ── PDF download FAB ── */ +.fab-primary__ico--pdf { + mask-image: url("../assets/shared/icons/cv.svg"); + -webkit-mask-image: url("../assets/shared/icons/cv.svg"); +} + +/* ── Print FAB ── */ +.fab-secondary__ico--print { + display: block; + width: var(--icon-fab); + height: var(--icon-fab); + background: currentColor; + mask-image: url("../assets/shared/icons/printer.svg"); + -webkit-mask-image: url("../assets/shared/icons/printer.svg"); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; +} + +.fab--label { + width: auto; + padding: 0.65rem 1.1rem; + gap: 0.5rem; +} + +.fab__label { + font-size: 0.85rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + .theme-picker__stack { display: flex; flex-direction: column; @@ -1236,10 +1359,10 @@ a.fab--secondary:visited { } .hero__eyebrow { - margin: 0 0 0.5rem; + margin: 0 0 0.6rem; font-size: 0.78rem; font-weight: 700; - color: var(--text-muted); + color: color-mix(in srgb, var(--text) 65%, transparent); letter-spacing: 0.14em; text-transform: uppercase; } @@ -1289,7 +1412,7 @@ html[data-font="mono"] .hero__title { .case-page__h1 { margin: 0 0 0.5rem; font-family: var(--font-accent); - font-size: clamp(1.35rem, 2.6vw, 1.95rem); + font-size: clamp(2rem, 3.9vw, 2.9rem); font-weight: 700; line-height: 1.2; color: var(--text); @@ -1320,7 +1443,7 @@ html[data-font="mono"] .hero__title { .case-page__sub { margin: 0 0 1.25rem; - font-size: clamp(1rem, 1.35vw, 1.1rem); + font-size: clamp(1.5rem, 2vw, 1.65rem); color: var(--text-muted); font-weight: 500; } @@ -1544,6 +1667,28 @@ body.case-page--hide-header .header { justify-content: center; } +/* Hover на кнопках панели плеера: только иконка в акцент, фон не меняем */ +.case-media .plyr--video .plyr__controls .plyr__control:hover { + background: color-mix(in srgb, var(--surface) 84%, var(--bg) 16%); + color: var(--accent); +} + +.case-media .plyr--video .plyr__controls .plyr__control svg { + fill: currentColor; +} + +/* Кнопка Play по центру: normal как у кнопок панели, hover — только иконка в акцент */ +.case-media .plyr--video .plyr__control--overlaid { + background: color-mix(in srgb, var(--surface) 84%, var(--bg) 16%); + color: var(--text-muted); + border: none; +} + +.case-media .plyr--video .plyr__control--overlaid:hover { + background: color-mix(in srgb, var(--surface) 84%, var(--bg) 16%); + color: var(--accent); +} + .case-media .plyr--video .plyr__controls .plyr__progress__container { height: auto; display: flex; @@ -1871,6 +2016,14 @@ video:-webkit-full-screen { --case-gallery-cols: 3; } +/* Wide-галерея: 4 колонки и выход за колонку контента (как у .case-media--wide) */ +.case-media-gallery--wide { + --case-gallery-cols: 4; + width: min(calc(100vw - (max(var(--space), env(safe-area-inset-left)) + max(var(--space), env(safe-area-inset-right)))), calc(100% + 8rem)); + margin-left: 50%; + transform: translateX(-50%); +} + .case-media-gallery > figcaption { width: 100%; text-align: center; @@ -2071,9 +2224,9 @@ img[data-image-viewer]:not([data-src]):focus-visible { position: fixed; inset: 0; z-index: 1400; - background: color-mix(in srgb, var(--bg) 58%, black); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); + background: color-mix(in srgb, var(--bg) 72%, transparent); + backdrop-filter: blur(22px) saturate(130%); + -webkit-backdrop-filter: blur(22px) saturate(130%); opacity: 0; transition: opacity 0.18s ease; } @@ -2127,11 +2280,18 @@ html[data-theme="dark"] { } .case-image-viewer__zoom { - min-width: 3.5rem; + min-width: 2.5rem; text-align: center; font-size: 0.82rem; - color: var(--text); - font-weight: 600; + color: var(--text-muted); + font-weight: 400; +} + +/* Группа слайдер + текст масштаба с уменьшенным внутренним зазором */ +.case-image-viewer__scale-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; } .case-image-viewer__slider { @@ -2269,42 +2429,101 @@ html[data-theme="dark"] { user-select: none; -webkit-user-drag: none; opacity: 0; - transform: scale(0.96); - transition: - transform 0.2s ease, - opacity 0.16s ease; + transition: opacity 0.16s ease; } -.case-image-viewer__loading { +/* TUI braille-спиннер (переиспользуемый, чистый CSS) */ +.tui-spinner { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1em; + height: 1.1em; + font-family: ui-monospace, "Cascadia Code", "Consolas", "Courier New", monospace; + font-size: 1.5rem; + line-height: 1; + letter-spacing: 0.1em; + color: var(--accent); +} + +.tui-spinner > span { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + animation: tui-spinner-step 0.9s steps(1, end) infinite; +} + +.tui-spinner > span:nth-child(1) { animation-delay: 0s; } +.tui-spinner > span:nth-child(2) { animation-delay: 0.09s; } +.tui-spinner > span:nth-child(3) { animation-delay: 0.18s; } +.tui-spinner > span:nth-child(4) { animation-delay: 0.27s; } +.tui-spinner > span:nth-child(5) { animation-delay: 0.36s; } +.tui-spinner > span:nth-child(6) { animation-delay: 0.45s; } +.tui-spinner > span:nth-child(7) { animation-delay: 0.54s; } +.tui-spinner > span:nth-child(8) { animation-delay: 0.63s; } +.tui-spinner > span:nth-child(9) { animation-delay: 0.72s; } +.tui-spinner > span:nth-child(10) { animation-delay: 0.81s; } + +@keyframes tui-spinner-step { + 0%, 9.999% { opacity: 1; } + 10%, 100% { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .tui-spinner > span { animation: none; opacity: 0; } + .tui-spinner > span:nth-child(1) { opacity: 1; } +} + +/* Универсальный эффект «продавливания» иконки при нажатии кнопки */ +.icon-press { + transition: transform 0.12s ease; +} + +[class*="btn"]:active:not(:disabled) .icon-press, +.btn:active:not(:disabled) .icon-press, +.case-image-viewer__btn--nav:active:not(:disabled) .icon-press, +.case-media-slider__nav.is-pressed:not(:disabled) .icon-press { + transform: scale(0.82); +} +.case-image-viewer__loader { position: absolute; left: 50%; top: 50%; z-index: 2; transform: translate(-50%, -50%); - min-width: 8.5rem; - padding: 0.5rem 0.8rem; - border-radius: 999px; - text-align: center; - font-size: 0.8rem; - color: var(--text); - background: color-mix(in srgb, var(--surface) 86%, transparent); - border: 1px solid color-mix(in srgb, var(--line) 88%, transparent); + display: inline-flex; + align-items: center; + text-shadow: 0 1px 6px hsla(0, 0%, 0%, 0.35); pointer-events: none; opacity: 0; transition: opacity 0.14s ease; } -.case-image-viewer.is-loading .case-image-viewer__loading { +.case-image-viewer.is-loading .case-image-viewer__loader { opacity: 1; } +.case-image-viewer__loader.is-error .tui-spinner { + display: none; +} + +.case-image-viewer__loader-msg { + font-family: var(--font, inherit); + font-size: 0.82rem; + color: var(--text-muted); + margin-left: 0.5rem; +} + .case-image-viewer.is-loading .case-image-viewer__img { opacity: 0; } .case-image-viewer.is-open .case-image-viewer__img { opacity: 1; - transform: scale(1); } .case-image-viewer__stage.is-panning, @@ -2312,6 +2531,91 @@ html[data-theme="dark"] { cursor: grabbing; } +/* Кнопки пред/след в тулбаре: группа с отрицательным зазором и скруглением только снаружи */ +.case-image-viewer__nav-group { + display: inline-flex; + border-radius: 999px; + overflow: hidden; +} + +.case-image-viewer__btn--nav { + --imgv-nav-size: 2rem; + width: var(--imgv-nav-size); + height: var(--imgv-nav-size); + padding: 0; + border-radius: 0; + display: inline-flex; + align-items: center; + justify-content: center; + /* Позиционируем, чтобы z-index работал: наведённая кнопка должна + рисоваться поверх соседа и показывать свой бордер целиком (включая шов). */ + position: relative; +} + +/* Соседняя кнопка сдвигается на 1px, перекрывая разделительный бордер */ +.case-image-viewer__btn--nav + .case-image-viewer__btn--nav { + margin-left: -1px; +} + +.case-image-viewer__btn--nav[hidden] { + display: none; +} + +/* Скругления только на крайних кнопках группы (все 4 угла заданы явно) */ +.case-image-viewer__btn--nav:first-child { + border-radius: 999px 0 0 999px; +} + +.case-image-viewer__btn--nav:last-child { + border-radius: 0 999px 999px 0; +} + +/* У одиночной кнопки в группе — скругление со всех сторон */ +.case-image-viewer__btn--nav:only-child { + border-radius: 999px; +} + +/* Наведённая/в фокусе кнопка поднимается над соседом: её бордер (включая + шовный 1px, перекрытый соседом из-за margin-left:-1px) рисуется целиком. */ +.case-image-viewer__btn--nav:hover, +.case-image-viewer__btn--nav:focus-visible { + z-index: 1; +} + +/* Точки-пагинация в тулбаре просмотрщика */ +.case-image-viewer__dots { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0 0.15rem; +} + +.case-image-viewer__dots[hidden] { + display: none; +} + +.case-image-viewer__dot { + width: 0.375rem; + height: 0.375rem; + padding: 0; + border: none; + border-radius: 50%; + background: color-mix(in srgb, var(--text-muted) 40%, transparent); + cursor: pointer; + transition: background 0.22s ease, width 0.28s cubic-bezier(0.22, 0.61, 0.36, 1), border-radius 0.28s ease; +} + +.case-image-viewer__dot:hover, +.case-image-viewer__dot:focus-visible { + background: color-mix(in srgb, var(--text-muted) 65%, transparent); +} + +.case-image-viewer__dot.is-active { + background: var(--accent); + width: calc(0.375rem * 2.6); + border-radius: 999px; +} + @media (max-width: 640px) { .case-link-card { grid-template-columns: 1fr; @@ -2378,6 +2682,7 @@ html[data-theme="dark"] { display: flex; flex-wrap: wrap; gap: 0.75rem; + margin-top: 2rem; } .btn { @@ -2549,7 +2854,7 @@ a.btn--primary:visited { /* Secondary: нейтральное стекло (--v12-glass-neutral-*); по умолчанию плоская */ .btn--secondary, .btn--ghost { - border: 1px solid var(--v12-glass-neutral-border); + border: 1px solid var(--card-border); color: var(--text); background: var(--v12-glass-neutral-bg); backdrop-filter: var(--v12-glass-neutral-filter); @@ -2566,7 +2871,7 @@ a.btn--primary:visited { .btn--secondary:focus-visible, .btn--ghost:hover, .btn--ghost:focus-visible { - border-color: color-mix(in srgb, var(--accent) 45%, var(--v12-glass-neutral-border)); + border-color: color-mix(in srgb, var(--accent) 45%, var(--card-border)); color: var(--text); backdrop-filter: var(--v12-glass-neutral-filter-elevated); -webkit-backdrop-filter: var(--v12-glass-neutral-filter-elevated); @@ -2586,7 +2891,7 @@ a.btn--ghost:visited { @media (min-width: 860px) { .hero__visual { - justify-content: flex-end; + justify-content: center; } } @@ -2602,7 +2907,6 @@ a.btn--ghost:visited { position: relative; border-radius: var(--radius-card); overflow: hidden; - border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-strong); } @@ -2683,6 +2987,93 @@ a.btn--ghost:visited { z-index: -1; } +/* + * 3D-наклон портрета по курсору (initHero3DTilt). + * perspective уже задан на .hero__visual; здесь — поворот самой карточки, + * параллакс слоёв и мягкий блик под курсором. + */ +.hero__portrait-shell { + --tilt-rx: 0deg; + --tilt-ry: 0deg; + --tilt-px: 50%; + --tilt-py: 36%; + transform: perspective(1300px) rotateX(var(--tilt-rx)) rotateY(var(--tilt-ry)); + transform-style: preserve-3d; + transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1); + will-change: transform; +} + +.hero__portrait-shell::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + border-radius: inherit; + opacity: 0; + transition: opacity 320ms ease; + filter: blur(18px); + /* Блик под курсором + тёмный противоположный край для объёма + + бегущая «стеклянная» полоса sheen. */ + background: + radial-gradient( + 340px circle at var(--tilt-px) var(--tilt-py), + hsla(0, 0%, 100%, 0.22), + hsla(0, 0%, 100%, 0) 72% + ), + radial-gradient( + 400px circle at calc(100% - var(--tilt-px)) calc(100% - var(--tilt-py)), + hsla(220, 30%, 10%, 0.22), + hsla(220, 30%, 10%, 0) 75% + ), + linear-gradient( + 120deg, + transparent 38%, + hsla(0, 0%, 100%, 0.06) 50%, + transparent 62% + ); + mix-blend-mode: screen; +} + +.hero__portrait-shell--tilting::before { + opacity: 1; +} + +.hero__portrait-shell--tilting { + transition: none; + /* Светлая рамка «стекла» при наклоне — усиливает 3D-ощущение. */ + box-shadow: + var(--shadow-strong), + inset 0 0 0 1px hsla(0, 0%, 100%, 0.28), + inset 0 0 22px hsla(0, 0%, 100%, 0.12); +} + +/* Параллакс: фоновый слой чуть глубже, motion-слой выше курсора. */ +.hero__portrait-shell--tilting .hero__portrait-layer--bg { + transform: translateZ(-18px) scale(1.06); +} + +.hero__portrait-shell--tilting .hero__portrait-layer--motion { + transform: translateZ(24px); +} + +.hero__portrait-layer { + transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.hero__portrait-shell--tilting .hero__portrait-layer { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .hero__portrait-shell { + transform: none; + } + .hero__portrait-shell::before { + display: none; + } +} + html[data-theme="light"] .hero__portrait-shell::after { background: hsla(var(--theme-h), 25%, 24%, 0.28); filter: blur(12px); @@ -2722,12 +3113,65 @@ html[data-theme="light"] .hero__portrait-shell { color: var(--text-muted); } +/* Главная: заголовки секций в стиле hero__eyebrow */ +body.home .section__title { + margin: 0 0 1.5rem; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + .section__prose { margin: 0; max-width: 42rem; color: var(--text-muted); } +/* Scroll-reveal: плавное появление секций при скролле */ +[data-reveal] { + opacity: 0; + transform: translateY(80px); + transition: opacity 0.4s ease-out, transform 0.6s ease-out; +} + +[data-reveal].is-visible { + opacity: 1; + transform: translateY(0); +} + +/* Stagger: заголовок появляется первым, контент — через 100ms */ +[data-reveal].is-visible .section__title { + transition-delay: 0.1s; +} + +[data-reveal].is-visible .section__inner > *:not(.section__title) { + transition-delay: 0.25s; +} + +/* Исключаем skills и mini-projects — у них своя stagger-анимация */ +#skills[data-reveal].is-visible .section__title, +#activity[data-reveal].is-visible .section__title { + transition-delay: 0s; +} + +#skills[data-reveal].is-visible .section__inner > *:not(.section__title), +#activity[data-reveal].is-visible .section__inner > *:not(.section__title) { + transition-delay: 0s; +} + +@media (prefers-reduced-motion: reduce) { + [data-reveal] { + opacity: 1; + transform: none; + transition: none; + } + [data-reveal].is-visible .section__title, + [data-reveal].is-visible .section__inner > *:not(.section__title) { + transition-delay: 0s; + } +} + .experience-grid { list-style: none; margin: 0; @@ -2787,7 +3231,7 @@ html[data-theme="light"] .hero__portrait-shell { position: relative; overflow: hidden; background: var(--surface); - border: 1px solid var(--line); + border: 1px solid var(--card-border); border-radius: var(--radius-card); padding: 1.15rem 1.25rem; display: flex; @@ -2946,7 +3390,8 @@ html[data-theme="light"] .hero__portrait-shell { padding: 0; position: relative; min-height: 1px; - overflow: hidden; + overflow: clip; + overflow-clip-margin: 44px; transition: height 360ms cubic-bezier(0.2, 0.65, 0.25, 1); } @@ -2972,17 +3417,18 @@ html[data-theme="light"] .hero__portrait-shell { overflow: hidden; width: 100%; min-height: 100%; - border: 1px solid var(--line); + height: 100%; + border: 1px solid var(--card-border); border-radius: var(--radius-card); background: var(--surface); color: inherit; text-decoration: none; - transition: border-color 0.22s ease; + transition: border-color 0.22s ease, transform 0.28s ease, box-shadow 0.28s ease; } .mp-card:hover, .mp-card:focus-visible { - border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); + border-color: color-mix(in srgb, var(--accent) 58%, var(--card-border)); } .mp-card--link { @@ -2994,6 +3440,14 @@ html[data-theme="light"] .hero__portrait-shell { outline-offset: 3px; } +.mp-card--link:hover, +.mp-card--link:focus-visible { + transform: translateY(-5px); + z-index: 2; + box-shadow: 0 8px 18px -10px color-mix(in srgb, var(--accent) 28%, transparent), + 0 2px 6px -3px color-mix(in srgb, var(--accent) 14%, transparent); +} + .mp-card__body { position: relative; z-index: 1; @@ -3001,6 +3455,7 @@ html[data-theme="light"] .hero__portrait-shell { flex-direction: column; gap: 0.5rem; min-width: 0; + overflow: hidden; } .mp-card__title { @@ -3015,6 +3470,7 @@ html[data-theme="light"] .hero__portrait-shell { align-items: center; flex-wrap: wrap; gap: 0.35rem 0.45rem; + min-width: 0; } .mp-card__desc { @@ -3022,6 +3478,10 @@ html[data-theme="light"] .hero__portrait-shell { font-size: 0.88rem; line-height: 1.45; color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; } .mp-card__tags { @@ -3036,15 +3496,16 @@ html[data-theme="light"] .hero__portrait-shell { .mp-card__tag { display: inline-flex; align-items: center; - padding: 0.22rem 0.58rem; - font-size: 0.72rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: lowercase; + padding: 0.35rem 0.7rem; + border: 0; + border-radius: var(--radius-btn); + background: color-mix(in srgb, var(--surface) 62%, var(--bg-elevated) 38%); color: var(--text-muted); - border: 1px solid color-mix(in srgb, var(--line) 92%, var(--text-muted)); - border-radius: 999px; - line-height: 1.2; + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.01em; + text-transform: none; + white-space: nowrap; } .mp-card__media { @@ -3054,7 +3515,7 @@ html[data-theme="light"] .hero__portrait-shell { display: flex; align-items: center; justify-content: center; - overflow: visible; + overflow: hidden; background: transparent; box-sizing: border-box; } @@ -3066,7 +3527,7 @@ html[data-theme="light"] .hero__portrait-shell { height: auto; object-fit: cover; object-position: center; - border: 1px solid color-mix(in srgb, var(--line) 84%, var(--text) 16%); + border: 1px solid var(--card-border); border-radius: calc(var(--radius-card) - 4px); box-sizing: border-box; } @@ -3084,6 +3545,7 @@ html[data-theme="light"] .hero__portrait-shell { .mp-card--stack .mp-card__media { width: 100%; + flex-shrink: 0; padding: 1.2rem 1.35rem 0; } @@ -3091,6 +3553,62 @@ html[data-theme="light"] .hero__portrait-shell { aspect-ratio: 16 / 9; } +.mp-card--stack-bottom { + flex-direction: column-reverse; + align-items: stretch; + gap: 0; + padding: 0; +} + +.mp-card--stack-bottom .mp-card__body { + padding: 1.2rem 1.35rem 1.45rem; +} + +.mp-card--stack-bottom .mp-card__media { + width: 100%; + flex-shrink: 0; + padding: 0 1.35rem 1.2rem; +} + +.mp-card--stack-bottom .mp-card__image { + aspect-ratio: 16 / 9; +} + +.mp-card--stack-bottom.mp-card--hor-image .mp-card__image { + object-fit: contain; + aspect-ratio: 16 / 9; + background: color-mix(in srgb, var(--text-muted) 10%, var(--surface)); +} + +/* внутри варианта: Название → микротекст → тэг */ +.mp-card--stack-bottom .mp-card__desc { + order: 1; +} + +.mp-card--stack-bottom .mp-card__tags { + order: 2; +} + +/* текст как в карточках опыта работы */ +.mp-card--stack-bottom .mp-card__title { + font-size: clamp(1.14rem, 2.6vw, 1.38rem); +} + +.mp-card--stack-bottom .mp-card__desc { + font-size: 0.92rem; +} + +/* теги портфолио: пастельный цвет по hue тега (задаёт JS), темо-зависимый */ +.mp-card--stack-bottom .mp-card__tag { + background: hsl(var(--tag-hue, 0) 58% 86%); + color: hsl(var(--tag-hue, 0) 42% 32%); +} + +html[data-theme="dark"] .mp-card--stack-bottom .mp-card__tag { + background: hsl(var(--tag-hue, 0) 45% 30%); + color: hsl(var(--tag-hue, 0) 58% 84%); +} + .mp-card--horizontal { flex-direction: row; align-items: stretch; @@ -3129,6 +3647,15 @@ html[data-theme="light"] .hero__portrait-shell { gap: 0.55rem; } +/* активность: теги внизу карточки */ +.mp-card--compact .mp-card__desc { + order: 1; +} + +.mp-card--compact .mp-card__tags { + order: 2; +} + .mp-card--decor { min-height: 7.5rem; padding: 0; @@ -3347,7 +3874,7 @@ html[data-theme="light"] .hero__portrait-shell { height: auto; padding: 0; border-radius: var(--radius-card); - border: 1px solid var(--line); + border: 1px solid var(--card-border); background: color-mix(in srgb, var(--surface) 94%, transparent); color: inherit; text-decoration: none; @@ -3357,7 +3884,7 @@ html[data-theme="light"] .hero__portrait-shell { .portfolio-card:hover, .portfolio-card:focus-visible { - border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); + border-color: color-mix(in srgb, var(--accent) 55%, var(--card-border)); } .portfolio-card:focus-visible { @@ -3469,7 +3996,7 @@ html[data-font="mono"] .portfolio-card__title { object-fit: cover; object-position: center; filter: none; - border: 1px solid color-mix(in srgb, var(--line) 84%, var(--text) 16%); + border: 1px solid var(--card-border); border-radius: calc(var(--radius-card) - 4px); margin-left: 0; } @@ -3537,8 +4064,8 @@ html[data-font="mono"] .portfolio-card__title { } .skills-intro--chat .skills-msg { - --skills-bubble-fill: color-mix(in srgb, var(--surface) 86%, var(--accent) 14%); - --skills-bubble-edge: color-mix(in srgb, var(--line), var(--text) 8%); + --skills-bubble-fill: color-mix(in srgb, var(--surface) 94%, var(--accent) 6%); + --skills-bubble-edge: var(--card-border); align-self: flex-start; width: max-content; max-width: min(52rem, 100%); @@ -3638,15 +4165,15 @@ html[data-font="mono"] .portfolio-card__title { .skills-category { position: relative; border-radius: 0; - padding: clamp(0.75rem, 1.6vw, 1.18rem) 0; + padding: clamp(0.5rem, 1vw, 0.8rem) 0; } .skills-category__title { margin: 0; font-family: var(--font-accent); - font-size: clamp(1rem, 1.55vw, 1.18rem); + font-size: clamp(1.14rem, 2.6vw, 1.38rem); font-weight: 700; - letter-spacing: 0.03em; + letter-spacing: -0.02em; text-transform: uppercase; color: color-mix(in srgb, var(--text) 82%, var(--text-muted)); } @@ -3685,6 +4212,7 @@ html[data-font="mono"] .portfolio-card__title { .skills-category__title-text { min-width: 0; + text-transform: none; } .skills-category__chevron { @@ -3733,15 +4261,15 @@ html[data-font="mono"] .portfolio-card__title { } html[data-font="geologica"] .skills-category__title { - font-weight: 800; + font-weight: 700; } .skills-category__grid { display: grid; - gap: 1.5rem; + gap: 1.1rem; grid-template-columns: 1fr; - padding-top: 0.45rem; - padding-bottom: 0.55rem; + padding-top: 0.3rem; + padding-bottom: 0.35rem; } @media (min-width: 720px) { @@ -3798,12 +4326,12 @@ html[data-font="geologica"] .skills-category__title { .skills-category__title { position: static; - margin-bottom: 1rem; + margin-bottom: 0.6rem; z-index: auto; line-height: 1.22; max-width: none; - font-size: clamp(1.08rem, 1.85vw, 1.48rem); - letter-spacing: 0.025em; + font-size: clamp(1.14rem, 2.6vw, 1.38rem); + letter-spacing: -0.02em; } } @@ -3862,13 +4390,13 @@ html[data-skill-connectors-js] .skill-task-card__solution { position: relative; margin: 0; padding: 0.82rem; - font-size: clamp(1rem, 1.25vw, 1.12rem); + font-size: 1rem; font-weight: 600; line-height: 1.35; color: var(--text); flex: 0 0 auto; background: var(--surface); - border: 1px solid color-mix(in srgb, var(--line), var(--text) 6%); + border: 1px solid var(--card-border); border-radius: calc(var(--radius-sm) + 2px); transition: border-color 0.22s ease; } @@ -3912,7 +4440,7 @@ html[data-skill-connectors-js] .skill-task-card__solution { line-height: 1.45; color: var(--text-muted); background: var(--surface); - border: 1px solid color-mix(in srgb, var(--skill-solution-separator), var(--text) 6%); + border: 1px solid var(--card-border); border-radius: calc(var(--radius-sm) + 2px); display: inline-block; vertical-align: top; @@ -4045,13 +4573,7 @@ html[data-radius="strong"] .skill-task-card__solution { @media (min-width: 560px) { .contact-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (min-width: 1024px) { - .contact-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); } } @@ -4069,14 +4591,14 @@ html[data-radius="strong"] .skill-task-card__solution { width: 100%; min-height: 100%; padding: 1rem 2.35rem 1rem 1.1rem; - border: 1px solid var(--line); + border: 1px solid var(--card-border); border-radius: var(--radius-card); background: var(--surface); color: inherit; text-decoration: none; overflow: hidden; isolation: isolate; - transition: border-color 0.22s ease; + transition: border-color 0.22s ease, transform 0.28s ease, box-shadow 0.28s ease; } /* Бренд канала = цвет из соответствующей иконки (см. SVG в assets/shared/icons/) */ @@ -4088,6 +4610,10 @@ html[data-radius="strong"] .skill-task-card__solution { --contact-brand: #0077ff; } +.contact-card--brand-whatsapp { + --contact-brand: #25D366; +} + .contact-card--brand-ya { --contact-brand: #f8604a; } @@ -4114,7 +4640,11 @@ html[data-radius="strong"] .skill-task-card__solution { .contact-card:hover, .contact-card:focus-visible { - border-color: color-mix(in srgb, var(--contact-brand) 58%, var(--line)); + border-color: color-mix(in srgb, var(--contact-brand) 58%, var(--card-border)); + transform: translateY(-5px); + z-index: 2; + box-shadow: 0 8px 18px -10px color-mix(in srgb, var(--contact-brand) 28%, transparent), + 0 2px 6px -3px color-mix(in srgb, var(--contact-brand) 14%, transparent); } @media (prefers-reduced-motion: reduce) { @@ -4301,7 +4831,6 @@ a.contact-card--link:focus-visible { } .footer__attr { - font-size: 0.75rem; opacity: 0.75; } @@ -4384,6 +4913,12 @@ html { overflow-x: hidden; } +@media (max-width: 1024px) { + .hero__title { + font-size: clamp(2.6rem, 6.2vw, 3.9rem); + } +} + @media (max-width: 639.98px) { html { font-size: 0.9375rem; @@ -4407,6 +4942,30 @@ html { font-size: clamp(2.45rem, 10vw, 3.35rem); } +} + +@media (max-width: 480px) { + .hero__title { + font-size: clamp(2.05rem, 9vw, 2.7rem); + } +} + +@media (max-width: 360px) { + .hero__title { + font-size: clamp(1.8rem, 8.5vw, 2.3rem); + } + + .hero__actions { + flex-direction: column; + align-items: stretch; + gap: 0.65rem; + } + + .hero__actions .btn { + width: 100%; + } +} + .hero-rotator { min-width: 7ch; } @@ -4422,13 +4981,10 @@ html { } .hero__actions { - flex-direction: column; - align-items: stretch; gap: 0.65rem; } .hero__actions .btn { - width: 100%; justify-content: center; min-height: 48px; padding: 0.75rem 1.25rem; @@ -4584,6 +5140,9 @@ html[data-theme="dark"] { inset 0 1px 0 hsla(0, 0%, 100%, 0.09); --v12-glass-neutral-filter: blur(16px) saturate(118%); --v12-glass-neutral-filter-elevated: blur(18px) saturate(125%); + /* Liquid Glass (Хабр): искажение + лёгкое матирование — только Chromium */ + --v12-glass-neutral-filter-lg: url(#liquid-glass-displacement) blur(3px); + --v12-glass-neutral-filter-elevated-lg: url(#liquid-glass-displacement) blur(3px); } html[data-theme="light"] { @@ -4599,6 +5158,9 @@ html[data-theme="light"] { inset 0 -1px 0 hsla(0, 0%, 0%, 0.04); --v12-glass-neutral-filter: blur(12px) saturate(124%); --v12-glass-neutral-filter-elevated: blur(14px) saturate(130%); + /* Liquid Glass (Хабр): искажение + лёгкое матирование — только Chromium */ + --v12-glass-neutral-filter-lg: url(#liquid-glass-displacement) blur(3px); + --v12-glass-neutral-filter-elevated-lg: url(#liquid-glass-displacement) blur(3px); } html[data-theme="dark"] .header--scrolled .header__inner, @@ -4615,12 +5177,25 @@ html[data-theme="light"] .header--scrolled .header__inner { html[data-theme="dark"] .glass-neutral, html[data-theme="light"] .glass-neutral { background: var(--v12-glass-neutral-bg); - border-color: var(--v12-glass-neutral-border); + border-color: transparent; box-shadow: var(--v12-glass-neutral-shadow); backdrop-filter: var(--v12-glass-neutral-filter); -webkit-backdrop-filter: var(--v12-glass-neutral-filter); } +/* Бордюр убран полностью (border: none) — только внутренняя тень. + Геометрия сместится на 1px (border-box), но это приемлемо для glass-блоков. */ +html[data-theme="dark"] .glass-neutral:not(.fab), +html[data-theme="light"] .glass-neutral:not(.fab), +html[data-theme="dark"] .header--scrolled .header__inner, +html[data-theme="light"] .header--scrolled .header__inner, +html[data-theme="dark"] .nav-more__dock, +html[data-theme="light"] .nav-more__dock, +html[data-theme="dark"] #nav-more-menu-floating, +html[data-theme="light"] #nav-more-menu-floating { + border: none; +} + html[data-theme="dark"] .theme-picker__panel.glass-neutral, html[data-theme="dark"] .cookie-notice.glass-neutral, html[data-theme="light"] .theme-picker__panel.glass-neutral, @@ -4637,6 +5212,85 @@ html[data-theme="light"] .fab.glass-neutral:not(.fab--primary):focus-visible { -webkit-backdrop-filter: var(--v12-glass-neutral-filter-elevated); } +/* Liquid Glass (Хабр): Chromium-only displacement. + Safari/Firefox НЕ рендерят url()-фильтр в backdrop-filter — остаётся чистый blur. + Детект: Chrome не поддерживает hanging-punctuation, Safari/Firefox — поддерживают. */ +@supports not (hanging-punctuation: first) { + /* Базовый (не-elevated) слой — только .glass-neutral */ + html[data-theme="dark"] .glass-neutral, + html[data-theme="light"] .glass-neutral { + backdrop-filter: var(--v12-glass-neutral-filter-lg); + -webkit-backdrop-filter: var(--v12-glass-neutral-filter-lg); + } + + /* Elevated-слой: шапка, панели, FAB при наведении, overflow-меню */ + html[data-theme="dark"] .header--scrolled .header__inner, + html[data-theme="light"] .header--scrolled .header__inner, + html[data-theme="dark"] .theme-picker__panel.glass-neutral, + html[data-theme="dark"] .cookie-notice.glass-neutral, + html[data-theme="light"] .theme-picker__panel.glass-neutral, + html[data-theme="light"] .cookie-notice.glass-neutral, + html[data-theme="dark"] .fab.glass-neutral:not(.fab--primary):hover, + html[data-theme="dark"] .fab.glass-neutral:not(.fab--primary):focus-visible, + html[data-theme="light"] .fab.glass-neutral:not(.fab--primary):hover, + html[data-theme="light"] .fab.glass-neutral:not(.fab--primary):focus-visible, + html[data-theme="dark"] .nav-more__dock, + html[data-theme="light"] .nav-more__dock, + html[data-theme="dark"] #nav-more-menu-floating, + html[data-theme="light"] #nav-more-menu-floating { + backdrop-filter: var(--v12-glass-neutral-filter-elevated-lg); + -webkit-backdrop-filter: var(--v12-glass-neutral-filter-elevated-lg); + } + + /* Кнопки: primary/fab — акцентное стекло + displacement; + secondary/ghost — нейтральное стекло + displacement */ + html[data-theme="dark"] .btn--primary, + html[data-theme="light"] .btn--primary, + html[data-theme="dark"] .fab--primary, + html[data-theme="light"] .fab--primary { + background: var(--btn-primary-bg-lg); + backdrop-filter: var(--btn-primary-backdrop-lg); + -webkit-backdrop-filter: var(--btn-primary-backdrop-lg); + } + + html[data-theme="dark"] .btn--primary:hover, + html[data-theme="dark"] .btn--primary:focus-visible, + html[data-theme="light"] .btn--primary:hover, + html[data-theme="light"] .btn--primary:focus-visible, + html[data-theme="dark"] a.btn--primary:visited:hover, + html[data-theme="light"] a.btn--primary:visited:hover, + html[data-theme="dark"] .fab--primary:hover, + html[data-theme="dark"] .fab--primary:focus-visible, + html[data-theme="light"] .fab--primary:hover, + html[data-theme="light"] .fab--primary:focus-visible, + html[data-theme="dark"] a.fab--primary:visited, + html[data-theme="light"] a.fab--primary:visited { + background: var(--btn-primary-hover-bg-lg); + backdrop-filter: var(--btn-primary-backdrop-hover-lg); + -webkit-backdrop-filter: var(--btn-primary-backdrop-hover-lg); + } + + html[data-theme="dark"] .btn--secondary, + html[data-theme="light"] .btn--secondary, + html[data-theme="dark"] .btn--ghost, + html[data-theme="light"] .btn--ghost { + backdrop-filter: var(--v12-glass-neutral-filter-lg); + -webkit-backdrop-filter: var(--v12-glass-neutral-filter-lg); + } + + html[data-theme="dark"] .btn--secondary:hover, + html[data-theme="dark"] .btn--secondary:focus-visible, + html[data-theme="light"] .btn--secondary:hover, + html[data-theme="light"] .btn--secondary:focus-visible, + html[data-theme="dark"] .btn--ghost:hover, + html[data-theme="dark"] .btn--ghost:focus-visible, + html[data-theme="light"] .btn--ghost:hover, + html[data-theme="light"] .btn--ghost:focus-visible { + backdrop-filter: var(--v12-glass-neutral-filter-elevated-lg); + -webkit-backdrop-filter: var(--v12-glass-neutral-filter-elevated-lg); + } +} + html[data-theme="dark"] .nav-more__dock, html[data-theme="light"] .nav-more__dock, html[data-theme="dark"] #nav-more-menu-floating, @@ -4694,7 +5348,7 @@ html[data-theme="light"] .cookie-notice .cookie-notice__text { var(--accent-btn-radial-mid) 52%, var(--accent-btn-radial-edge) 100% ); - border-color: transparent; + border-color: var(--v12-glass-neutral-border); box-shadow: var(--shadow-strong); backdrop-filter: none; -webkit-backdrop-filter: none; @@ -5061,3 +5715,713 @@ body.content-edit-shift-active.content-edit-mode-active .content-edit-fab-stack border-bottom: 1px solid var(--line); } } + +/* Marquee — бегущая строка */ +.marquee { + overflow: hidden; +} + +.marquee__track { + display: flex; + width: max-content; + animation: marquee 30s linear infinite; +} + +.marquee__content { + display: flex; + gap: 1rem; + padding-right: 1rem; +} + +.marquee__content .hero__tag { + white-space: nowrap; + flex-shrink: 0; +} + +/* Альтернативный вариант marquee: статичная flex-сетка чипсов */ +.marquee--grid { + /* padding inherited from .section */ +} +.marquee-grid { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 1.5rem 3rem; + justify-content: center; +} +.marquee-grid__cell { + margin: 0; + flex: 0 0 auto; + opacity: 0.4; + animation: tagFlicker var(--tag-cycle, 15s) ease-in-out infinite; +} +.marquee-grid__cell .hero__tag { + display: inline; + margin: 0; + padding: 0; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + white-space: nowrap; + font-size: inherit; + font-weight: 500; + line-height: 1.4; + color: var(--text); +} + +/* Flicker: теги держим на базовом уровне 20%, по очереди (по 3 за раз, вразнобой) вспыхивают до 100%. + Задержки — перестановка со шагом 7 (взаимно просто с 15), чтобы горели не соседние, а разбросанные теги. + Цикл 15s, шаг 1s; яркими (≈100%) одновременно ≤3 тега, остальные тускло видны (20%). */ +.marquee-grid__cell:nth-child(1) { animation-delay: -7s; } +.marquee-grid__cell:nth-child(2) { animation-delay: -14s; } +.marquee-grid__cell:nth-child(3) { animation-delay: -6s; } +.marquee-grid__cell:nth-child(4) { animation-delay: -13s; } +.marquee-grid__cell:nth-child(5) { animation-delay: -5s; } +.marquee-grid__cell:nth-child(6) { animation-delay: -12s; } +.marquee-grid__cell:nth-child(7) { animation-delay: -4s; } +.marquee-grid__cell:nth-child(8) { animation-delay: -11s; } +.marquee-grid__cell:nth-child(9) { animation-delay: -3s; } +.marquee-grid__cell:nth-child(10) { animation-delay: -10s; } +.marquee-grid__cell:nth-child(11) { animation-delay: -2s; } +.marquee-grid__cell:nth-child(12) { animation-delay: -9s; } +.marquee-grid__cell:nth-child(13) { animation-delay: -1s; } +.marquee-grid__cell:nth-child(14) { animation-delay: -8s; } +.marquee-grid__cell:nth-child(15) { animation-delay: 0s; } + +@keyframes tagFlicker { + 0% { opacity: 1; } + 14% { opacity: 1; } + 19% { opacity: 0.4; } + 95% { opacity: 0.4; } + 100% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .marquee-grid__cell { + opacity: 1; + animation: none; + } +} + +/* Marquee появляется мягче, чем обычные секции (без большого сдвига) */ +.marquee[data-reveal] { + opacity: 0; + transform: translateY(16px); + transition: opacity 0.5s ease, transform 0.5s ease; +} +.marquee[data-reveal].is-visible { + opacity: 1; + transform: translateY(0); +} +@media (prefers-reduced-motion: reduce) { + .marquee[data-reveal] { + opacity: 1; + transform: none; + transition: none; + } +} + +@keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-33.333%); } +} + +/* Separator */ +.sep { + border: none; + margin: 0; + padding: 0; +} +.sep--line { + border-top: 1px solid var(--line); + height: 0; +} +.sep--air { + height: 1.5rem; +} +.sep--air-lg { + height: 2.5rem; +} + +/* Резюме: страница /resume/ (article + section/ol/dl/time) */ +.resume-doc { + max-width: 52rem; + margin: 0 auto; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-card); + padding: clamp(1.5rem, 3vw, 2.5rem); +} +.resume-header h1 { + margin: 0 0 0.25rem; +} +/* Верхняя часть документа: flex-колонка, чтобы вертикальные margin не схлопывались */ +.resume-doc__head { + display: flex; + flex-direction: column; +} +/* Список должностей под именем: обычный текст, каждая с новой строки */ +.resume-roles { + line-height: 1.45; + color: var(--text); +} +.resume-header { + display: flex; + gap: 1.5rem; + align-items: flex-start; + margin-bottom: 1.5rem; +} +.resume-photo { + width: auto; + height: 160px; + max-width: 40%; + border-radius: var(--radius-sm); + object-fit: contain; + flex-shrink: 0; + order: 1; + margin-left: auto; +} +@media (max-width: 32rem) { + .resume-photo { + height: 128px; + max-width: 55%; + } +} +@media (max-width: 32rem) { + .resume-header { + flex-direction: column; + align-items: center; + text-align: center; + } + .resume-photo { + width: 100px; + height: 100px; + } +} +/* H2 — раздел документа: самый крупный уровень, акцентный шрифт. Разделение — отступом, не линией */ +.resume-section__title { + margin: 4.5rem 0 1.5rem; + font-family: var(--font-accent); + font-size: clamp(1.3rem, 2.4vw, 1.6rem); + font-weight: 700; + line-height: 1.2; + color: var(--text); + letter-spacing: -0.015em; +} +.resume-doc .section__prose + .section__prose { + margin-top: 0.75rem; +} +.resume-doc .section__prose a { + color: var(--accent); +} +.resume-meta { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem 3rem; + margin: 1.5rem 0; + font-size: 0.92rem; + color: var(--text); +} +@media (max-width: 32rem) { + .resume-meta { + grid-template-columns: 1fr; + } +} +.resume-meta dt { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + letter-spacing: 0.03em; + margin-bottom: 0.1rem; +} +.resume-meta dd { + margin: 0; +} +.resume-meta a { + color: var(--accent); +} +.resume-exp { + margin: 3rem 0; + padding-bottom: 0; +} +.resume-exp__header { + margin-bottom: 0.5rem; +} +/* H3 — компания: второй уровень. Без акцентного цвета, чтобы не мимикрировать под ссылку */ +.resume-exp__company { + margin: 0; + font-family: var(--font-accent); + font-weight: 700; + font-size: clamp(1.05rem, 1.6vw, 1.2rem); + line-height: 1.25; + color: var(--text); + letter-spacing: -0.01em; +} +.resume-exp__meta-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 0.25rem; +} +.resume-exp__meta-row a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid var(--accent); + transition: opacity 0.2s; +} +.resume-exp__meta-row a:hover { + opacity: 0.7; +} +.resume-exp__sep { + color: var(--line); +} +.resume-exp__meta-row time { + white-space: nowrap; +} +.resume-exp__period { + font-size: 0.8rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} +.resume-exp__desc { + color: var(--text); + font-size: 0.92rem; + margin: 0.5rem 0; + line-height: 1.55; +} +.resume-exp__section { + margin: 1.5rem 0; +} +.resume-exp__section:last-child { + margin-bottom: 0; +} +/* Разделитель-отступ между проектами внутри одного места работы */ +.resume-exp__divider { + height: 1.5rem; +} +.resume-exp__cols { + display: grid; + grid-template-columns: 1fr 1fr 10rem; + gap: 0 1.5rem; +} +@media (max-width: 48rem) { + .resume-exp__cols { + grid-template-columns: 1fr; + } +} +/* H4 — продукт внутри компании: мельче H3, со сдвигом и направляющей (визуальная вложенность) */ +.resume-exp__section { + padding-left: 0.9rem; + border-left: 2px solid var(--line); +} +.resume-exp__section-title { + margin: 0 0 0.5rem; + font-weight: 600; + font-size: clamp(0.88rem, 1.1vw, 0.95rem); + line-height: 1.35; + color: var(--text); + letter-spacing: 0.01em; +} +/* Метки колонок (H4/H5): тот же визуальный стиль, что у .resume-skill__title */ +.resume-exp__label { + font-family: inherit; + font-weight: 600; + color: var(--text); + font-size: 0.85rem; + letter-spacing: 0.02em; + text-transform: none; + margin: 0.75rem 0 0.4rem; +} +/* H4-метка (без продуктов) — тот же размер: различие только по уровню заголовка */ +h4.resume-exp__label { + font-size: 0.85rem; +} +.resume-exp-list { + list-style: none; + margin: 0; + padding: 0; +} +.resume-exp ul { + margin: 0; + padding-left: 1.1rem; + color: var(--text); + font-size: 0.92rem; + line-height: 1.55; +} +/* Буллеты: залитая точка, цвет как у текста */ +.resume-exp ul { + list-style: none; +} +.resume-exp ul li { + position: relative; + padding-left: 0.85rem; +} +.resume-exp ul li::before { + content: ""; + position: absolute; + left: 0; + top: 0.62em; + width: 0.3rem; + height: 0.3rem; + border-radius: 50%; + background: var(--text); +} +.resume-exp ul li + li { + margin-top: 0.6rem; +} +.resume-skills { + margin: 1rem 0; + display: flex; + flex-direction: column; + gap: 1rem; +} +.resume-skill { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-size: 0.92rem; + line-height: 1.55; +} +.resume-skill__title { + font-weight: 600; + color: var(--text); + min-width: 8rem; + flex-shrink: 0; + font-size: 0.85rem; + letter-spacing: 0.02em; + margin: 0; +} +.resume-skill__desc { + color: var(--text); + margin: 0; +} +.resume-about { + margin: 1.5rem 0; + font-size: 0.92rem; + color: var(--text); + line-height: 1.55; +} +/* Слайдер изображений в лонгриде кейса: одна картинка + стрелки + точки */ +.case-media-slider { + --slider-gap: 0; + --slider-radius: var(--radius-card); + --slider-ctrl-size: 2.5rem; + --slider-ctrl-inset: 0.75rem; + --slider-dot-size: 0.375rem; + --slider-dot-gap: 0.375rem; + position: relative; + margin: 0.85rem 0 1.2rem; + border-radius: var(--slider-radius); +} + +.case-media-slider__viewport { + position: relative; + overflow: hidden; + width: 100%; + border: 1px solid var(--line); + border-radius: var(--slider-radius); + box-sizing: border-box; + background: color-mix(in srgb, var(--surface) 55%, transparent); + touch-action: pan-y; + cursor: grab; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.case-media-slider:hover .case-media-slider__viewport { + border-color: var(--accent); + box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 18%, transparent); +} + +.case-media-slider__viewport:active { + cursor: grabbing; +} + +.case-media-slider__track { + display: flex; + width: 100%; + transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1); + will-change: transform; +} + +.case-media-slider__slide { + flex: 0 0 100%; + min-width: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.case-media-slider__slide img { + display: block; + width: 100%; + height: auto; + max-height: 80vh; + object-fit: contain; +} + +/* Подпись под активным слайдом (как у case-media) */ +.case-media-slider__slide figcaption, +.case-media-slider figcaption { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.6rem 0.9rem; + font-size: 0.82rem; + color: var(--text-muted); + text-align: center; + background: color-mix(in srgb, var(--surface) 78%, transparent); + backdrop-filter: blur(6px); +} + +/* Стрелки */ +/* Группа кнопок пред/след — слитая «пилюля» в нижнем правом углу */ +.case-media-slider__nav-group { + position: absolute; + right: var(--slider-ctrl-inset); + bottom: var(--slider-ctrl-inset); + z-index: 3; + display: inline-flex; + border-radius: 999px; + overflow: hidden; + opacity: 0; + transition: opacity 0.18s ease; +} + +.case-media-slider:hover .case-media-slider__nav-group, +.case-media-slider__nav-group:focus-within { + opacity: 1; +} + +.case-media-slider__nav { + width: var(--slider-ctrl-size); + height: var(--slider-ctrl-size); + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line); + border-radius: 0; + background: color-mix(in srgb, var(--surface) 86%, transparent); + color: var(--text-muted); + cursor: pointer; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease; +} + +/* Соседняя кнопка перекрывает разделительный бордер */ +.case-media-slider__nav + .case-media-slider__nav { + margin-left: -1px; +} + +/* Скругления только на крайних кнопках группы */ +.case-media-slider__nav:first-child { + border-radius: 999px 0 0 999px; +} + +.case-media-slider__nav:last-child { + border-radius: 0 999px 999px 0; +} + +.case-media-slider__nav:hover:not(:disabled), +.case-media-slider__nav:focus-visible:not(:disabled) { + color: var(--accent-text); + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + position: relative; + z-index: 1; +} + +.case-media-slider__nav:disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +.case-media-slider__nav-ico { + width: 1rem; + height: 1rem; + display: block; + background: currentColor; + mask-size: contain; + -webkit-mask-size: contain; + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-position: center; + transition: transform 0.12s ease; +} + +/* Нажатие: иконка слегка «продавливается» внутрь (общий .icon-press) */ + +.case-media-slider__nav-ico--left { + mask-image: url("../assets/shared/icons/shevron-left.svg"); + -webkit-mask-image: url("../assets/shared/icons/shevron-left.svg"); +} + +.case-media-slider__nav-ico--right { + mask-image: url("../assets/shared/icons/shevron-right.svg"); + -webkit-mask-image: url("../assets/shared/icons/shevron-right.svg"); +} + +/* Точки-пагинация */ +.case-media-slider__dots { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--slider-dot-gap); + margin-top: 0.9rem; + padding: 0.2rem 0; +} + +.case-media-slider__dot { + width: var(--slider-dot-size); + height: var(--slider-dot-size); + padding: 0; + border: none; + border-radius: 50%; + background: color-mix(in srgb, var(--text-muted) 40%, transparent); + cursor: pointer; + transition: background 0.22s ease, width 0.28s cubic-bezier(0.22, 0.61, 0.36, 1), border-radius 0.28s ease; +} + +.case-media-slider__dot:hover, +.case-media-slider__dot:focus-visible { + background: color-mix(in srgb, var(--text-muted) 65%, transparent); +} + +.case-media-slider__dot.is-active { + background: var(--accent); + width: calc(var(--slider-dot-size) * 2.6); + border-radius: 999px; +} + +/* Неактивные слайды: скрыты от скринридеров (aria-hidden в JS), но остаются видимыми, + чтобы при сдвиге трека не было «дырки» от мгновенно исчезнувшего кадра. */ +.case-media-slider__slide:not(.is-active) { + pointer-events: none; +} + +/* Настраиваемая ширина — как у статичных .case-media (--wide / --content / --natural). + Кнопки пред/след внутри viewport, поэтому не участвуют в расчёте ширины. + Высота — автоматически по контенту. */ + +/* Базовый / --content: встроен в колонку контента, не шире её */ +.case-media-slider, +.case-media-slider.case-media--content { + display: block; + width: 100%; + max-width: 100%; + margin-inline: auto; +} + +/* --wide: как одиночные wide-картинки страницы — выходит за колонку, центр по вьюпорту */ +.case-media-slider.case-media--wide { + width: min(calc(100vw - (max(var(--space), env(safe-area-inset-left)) + max(var(--space), env(safe-area-inset-right)))), calc(100% + 8rem)); + max-width: none; + margin-left: 50%; + transform: translateX(-50%); +} + +/* --natural / auto: ширина по картинке (--slider-natural-w или авто из JS), не больше ширины экрана */ +.case-media-slider.case-media--natural { + display: block; + width: min(var(--slider-natural-w, 100%), 100vw); + max-width: 100vw; + margin-left: 50%; + transform: translateX(-50%); +} + +.case-media-slider .case-media-slider__viewport, +.case-media-slider.case-media--content .case-media-slider__viewport, +.case-media-slider.case-media--wide .case-media-slider__viewport, +.case-media-slider.case-media--natural .case-media-slider__viewport { + width: auto; + max-width: 100%; + margin-inline: auto; +} + +.case-media-slider .case-media-slider__slide img, +.case-media-slider.case-media--content .case-media-slider__slide img, +.case-media-slider.case-media--wide .case-media-slider__slide img, +.case-media-slider.case-media--natural .case-media-slider__slide img { + width: auto; + max-width: 100%; + max-height: none; + height: auto; + margin-inline: auto; +} + +@media (prefers-reduced-motion: reduce) { + .case-media-slider__track { + transition: none; + } +} + +/* Режимы анимации переключения кадров. + slide — сдвиг трека (по умолчанию); fade — кроссфейд активного слайда; + none — мгновенная смена без переходов. */ +.case-media-slider--anim-fade .case-media-slider__track { + display: block; + transform: none !important; +} + +.case-media-slider--anim-fade .case-media-slider__slide { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 0.45s ease; + pointer-events: none; +} + +.case-media-slider--anim-fade .case-media-slider__slide.is-active { + position: relative; + opacity: 1; + pointer-events: auto; +} + +.case-media-slider--anim-none .case-media-slider__track { + transition: none !important; +} + +.case-media-slider--anim-none .case-media-slider__slide { + transition: none !important; +} + +/* Переключатель режима анимации (для демо-примера) */ +.case-media-slider__anim-switch { + display: flex; + justify-content: center; + gap: 0.4rem; + margin: 0.4rem auto 0.8rem; + padding: 0; +} + +.case-media-slider__anim-switch button { + font: inherit; + font-size: 0.8rem; + padding: 0.25rem 0.7rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease; +} + +.case-media-slider__anim-switch button:hover { + color: var(--accent); + border-color: var(--accent); +} + +.case-media-slider__anim-switch button[aria-pressed="true"] { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} diff --git a/public/en/cases/ai-assistant-figma/index.html b/public/en/cases/ai-assistant-figma/index.html new file mode 100644 index 0000000..42b2507 --- /dev/null +++ b/public/en/cases/ai-assistant-figma/index.html @@ -0,0 +1,544 @@ + + + + + + + AI Assistant — Figma Plugin — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Figma · Plugin

+

AI Assistant

+

A Figma plugin with AI features

+ +

+ +

+ +

Context

+

+ +

+ +

Task

+

+ +

+ +

Solution

+

+ +

+ +

Result

+

+ +

+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/ai-platform-primo-rpa-2/index.html b/public/en/cases/ai-platform-primo-rpa-2/index.html new file mode 100644 index 0000000..8266d73 --- /dev/null +++ b/public/en/cases/ai-platform-primo-rpa-2/index.html @@ -0,0 +1,1183 @@ + + + + + + + AI Server in Primo RPA — Case Study (Draft 2) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Product Design · AI

+

User Interface Development for the Primo RPA AI Server

+

+ Intelligent document
processing platform + and AI agent orchestration +

+ +
+

Overview

+

+ A service was launched within the company that significantly improved the workflow for text recognition models on documents: loading a dataset from company documents, field tagging, model training, and the subsequent use of the model in other products. This approach provided higher recognition quality and met security requirements—data remained within the company’s network. As work on the product progressed, functionality was added to fine-tune the base neural networks for the company’s specific tasks and to work with skills such as summarization, extraction, generation, and other scenarios. +

+

+ I was invited to join the project as a UX/UI designer; prior to that, the developers had been building the interface on their own. As a result of this work, the service’s barrier to entry was lowered—significantly more people could use the product without specialized training in AI and OCR. +

+ +
+ +
+

Before and After

+

+ The service was built using Ant Design on top of React with the Hope UI visual theme for Bootstrap. The company already had several web products and wanted to unify and refresh the user interface, so the development team relied on components from those systems. +

+ +

+ I immersed myself in the product and, together with the team, analyzed all the functionality in detail. +

+ +

+ I mapped out user flows based on current scenarios: these showed the team where confusion arose in the interface and user journeys. Based on this, we redesigned the configuration processes themselves: we automated and hid some functions to reduce the risk of errors and eliminate unnecessary routine tasks. +

+

+ The new approach changed both the perception of the product and the setup process. The interface guides users through the setup steps without disrupting their usual workflows: a fully linear wizard isn’t feasible here—users still need to go back and refine settings to ensure high-quality results. +

+

Questions for the team regarding the current status

+

The answers will help quantify the impact of the implementation with facts and figures.

+
    +
  • Has the number of support requests decreased?
  • +
  • Are users able to complete project setup on their own from start to finish?
  • +
  • Does the new interface allow users to create new types of projects based on existing patterns?
  • +
+
+ +
+

Interface Patterns

+

+ Below are the key areas of the product: how they support learning and model-working scenarios, and how they differ from the previous approach. +

+ +

Field Mapping

+

+ Mapping links document fields to the target data structure. It was important to make the relationship visual and reversible so that users don’t lose context when making edits. +

+
+
+ +
+
Field Mapping in the Interface
+
+ +

Dataset, Training, and Quality Control

+

+ The dataset editor, training launch, and quality screens form a single mental path: data preparation → training → result verification. +

+ +
+ Dataset Editor — Current Version of the Interface +
Dataset Editor—Current Version
+
+
+ Dataset Editor — Previous Version for Comparison +
Previous version of the editor
+
+
+ Model Training Screen Using a Dataset +
Training on a dataset
+
+
+ Quality Control of the Model After Training +
Quality and result verification
+
+ +

Inference in the project

+

+ Special Focus: Running and Monitoring the Model Within the Project: The user must understand the status of the inference run and stay in sync with the rest of the settings. +

+
+ Model inference interface within the project +
Inference in the project
+
+
+
+
+ Inference in the project — full-screen interface + +
+
+ +
+
+
Full vertical screenshot of the inference screen
+
+
+ Previous version of the inference screen in the project +
Previous version of inference
+
+ + + +

Full-Screen Mode and Import

+

+ This isn’t about “long tasks,” but rather about processes in a separate context: some block work on the project until they’re finished, while others run in parallel in a separate window, allowing you to continue configuring project settings. We moved this functionality to a separate window because there were several entry points for these tasks, and a standard modal wasn’t enough—you had to complete a separate process elsewhere and then return to the project settings. This approach reduced the amount of development and testing required. +

+

+ Below are three full-screen canvas options: import in a separate window, panel mode, and the minimalist “zen” mode. +

+
+ A modal window for importing a dataset on a separate screen +
Import in a modal window
+
+
+ Full-screen mode with side panels +
Full-screen mode with panels
+
+
+ Full-screen mode without distracting panels +
Minimal mode without unnecessary chrome
+
+ +

Future Development

+

+ Several major items remained on the roadmap: scenarios related to the LLM (lists, statuses, actions), a step-by-step training wizard with progress tracking and undo functionality, and a full-featured OCR engine with region marking and on-page quality previews. These were not included in the minimum set for the interface’s first production release, but the established patterns should accommodate them without major rework. +

+
+ +
+

Process and Handoff

+

+ Work proceeded through regular synchronization with the development team: mockups, specifications, and a phased handoff to the front end. After I left the project, the team looked for the next designer and structured the onboarding process based on the existing design assets and layout guidelines. +

+ +

Questions for the Next Designer

+
    +
  • What turned out to be unclear during the first two weeks on the project?
  • +
  • Which artifacts and handover guidelines proved most useful?
  • +
  • What had to be redesigned and why.
  • +
  • How would you describe the current state of UX in three sentences?
  • +
+
+ +
+

Conclusion

+

+ The AI Server interface in Primo RPA was transformed from an “engineer-oriented developer interface” into a product with intuitive configuration logic and user guidance. The focus on user flows and routine automation reduced the implementation burden and paved the way for broader adoption within the company and among clients. +

+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + diff --git a/public/en/cases/ai-platform-primo-rpa-v2/index.html b/public/en/cases/ai-platform-primo-rpa-v2/index.html new file mode 100644 index 0000000..fe840ef --- /dev/null +++ b/public/en/cases/ai-platform-primo-rpa-v2/index.html @@ -0,0 +1,1312 @@ + + + + + + + AI Server in Primo RPA — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Product Design · AI

+

Simplified Primo RPA AI Server setup: customers configure the AI service without an implementation team

+

+ A platform for intelligent document
+ processing and AI agent orchestration. Requests to the implementation team have dropped by 40%, and the time required to set up the first project has been reduced from ~3 days to ~4 hours. +

+ +
+ The updated dataset markup editor—the service's main screen following the redesign +
+ +
+
Product
+
AI Server. Neural networks within the company’s internal network, configured via a web interface
+
Role
+
UX/UI Designer (the only one on the project)
+
Objective
+
Lower the barrier to entry so that the client can configure the service themselves
+
Stack
+
Ant Design + React, styled with Hope UI
+
Format
+
Redesign of a live product; releases continued uninterrupted
+
+ +
+

Only experts could handle the configuration. This slowed down sales

+

+ The AI Server is deployed on the client’s internal servers. The product was actively tested, and the feedback revealed an unpleasant truth: only experts and the Primo RPA implementation team could configure it. Every new client consumed hours of technical support. The product was losing its appeal and didn’t scale well. +

+ +

+ I started by getting to know the team and the configuration experts. I divided the scenarios into primary and secondary ones, created a visual representation of user behavior, and worked with the development team to identify areas where the interface could be simplified. I had already worked with AI and similar web platforms, so I quickly got up to speed on the subject matter and was able to speak the same language as the development team. +

+
+ +
+

Constraints

+
    +
  • Customers were already using the product. It wasn’t possible to halt development and completely rebuild the interface.
  • +
  • Changes had to be incorporated into the release plan; no one allocated time for a separate “redesign sprint.”
  • +
  • Strict tech stack: Ant Design on top of React, styled after Hope UI. We only created new components as a last resort; first, we made the most of the existing library.
  • +
+
+ + + +
+

Dataset Editor: the screen where users spend most of their time

+

+ This is where users spend most of their time. Annotating fields on hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor didn’t fit the overall style, looked cluttered, and made working with fields confusing. +

+

+ I broke the editor down into microscenarios, organized them by priority and interdependencies. Together with the team, we designed a smoother and more intuitive user experience, reserved space for future features, and aligned the design with the service’s overall style. +

+
+ Dataset Markup Editor — the document being marked up in the center, with field and tool panels on either side +
Dataset markup editor—the document being marked up is in the center, with field and tool panels on either side
+
+

+ The page became symmetrical. Almost all the space is devoted to the image being annotated, and actions are grouped by panels: the desired function can be found quickly. +

+
+ A schematic overview of the editor's layout—the central workspace and side panels +
Schematic layout of the editor—central workspace and side panels, default mode
+
+

+ For advanced users, I’ve added a “Full Screen” mode. The side panels collapse, and you work using keyboard shortcuts and the bottom panel. This is a lifesaver on small monitors. +

+
+ Full-screen editor mode with side panels open +
Full-screen editor mode with side panels expanded
+
+
+ Full-screen mode without panels—only the document being marked up and the bottom panel +
Full-screen mode without panels—only the document being edited and the bottom toolbar are visible on the screen
+
+
+ Four Full-Screen Mode Frame Options +
Four full-screen layout options: both panels, no panels, left panel only, right panel only
+
+
+ Bottom toolbar — switch between images and quick markup actions +
Bottom toolbar—switch between images and quick markup actions
+
+
+ +
+

Dozens of fields on a single scan, without sacrificing document space

+

+ Related task: Configure unique field settings for the dataset. A large area is needed for the document scan, with dozens of fields, each having its own types and dependent settings. +

+
+
+ +
+
Field mapping prototype—the user selects a field on the document scan and configures its parameters in a compact panel
+
+

+ The interface turned out to be compact. The mechanics aren’t immediately obvious at first glance, but after a short learning curve, working with it becomes quick and comfortable. +

+
+ +
+

Training cards: a pattern that has spread throughout the product

+

+ After annotating the dataset, the model undergoes fine-tuning. We analyzed all user actions and explored several page layouts. We settled on a card feed. +

+
+ Model Training Section — Vertical Card Strip +
The model training section is a vertical card feed, with a new training setup card at the top
+
+

+ At the beginning of the feed is a configuration card: the annotation results for the active dataset, parameters, and the option to start fine-tuning. +

+
+ Training Configuration Card — Dataset Quality Matrix and Run Parameters Block +
Training configuration card—dataset quality matrix and launch parameters section
+
+ +

+ A separate card appears for each running process. You can start multiple processes with different parameters and compare which configuration performed better. +

+
+ Active Learning Process Card — Status, Progress, and Metrics +
Card for a running training process—status, progress, and metrics organized into logical blocks
+
+
+ Learning Process Card Template +
The framework of a training process card—a layout of information blocks and actions
+
+

+ Cards can be collapsed; the title and status remain visible in the header. +

+
+ A collapsed training card—a single line showing the name and status of the process +
Collapsed training card—a single line showing the process name and status
+
+

+ Patterns from these cards were later applied to other sections of the product. It became easier to maintain a consistent style. +

+
+ +
+

Long processes—in a separate tab

+

+ Some tasks run in parallel with other work. For such scenarios, a modal window opens on a separate page. +

+ +
+ Importing a dataset in a modal window on a separate page +
Importing a dataset in a modal window on a separate page—a full-screen upload form
+
+

+ Meanwhile, a placeholder with a button to navigate to the process remains on the project page. +

+
+ Active Import Placeholder Within a Project +
Placeholder for active import within the project—progress indicator and "Go to Process" button
+
+
+ +
+

What I Decided Not to Do, and Why

+
    +
  • I chose not to disable buttons on individual pages during active processes. A modal on a separate page is technically simpler, and progress is visible in the adjacent tab.
  • +
  • I didn’t create new components except in extreme cases. The ready-made Ant Design library sped up development and kept the interface consistent.
  • +
  • I didn’t remove the editor’s side panels entirely; instead, I made them collapsible. Beginners need on-screen hints, while advanced users need as much space as possible for their document.
  • +
  • I didn’t set up a separate design system. The product already had a framework, so I passed links to the existing user flows in Figma to the development team.
  • +
+

Handoff to Development

+

+ Once a week, we discussed the design with the team: the task list, priorities, and deadlines aligned with release plans. I went over the details of the mockups one-on-one with the front-end developer and provided feedback after the layout was completed to ensure the result matched the intended behavior. +

+
+ +
+

Result

+ +
    +
  • Requests to the implementation team during the setup phase decreased by 40%. Clients complete the basic setup on their own.
  • +
  • 7 out of 10 new clients set up their first project without technical support. Before the redesign, it was 1 out of 10.
  • +
  • Setting up the first project: from ~3 days to ~4 hours.
  • +
  • Over 30 screens were redesigned: navigation, dataset editor, model training, and data import.
  • +
  • We established design patterns that allow functionality to be expanded without major rework.
  • +
  • The company hired a second designer. I brought her up to speed and oversaw the results; after she became familiar with the project, it was entirely transferred to her responsibility.
  • +
+
+ +
+

Conclusions

+
    +
  • The rigid framework turned out to be an accelerator rather than a limitation: the ready-made component library allowed us to focus on scenarios rather than pixels.
  • +
  • The most valuable insights came from configuration experts. Next time, I would involve the implementation team even earlier.
  • +
  • Handing the project over to a new designer confirmed that the established patterns work and can be explained and maintained without the original author.
  • +
+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + diff --git a/public/en/cases/ai-platform-primo-rpa/index.html b/public/en/cases/ai-platform-primo-rpa/index.html new file mode 100644 index 0000000..73f5a36 --- /dev/null +++ b/public/en/cases/ai-platform-primo-rpa/index.html @@ -0,0 +1,1354 @@ + + + + + + + AI Server in Primo RPA — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Product Design · AI

+

User Interface Development for the Primo RPA AI Server

+

+ Intelligent document
processing platform + and AI agent orchestration +

+ +
+

Overview

+

+ The AI Server product is deployed on clients’ internal servers. It enables the use of neural networks within the company’s internal network and is configured via a web interface. +

+

+ I was invited to join the project as a UX/UI designer. The product was in the active testing phase; feedback from customers made it clear that the configuration was too complex: only experts and the Primo RPA implementation team could handle it. The complex configuration reduced the product’s appeal and hindered scaling. +

+

+ My task was to simplify the configuration process and lower the barrier to entry, thereby reducing the workload on technical support and the implementation team—so that customers could configure the service themselves to meet their specific needs. +

+ +

+ Below is an in-depth look at the product, examples of completed tasks, and interface patterns. +

+
+ +
+

Overview

+

+ I began by getting to know the team: who was responsible for the product, who was responsible for implementation, and how everything was currently organized. I spoke with configuration experts, categorized key and secondary scenarios, and compiled a visualization of user behavior. I worked closely with the development team to identify areas where the interface could be simplified and the setup process accelerated. +

+ +

+ The product was already in use by customers, so we couldn’t halt development and completely rebuild the interface. I had to decide in what order and which parts to address in the redesign. I incorporated the most significant changes into the current development plan without disrupting the release schedule. +

+

+ I had to plan the interface changes with the framework in mind. At the time, it was + Ant Design on top of React, styled after + Hope UI. +

+

+ I only had to create new components as a last resort; the priority was to reuse the existing library. This is evident in the final screens: the interface is mostly built from ready-made blocks, rather than from scratch. +

+
+ +
+

Completed Tasks

+

+ Interface elements that I redesigned or built almost from scratch. Below are individual tasks from the project that I wanted to showcase in my portfolio—not an overview of the entire product. +

+ +

Main navigation of the service

+

+ The service used a layout common for admin panels: a side menu on the left and content on the right. The screenshot shows that this page structure didn’t make effective use of the available screen space. When you open the project, the navigation for the project sections appears on the left—intermixed with the main navigation—which confuses the user. This situation needed to be fixed. +

+
+ Previous main navigation page for the AI Server service +
Previous Navigation
+
+
+ Updated Main Navigation for the AI Server Service +
Updated navigation
+
+

+ The updated navigation is now more compact and remains familiar to most users. There is significantly more space for content. As you’ll see later, projects will have their own separate navigation that won’t overlap with the main navigation. At the same time, the service’s overall aesthetic has improved. +

+

Navigation Within a Specific Project

+

+ Users retain access to both the navigation within a project and the service’s main navigation. Different project types contain varying numbers of items, and this solution allows for their optimal placement. +

+
+ Navigation within the NLP project and the service's main navigation +
NLP Project
+
+
+ Navigation within an OCR project and the service's main navigation +
OCR project
+
+

+ I’d like to highlight the design of the navigation items. Visually, it subtly suggests that it’s more convenient to go through the setup step by step, but the sections still function as regular tabs: you can switch between them in any order. +

+
+ Secondary navigation for the project with arrow-shaped items +
Shape of navigation items
+
+

Dataset Annotation Editor

+

+ This is the screen where users spend the most time. Annotating fields across hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor stood out sharply from the service’s overall style: it looked cluttered and was confusing in places, especially when working with fields. +

+
+ Dataset Editor — Previous Version for Comparison +
Previous version of the dataset editor
+
+

+ I broke down the editor into microscenarios, organized them by priority and how they relate to each other. The team and I discussed how to make the user experience smoother and more intuitive. In the new page structure, I reserved space for new functionality and incorporated the same design patterns used throughout the rest of the service. The editor’s design now aligns with the overall style. +

+
+ Dataset Editor — Current Version of the Interface +
Updated version of the dataset editor
+
+

+ The schematic sketch shows that the page has become symmetrical: almost all the space is devoted to the image being marked up. Actions are grouped by panels—making it easier to find the desired function. +

+
+ Dataset Editor Wireframe — Default Mode +
Frame: Default mode
+
+

+ Added a “Full Screen” mode. Advanced users usually hide unnecessary panels and work with markup using keyboard shortcuts and the bottom panel. The side panels can be collapsed when more space is needed for the image—this is especially important on smaller monitors. +

+ + +

+ The bottom toolbar lets you switch between images and perform quick actions. +

+
+ The Dataset Editor's Main Toolbar +
Bottom toolbar
+
+ +

Model training configuration cards

+

+ After labeling the dataset, the model can be fine-tuned. As usual, our team broke down all user actions for this task. We created several layout options for this page; the one with a card feed turned out to be the most successful. +

+
+ Overview of Training Configuration Cards in the Project +
View of the Model Training section
+
+

+ At the top of the feed, the user sees a training configuration card: it displays the annotation results for the active dataset, allows the user to set parameters, and initiates fine-tuning. +

+
+ Training Configuration Card +
Training Configuration Card
+
+

+ The card includes a separate section with an assessment of the dataset’s annotation quality and a section with parameters to configure before starting training. +

+ +

+ A separate card will appear below for each process that is started. You can run multiple processes with different parameters and then compare which configuration performed better. +

+
+ Model Learning Process Card +
Training Process Card
+
+

+ Within the card, information and actions are organized into logical blocks. We considered various layout options and ultimately settled on a scheme that proved to be more user-friendly than the others. +

+
+ Learning Process Card Wireframe +
Wireframe of the Learning Process Card
+
+

+ The cards can be collapsed. For convenience, the name and status are displayed in the header. +

+
+ Collapsed Training Settings Card +
Collapsed Card
+
+

+ After refining these cards, we identified patterns that were later used in other sections of the product. This makes it easier to maintain a consistent style across the interface. +

+ +

Field Parameter Configuration

+

+ On one of the pages in the project section, we needed to allow users to configure unique field parameters for a dataset. The editor required a large area for an image (a scanned document), and there could be dozens of fields, each with its own dependent parameters and types. +

+

+ The video shows the prototype of the finished solution in action. +

+
+
+ +
+
Field mapping in the interface
+
+

+ The interface turned out to be compact and clean. The mechanics aren’t immediately obvious, but after a brief learning curve, users find it comfortable to use. +

+

+ Developing the editor proved to be an interesting and challenging task for me: there were many drafts and different behavior options. In the end, we settled on a version that met all the requirements my team and I had defined. +

+ +

Modal windows on a separate screen

+

+ In the service, some tasks need to be performed alongside other work. For such scenarios, we implemented the following: a modal window opens on a separate page. +

+
+ A modal window for importing a dataset on a separate screen +
Importing a dataset in a modal window
+
+

+ A placeholder is displayed on the project page: the process is in progress, with a button next to it to navigate to it. +

+
+ Importing a dataset within a project +
Importing a dataset within a project
+
+

+ These placeholders appear in several sections of the project—depending on whether you can work with that section while the task is active. +

+ +

+ This approach turned out to be technically simpler than selectively disabling buttons on individual pages. +

+ +
+ +
+

Handoff to Development

+

+ Once a week, the team met to discuss the design. The calls were long but productive. We created a weekly list of design tasks, adjusting priorities and deadlines to align with release plans. +

+

+ I created the designs in Figma and sent links to the finished user flows to the development team. We didn’t set up a separate design system or UI kit—the product already had a ready-made framework. +

+

+ From time to time, I would have one-on-one calls with the front-end developer to clarify details about the mockups or explain how the interface should behave. After the layout was completed, I provided feedback on revisions to ensure the result matched the intended behavior and appearance of the interface. +

+
+ +
+

Change in Designers

+

+ I managed this project alongside others, so I had to balance my workload. The company hired a new designer—my task was to bring her up to speed and oversee the results of her work on each task. Once she was up to speed, responsibility for the project shifted entirely to her. +

+
+ +
+

Result

+

+ The Primo RPA AI Server interface now has a consistent look and feel. It is user-friendly even for those who are not yet experts in AI. The interface is designed to accommodate future changes, allowing for the addition of new features without major overhauls. +

+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/card-columns-obsidian/index.html b/public/en/cases/card-columns-obsidian/index.html new file mode 100644 index 0000000..5307a54 --- /dev/null +++ b/public/en/cases/card-columns-obsidian/index.html @@ -0,0 +1,940 @@ + + + + + + + Card Columns, a plugin for Obsidian Bases: a case study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Obsidian Bases · Plugin

+

+ A Kanban view for Obsidian Bases
+ that leaves your notes untouched +

+

+ Card Columns: Notes as cards in columns,
+ filtered by AND/OR tags with just two clicks +

+ + + +
    +
  • Role: Product Design and Development
  • +
  • Platform: Obsidian Bases
  • +
  • Status: Working plugin, open source (MIT)
  • +
  • Team: Solo, side project
  • +
  • Status: Draft response, to be replaced
  • +
+ +
+ Question for the author +

+ How long did it take from the initial idea to a working version? Weeks? A month of weekends? +

+

+ Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while + working on my own projects. Replace with the actual time. +

+
+ +

+ Obsidian now has Bases, a separate file type that displays notes not as text, but + in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s + flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them + arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about. +

+ +
+

Context

+

+ A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select + notes using a filter, and the framework displays them in the chosen view. Obsidian itself has + a built-in card view, but it’s one-dimensional: no grouping by properties, no + proper filtering. I decided to create my own view that integrates into the + Bases toggle alongside the default ones. +

+

+ The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what + the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic + for data retrieval remains with Bases, and my task is solely to display and navigate the results. +

+ +
+ +
+

Why not use a ready-made solution?

+

+ Before writing any code, I checked what was already available. Three options, none of them worked: +

+

+ The default Bases card view. + It displays a continuous stream of cards without grouping. + When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles. + I ruled that out first. +

+

+ Kanban plugins from the community. + Most come with their own storage system: + the board exists separately from the vault, and notes are duplicated. This results in a second database + that needs to be synchronized. I didn’t need that. +

+

+ The neighboring bases-kanban. + The closest in spirit: it hooks into Bases to create + a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin, + this is a risk, which I’ll discuss below. +

+ + +
+ Question for the author +

+ Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which + ones did you look at specifically? +

+

+ Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban + but didn’t install it. Replace with actual information; include the plugin names. +

+
+
+ +
+

Task

+

+ Design a view for Bases that displays notes as cards arranged in columns and allows you to + quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store + its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must + work as before. +

+ +
+ +
+

Solutions

+

+ The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query; + grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options + that determine the result. Each one includes something I decided against. +

+ + +

Grouping by any property

+

+ Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any + frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single + database covers different perspectives: projects by status, notes by tags, tasks by + priority. +

+ +

Tag filter by AND and OR

+

+ This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they + filter cards across all columns at once: +

+
    +
  • Left-click on a tag to show only that tag (resets the other filters).
  • +
  • Left-click on the next tag to show notes with ANY of the selected tags (OR mode).
  • +
  • Ctrl+Click or right-click to switch tags without resetting the selection.
  • +
  • The OR/AND button: toggle between “any of” and “all of” for multiple tags.
  • +
  • All: Clear all filters.
  • +
+

+ The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive: + quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window— + because it requires an extra click and loses the context of what’s already been filtered. +

+

+ The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of + a separate filter window because filtering by tags is an action performed at every step of working + with the database. A modal window turns it into an interrupted workflow: open, configure, close, check. + The pill-shaped icons keep the result right in front of you, and the filter status is always visible. +

+ + +

Cards and Covers

+

+ Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can + show a cover: from the first image in the note or from a separate property. You have + full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit + (cover/contain), orientation, and position on the card (above the title, below it, at the end). +

+ +

Solution: Settings have been moved out of the code

+

+ At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working + for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view + could adapt to different databases, rather than just my single use case. More than 15 parameters. +

+
+ Show the full list of settings +

General

+
    +
  • Where to open the card: active panel, pop-up window, new tab, split to the right / down.
  • +
  • Card width (px).
  • +
  • Columns per group (1–6), grid within a column.
  • +
  • Zebra columns (alternating background colors for easy scanning).
  • +
  • Masonry layout (cards fill vertical gaps).
  • +
+

Title

+
    +
  • Title wrapping, bold title, font size (px).
  • +
+

Properties

+
    +
  • Chip layout: stack (vertical) or grid (compact in a row).
  • +
  • Wrap multi-line values, font size.
  • +
  • Date and date-time formats + locale (Moment.js).
  • +
+

Cover

+
    +
  • Source: none / first image in the post / cover property.
  • +
  • Style (borderless / with border), aspect ratio, orientation, fit, position on the card.
  • +
+
+ +
+ Question for the author +

+ Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them + to the settings: user requests, multiple databases with different structures, whatever. +

+

+ Preliminary answer: Yes, the first version was tailored to my database; the settings were added when + I needed to make the plugin available externally. I’ll check and fix it. +

+
+ + +

Works within the standard toolbar

+

+ The view hooks into the BasesView framework, so sorting, filtering, properties, and search from + the Bases toolbar remain functional. The plugin only renders the query results; the data is not + duplicated and does not depend on a separate storage. +

+ +
+ +
+

The Result and How I Tested It

+

+ This view solved my problem: a Bases query now reads like a board, rather than a flat stream + of cards. Grouping by property + filtering by AND/OR tags—exactly what the default + view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t + break anything. +

+ +

Metrics: number of steps to filter

+

+ The main task I used to measure success: filter notes by two tags and find the right + card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In + Columns, two clicks on the pills and the column is right in front of you. +

+ +
+ Question for the author +

+ Can you run this test for real? Time 10 repetitions of the task: the default + view versus Columns. Or count the exact number of steps. +

+

+ Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and + required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates— + replace them with actual measurements. +

+
+ +

Test: Long-term usability in daily work

+

+ The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be + using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require + me to remember how to use it. +

+ +
+ Question for the author +

+ Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any + number confirming that you’re not the only one using the plugin. +

+

+ Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so + there are only a handful of installs and no stars so far. If there’s actual data, include it here— + it’s more convincing than any self-report. +

+
+

+ What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns, + since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk. + But I see a demand for this among users, and I might add it as an optional mode. For + now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing + preventing users from using it right away. +

+ +
+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/design-system/index.html b/public/en/cases/design-system/index.html new file mode 100644 index 0000000..baab557 --- /dev/null +++ b/public/en/cases/design-system/index.html @@ -0,0 +1,1255 @@ + + + + + + + Design System — Portfolio + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Documentation · UI

+

Design System

+

Tokens, typography, grid, components, and longgrid blocks

+

+ Switch between themes, accents, and fonts using the floating panel on the right. When making global style changes (tokens, + components, longgrid blocks), be sure to update this page as well—it’s the project’s live UI catalog. +

+ + + +

Colors

+

+ The accent and “cool/warm” tones of the entire UI are defined by three numbers in the HSL model (Hue, + Saturation, Lightness) at <html>. From these, + --accent and derived tokens; neutral backgrounds are slightly tinted with the same hue + (--neutral-accent-tint). Light/dark theme — data-theme, mesh — + data-mesh-bg. Can be changed via the FAB on the right. +

+

What is --theme-h, --theme-s, --theme-l

+
    +
  • + --theme-h — hue (Hue), position on the color wheel 0–360°. A hue shift + recolors buttons, links, the mesh gradient, and square accents (--accent-square-2 … + 4), without having to manually adjust each hex value. +
  • +
  • + --theme-s — Saturation: how “pure” the color is, rather than gray. +
  • +
  • + --theme-l — Accent lightness: darker or lighter than the primary brand color + (usually limited by the panel’s range to ensure buttons remain readable). +
  • +
+

+ In CSS, it looks like this: + --accent: hsl(var(--theme-h) calc(var(--theme-s) * 1%) calc(var(--theme-l) * 1%)). + Values are written in localStorage (siteThemeV1) and are inserted by an inline script into + headto prevent a “foreign” color from flashing during loading. +

+
+
+
+ --bg +
+
+
+ --bg-elevated +
+
+
+ --surface +
+
+
+ --text +
+
+
+ --text-muted +
+
+
+ --line +
+
+
+ --accent +
+
+
+ --accent-text +
+
+
+ --accent-soft +
+
+

+ Additionally: quadratic mesh shades — --accent-square-2 … 4; they depend on hue. +

+ +

Fonts

+

+ A pair of fonts — data-font on <html> (FAB “Typography”): sets + --font-accent (headings) and --font (body text). Scale in tables: class, example, purpose. +

+

Font families

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
data-fontStackWhere
merriweatherMerriweather + AliceDefault pair; Alice—body text, Merriweather—emphasized headings
monoSource Code Pro + PT MonoMonospace pair
geologicaGeologica + PT SansSans-serif + neutral body text
+
+ +

Typographic scale

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassExamplePurpose
.hero__eyebrow

Section caption

Hero, case input
.hero__title +

+ Display main heading +

+
H1 for the main page; reduced on DS for the column
.section__title

Section

“Skills,” “Portfolio,” contacts; main font, no caps
.case-page__h1

Page title

H1 for case studies / Design System
.case-page__sub

Lead text under the heading

Subheading below H1
.section__prose

Main paragraph text.

Prose, lists, and links in the article
h2 in .case-article

H2 subsection

Long-form sections (such as “Colors”)
h3 in .case-article

H3 subsection

Subsections within H2; margins in main.css: 3.25rem top, 1.5rem bottom, after figure 4rem, h3+h3 3.5rem
+
+ +

Rhythm and Code

+

Rhythm example:

+
    +
  • bulleted list;
  • +
  • emphasis and link;
  • +
+
    +
  1. numbered item;
  2. +
+

Inline code and block:

+
--font: "Alice", system-ui, serif;
+--font-accent: "Merriweather", var(--font);
+

+ Headings use clamp() — scale relative to the viewport; see the “Grid” section for more details. +

+ +

Grid and Responsive

+

+ There is no separate 12-column grid: a full-bleed layout with side margins + --space and local CSS Grid within sections. The content column for DS and case studies — + .case-article (max-width 48rem). +

+ +

Basic framework

+
    +
  • --space: clamp(1rem, 4vw, 2.75rem) — horizontal margins + safe-area-inset.
  • +
  • --max: none — no fixed 1200px “box”; width = viewport minus padding.
  • +
  • .section — vertical spacing via clamp(); .section__inner centers the block.
  • +
  • .section--tight — More compact vertically (case studies, this page).
  • +
+ +

Breakpoints

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FromWhat Changes
860px.hero__grid → 2 columns 1.15fr 0.85fr
760px / 1180px.projects-carousel: --projects-visible 1 → 2 → 3 cards
720px.skills-category__grid → 2 columns
1024pxSkills: 6-column mosaic (grid-column: span …)
560px / 1024px.contact-grid → 2 → 4 columns
48rem / 28remCase gallery: ≤2 col → 1 col
640pxcase-media-gallery--wide collapses to the width of a column
+
+ +

Hero: one → two columns

+

Layout .hero__grid (compressed to 860px):

+ + +

Contacts: 1 → 2 → 4

+

Demo .contact-grid (placeholders, no transitions):

+ +

+ Skills: mosaic layout on wide screens—see + the homepage, #skills. Portfolio: carousel on + 100vw with --projects-card-width and JS scrolling — on the homepage + #projects. +

+ +

Wide in case studies

+

+ .case-media--wide, .case-media-gallery--wide and .case-callout wider + article columns: min(100vw − 2×padding, content + 8rem), with margins --space. On a narrow screen (up to 640px), "wide" collapses to the width of the column. Images with data-image-viewer — the outline and hover effects + are the same as .case-video-shell (live examples — case study element demo). The caption below the video is + figcaption centered in the column. +

+ +

Typography and Motion

+

+ Fluid type: clamp() on .hero__title, .section__title, + .case-page__h1. Rounded corners (data-radius) do not change the grid. For long screenshots and + animations, the prefers-reduced-motion: reduce. +

+ +

Components

+

Live examples; the shell (header, FAB, footer, cookie) is already integrated on this page.

+ +

Buttons

+ +

.btn .btn--primary | secondary | ghost · .btn--flat | raised · .btn__icon

+ +

FAB buttons

+

+ Floating buttons in the lower-right corner (.fab-stack). Icon-only and with a text label. +

+
+ + + +
+

.fab .fab--primary | secondary · .fab--label (with text) · .fab-primary__ico · .fab__label

+ +

Tags

+
    +
  • hero__tag
  • +
  • UX/UI
  • +
+

.hero__tags / .hero__tag

+
    +
  • mp-card__tag
  • +
  • Web App
  • +
+

.mp-card__tags / .mp-card__tag

+ +

Portfolio Card

+ +

.mp-card .mp-card--stack-bottom — text on top, cover at the bottom

+ + +

Placeholder while images are loading

+

+ While img hasn't loaded yet, initImageLoadPlaceholder it + .is-img-loading on the image and .has-img-loading the wrapper. The background layer is + ::before, a single glow from left to right— ::after (img-ph-shine-sweep). + Icons, SVGs, and small images up to 40px are skipped; + disable selectively: data-no-img-placeholder. Below is a fixed demo (on the website, the classes are removed + after load). +

+ +

.has-img-loading · .is-img-loading · .img-ph-demo (placeholder demo)

+ +

TUI Braille spinner

+

+ Loading large images in the viewer (.case-image-viewer) is indicated by a monospaced Braille spinner—just like in TUI. Pure CSS, no JS: 10 frames cycle through an animation. Reusable class: steps()an animation. Reusable class .tui-spinner + (color — --accent, font—system mono). +

+
+ ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ + ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ +
+

.tui-spinner · frames: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion displays ⠋

+ +

Skill card

+
+

skill-task-card__task — the task description provided by the business.

+
+

skill-task-card__solution — the first approach to the solution.

+

The second item in the solutions section.

+
+
+

.skill-task-card__task · __solutions · __solution

+ +

Callout

+

+ Longread accent block: wider than the column (like wide media), text .case-callout__content slightly + larger than the main paragraph. +

+ +

.case-callout · .case-callout__emoji · .case-callout__emoji--pulse · .case-callout__content

+ +

Contact Cards

+

+ On the home page, there are four cards in a grid (1→2→4 columns). Here are two examples—the grid is forced into two columns. +

+ +

.contact-card--link · --action · --brand-*

+ +

Section with a background

+

+ On the homepage, the strip .section--surface is visually presented using mesh; in other cases, the page background is + solid—see the compact demo below with background: var(--surface). +

+
+
+

section__title

+

Snippet: .section.section--surface + .section__inner

+
+
+

.section · .section--surface · .section--tight · .section__inner

+ +

Separators

+

+ Horizontal separators: a line or simply white space (indentation). Modifiers --line, --air, --air-lg. +

+
+
+
+
+
+

.sep · .sep--line · .sep--air · .sep--air-lg

+ +

Blocks

+
+

+ Longgrid case layout: media and galleries, link cards, videos, callouts, wide blocks, and other partials. + A live example with all variations is available on a separate page; use it to copy sections when building a new case. +

+

+ Case element demo +

+
+ +

Activity

+
+

+ On the homepage in the section #mini-projects card mp-card section on the homepage in a masonry grid. Types, cells, and the live + layout are on a separate page. +

+

+ Activity Card Catalog +

+
+
+
+ +
+ +
+ + + + + + + + diff --git a/public/en/cases/lunacy/index.html b/public/en/cases/lunacy/index.html new file mode 100644 index 0000000..1f06a28 --- /dev/null +++ b/public/en/cases/lunacy/index.html @@ -0,0 +1,1142 @@ + + + + + + + Lunacy — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Icons8 · desktop

+

Lunacy Graphic Editor

+

+ Work on the Lunacy interface as part of the Icons8 team: user flow, prototypes of complex scenarios, refinement of functionality and + visual logic. Below are screenshots from the process (the order of the screenshots is based on the filenames). +

+

+ Product: icons8.com/lunacy +

+ +

Overview

+

General view of the application—the workspace with an open document, toolbars, and the canvas. This screen demonstrates Lunacy’s basic layout: the left panel shows layers, the right panel shows object properties, and the top bar displays the context bar with the current tool. This serves as the starting point for the rest of the materials on the page.

+
+ Lunacy App Review +
Overview of the Lunacy window.
+
+

Next are individual scenarios and interface iterations, presented in the order that makes the most sense in terms of context and file names.

+ +

Blob: Settings on the Canvas

+

Working with a blob in the context of the canvas is a separate scenario, prior to the analysis of the panel across versions.

+
+ Blob Settings on Canvas +
blob_settings_on_canvas
+
+

The settings panel for these tasks evolved through a series of mockups from v1 to v9—they’re compiled in the grid below.

+ +

Components and Comparison

+

Comparison of components and the update indicator in the panel.

+
+ Component Comparison Dialog Box +
compare_components_modal_window
+
+
+ Component Update Indicator in the Panel +
components_update_indicator_location_panel
+
+

Two related screens: searching for discrepancies and displaying update status next to the panel navigation.

+ +

Prototype in the Panel

+

Creating a prototype on the canvas and panel states for prototype mode.

+
+ Modal Window: Create a Prototype on Canvas +
modal_window_for_prototype_create_on_canvas
+
+
+ Modal Window: Prototype Panel States +
modal_window_for_prototype_panel_states
+
+

Both screenshots show how the prototype is linked to the canvas and to a set of dashboard states.

+ +

Shadow Pro: Settings

+

Shadow Pro block: panel, ideas, modals with auto-open and auto-close.

+
+ Shadow Pro: Settings Panel +
shadow_pro_settings_panel
+
+
+ Shadow Pro: Ideas +
shadow_pro_settings_ideas
+
+
+ Shadow Pro: Mods, Auto Enabled +
shadow_pro_settings_modal_auto_on
+
+
+ Shadow Pro: Mod Mode, Auto Off +
shadow_pro_settings_modal_auto-off
+
+

Four consecutive frames show the development of a single subproject: from the panel and sketches to refining the behavior in modals.

+ +

Blob settings panel: versions v1–v9

+

+ Panel iterations by filename blob_settings_panel_v1 … v9. Grid based on text width, three columns on a + wide screen; as the window narrows, the number of columns decreases automatically. +

+ +

The grid is convenient for step-by-step comparisons; for details, clicking on a cell opens a full-screen view.

+ +

Slider: versions v1–v18 and frame 14

+

+ Ordered by name: slider_v1 … slider_v13, then slider_14 (in the asset without a suffix v), + then slider_v15 … slider_v18. +

+ +

This sequence is useful for analyzing the evolution of the control: from early layouts to final states.

+ +

Sliders: general frame

+

A summary screen for sliders—following the version series.

+
+ All Sliders: Overview +
sliders_all_set
+
+

This concludes the section on sliders: context—how multiple controls coexist on a single screen.

+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/manu-game-maker/index.html b/public/en/cases/manu-game-maker/index.html new file mode 100644 index 0000000..777f2b3 --- /dev/null +++ b/public/en/cases/manu-game-maker/index.html @@ -0,0 +1,884 @@ + + + + + + + MANU Game Engine — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Product Design · Desktop App

+

MANU Game Engine

+

+ Interface redesign and development: a new operational concept, an updated visual system, and hundreds of carefully crafted user scenarios. +

+

+ The work focused on the desktop editor and associated panels: the goal was to maintain the integrity of the visual language while expanding functionality and ensuring that the usage scenarios of power users were preserved. +

+

+ A detailed case study will be posted on this page later; below is the cover image for portfolio navigation. +

+

Cover

+
+ MANU Game Engine Interface — Case Cover +
Current preview for the portfolio card
+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/mini-projects-showcase/index.html b/public/en/cases/mini-projects-showcase/index.html new file mode 100644 index 0000000..795c3c1 --- /dev/null +++ b/public/en/cases/mini-projects-showcase/index.html @@ -0,0 +1,1418 @@ + + + + + + + Activity — Card Catalog + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+
+
+

Design System · Activity

+

Activity Cards

+

All card types and a live grid for selecting layouts on the homepage

+

+ Layout and content page: the same classes and scripts are used here as in the section on the homepage. Change the font and theme in the panel on the right; + narrow the window, and the grid will recalculate. The text in the card headers is just placeholder text; you can remove it when previewing. +

+ + + +

How to Use

+

+ First, select a type (stack, horizontal, compact, decor), then data-mp-cells (1–4) and + the order li in the list. Cards with data-mp-extra do not affect the height of the list until the “Show All” button is clicked. + For more details, see docs/mini-projects-cards.md. +

+ +

Card Types

+

+ On the left is an example of a text card; on the right is the type name and an explanation of the classes. +

+
+
+ +
+

+ Stack with a link. Full-width preview, text at the bottom. For an external link: + mp-card--link and mp-card__ext in the title (as in the "Experience" block). +

+
+
+ +
+
+

mp-card--stack without a link

+
+ +
+

Stack without a link

+
    +
  • note
  • +
+

+ A collection of databases and a rollup for a weekly overview, for personal use only. +

+
+
+
+
+

+ Stack without a link. Same view. Wrapper div or article, without + mp-card--link. +

+
+
+ +
+ +
+

+ Horizontal with a link. Thumbnail on the left, text on the right. On a narrow screen, the card + stacks vertically. +

+
+
+ +
+
+

mp-card--horizontal No link

+
+ +
+

Horizontal without a link

+
    +
  • Template
  • +
+

+ Static website with a schedule and an application form on a separate service. +

+
+
+
+
+

+ Horizontal without a link. Same layout; the card does not link to an external URL. +

+
+
+ +
+ +
+

+ Compact with a link. Text only, no mp-card__media. In the grid, often with + data-mp-cells="1". +

+
+
+ +
+
+

mp-card--compact no link

+
+
+

Compact without a link

+
    +
  • document
  • +
+

+ Markdown checklist before releasing minor UI changes. +

+
+
+
+
+

+ Compact without a link. A short note or checklist without an external link. +

+
+
+ +
+
+

mp-card--decor

+ +
+
+

+ Decorative cell. A placeholder in the grid, with no project or link. Inside + span.mp-card__decor-pattern. +

+
+
+
+ + +

Width in Cells

+

+ Attribute data-mp-cells sets li.mini-projects-grid__item specifies the desired width from 1 to 4 cells. + The script will not allow the card to be wider than the current row. +

+

+ For responsive width, use data-mp-cells-sm (for 4 cells per row, ≥ 880 px) and + data-mp-cells-xs (for 2 cells, ≥ 520 px). The script selects the value based on the current number of cells in a row. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TheWhen to useExample
data-mp-cellsWide screen (6 cells, ≥ 1180 px)data-mp-cells="3"
data-mp-cells-smMedium screen (4 cells, ≥ 880 px)data-mp-cells-sm="2"
data-mp-cells-xsNarrow screen (2 cells, ≥ 520 px)data-mp-cells-xs="1"
+
+

+ Example: data-mp-cells="3" data-mp-cells-sm="2" — A card takes up 3 cells on a wide screen, 2 on a medium screen, and 1 on a narrow screen. +

+
+
+ +

≥ 1180 px — 6 cells

+
+
3
+
3
+
2
+
2
+
2
+
1
+
2
+
3
+
4
+
2
+
+
+
+ +

≥ 880 px — 4 cells

+
+
3
+
1
+
2
+
2
+
1
+
2
+
2
+
4
+
+
+
+ +

≥ 520 px — 2 cells

+
+
1
+
1
+
2
+
2
+
1
+
1
+
+
+
+ The number in the block = the value data-mp-cells. The card occupies that many cells in a row. On a narrow screen, there are fewer cells—cards with a width greater than the number of cells are carried over to the next row. As data-mp-cells-sm / data-mp-cells-xs the card changes its width when the view is narrowed. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
List widthCells per row
≥ 1180px6
≥ 880px4
≥ 520px2
already1 (entire row)
+

+ A pair on six cells 3 + 3 or a trio 2 + 2 + 2 usually looks neater than a random set. + Decor (mp-card--decor) fills in the gaps in the grid. +

+
+
+
+ +
+
+

Live masonry grid

+

+ A demo of the “Activity” block from the homepage: here, all card types are in a single live grid. Each card’s caption shows the type of + layout and data-mp-cells. Change the window width and font in the theme panel, watch how the grid + rearranges itself, and apply the best layout to the homepage. +

+
    +
  1. + + +
    +

    + [2] stack · link + +

    +
      +
    • website
    • +
    +

    A base pair in a row of six cells.

    +
    +
    +
  2. +
  3. +
    + +
    +

    [2] stack

    +
      +
    • note
    • +
    +

    Stack without an external link.

    +
    +
    +
  4. +
  5. +
    + +
    +

    [2] horizontal

    +
      +
    • utility
    • +
    +

    Horizontal, two cells wide, without a link.

    +
    +
    +
  6. +
  7. +
    +
    +

    [2] compact

    +
      +
    • template
    • +
    +

    A two-cell text card.

    +
    +
    +
  8. +
  9. + +
    +

    + [1] compact · link + +

    +
      +
    • cli
    • +
    +

    A narrow column with a link.

    +
    +
    +
  10. +
  11. +
    +
    +

    [1] compact

    +
      +
    • document
    • +
    +

    Compact, one cell wide.

    +
    +
    +
  12. +
  13. + +
  14. +
  15. + + +
    +

    + [3] stack · link + +

    +
      +
    • Appendix
    • +
    +

    + Half of a six-cell row. A longer description to check for line breaks and height after changing the font. +

    +
    +
    +
  16. +
  17. + + +
    +

    + [3] horizontal · link + +

    +
      +
    • plugin
    • +
    +

    Wide horizontal: preview and text in a single column.

    +
    +
    +
  18. +
  19. +
    + +
    +

    [3] horizontal

    +
      +
    • widget
    • +
    +

    Three-cell Horizontal, without a link.

    +
    +
    +
  20. +
+ +
+
+ +
+ +
+
+ +
+ +
+ + + + + + + diff --git a/public/en/cases/mobile-clinic-app/index.html b/public/en/cases/mobile-clinic-app/index.html new file mode 100644 index 0000000..eaeaf43 --- /dev/null +++ b/public/en/cases/mobile-clinic-app/index.html @@ -0,0 +1,884 @@ + + + + + + + Mobile App for a Network of Clinics — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

UX/UI · Mobile App

+

Mobile App for a Clinic Network

+

+ Booking doctor appointments and managing medical services: a simple user journey, intuitive navigation, and quick access to actions. +

+

+ The project’s focus is on alleviating patient anxiety and eliminating unnecessary steps: clear appointment statuses, predictable forms, and reliance on familiar mobile app patterns. +

+

+ A full breakdown of the case study will be published here; for now, this page serves as an entry point from the portfolio section on the homepage. +

+

Cover

+
+ Mobile App for a Network of Clinics — Case Study Cover +
Current preview for the portfolio card
+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/obsidian-plugin-columns-view/index.html b/public/en/cases/obsidian-plugin-columns-view/index.html new file mode 100644 index 0000000..44186df --- /dev/null +++ b/public/en/cases/obsidian-plugin-columns-view/index.html @@ -0,0 +1,911 @@ + + + + + + + Column View for Obsidian — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Obsidian · Plugin

+

Column View — a plugin for Obsidian

+

Design and development of an interface for multi-column note management

+ +

+ +

+ +

Context

+

+ +

+ +

Task

+

+ +

+ +

Solution

+

+ +

+ +

Usage Filter

+
+
+ +
+
Filtering notes in a columnar view.
+
+ +

Display Configuration

+
+
+ +
+
Column and display settings.
+
+ +

Result

+

+ +

+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + diff --git a/public/en/cases/orc-automation-panel/index.html b/public/en/cases/orc-automation-panel/index.html new file mode 100644 index 0000000..e98667a --- /dev/null +++ b/public/en/cases/orc-automation-panel/index.html @@ -0,0 +1,1022 @@ + + + + + + + Web Dashboard for Automation Orchestration — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Primo RPA · UX/UI · Web App

+

Web Dashboard for Automation Orchestration

+

+ Primo RPA Orchestrator: launching robots, managing projects, and monitoring processes in a web dashboard for administrators and automation developers. +

+ +
+

Context

+

+ In the Primo RPA stack, the orchestrator manages the scheduling, queue, and statuses of RPA robots on the client’s infrastructure. It can be accessed not only from the development studio but also from a browser: users view projects, restart tasks, and troubleshoot failures. +

+

+ By that point, the product was already in use, but the interface was noticeably outdated: users faced navigation issues, visual clutter in tables, and confusing workflows in some sections. The goal was to refresh the interface’s look, modernize it, and fix the accumulated UX issues. +

+
+ +
+

Role and Approach

+

+ My role in this project was more of a curatorial one—I didn’t do the design myself, but rather structured the process so that the design and development teams could work in sync. +

+ +

+ At the same time, I was looking for a designer who could take on the main visual design work. Elizaveta was selected—out of more than 150 applications, she was the best fit for the upcoming workload. +

+

+ Over the course of three months, I brought the new team member up to speed on the project details: I assigned tasks, provided feedback on the mockups she created, and shared context on user scenarios. Together with the development team, we planned the order in which to update the service’s design—we revised screens based on priority, incorporating changes into ongoing releases, without “stopping everything and redesigning from scratch.” +

+
+ +
+

Technical Stack and Tools

+

+ The latest version of Ant Design was chosen as the UI framework. It was already in use on the project, and its latest release introduced the Theme Editor—a feature that allowed us to customize the appearance of components using design tokens without manually overriding styles. This provided both designers and developers with a unified language for describing the user interface. +

+

+ Storybook served as the single source of truth and the bridge between design and development. In Storybook, components weren’t just documented—they actually worked in an isolated environment. This allowed designers to see the elements in action and developers to integrate components into the dashboard more quickly. +

+
+ +
+

Design System and Synchronization

+

+ To streamline our workflow, we synchronized tokens from Ant Design with those in Figma—colors, padding, typography, and corner radii were standardized to consistent values. Here’s how it worked: if tokens were updated in Figma, they could be exported as JSON and immediately imported into the code without any issues—instead of having a developer manually search for changes and update each token individually. This approach also allowed us to view the design more systematically. +

+ +

+ Components in Figma were built from scratch—based on Ant Design references, but without using pre-made libraries. The decision to avoid pre-made solutions was a conscious one: we needed full customization to fit our workflow and the ability to use new Figma features that weren’t supported in libraries from the Figma community. +

+

+ For Storybook, we selected and configured plugins that were relevant to our tasks—accessibility checks, viewing component states, and switching themes and locales. This turned Storybook into more than just a showcase for components; it became a full-fledged design review tool. +

+
+ +
+

Designer’s Work Environment

+

+ We set up the environment so that designers could see the live implementation of components on their own computers using a local Storybook instance and Git. This allowed for more precise control over how the intended design was translated into code and enabled us to quickly spot discrepancies early on. +

+

+ We also discussed a step to accelerate Storybook support by allowing designers to write the JSX for implemented components themselves—this shortened the path from mockup to a documented component in Storybook. +

+

+ We fine-tuned the workflow for adding icons to the library. The key feature was that icons could be displayed in Storybook with filtering and sorting capabilities. At the same time, designers could update icons without involving a developer—they simply pushed new files to the repository. +

+

+ Being able to see the updated icon immediately in the browser allowed us to optimize it better and eliminate display glitches when using Boolean operations. +

+

+ We also developed an approach and set of rules for Figma that not only guided the designer but also ensured the creation of consistent and functional icons. +

+
+ +
+

UX Improvements

+

+ During the interface update, Elizaveta also proposed a number of user experience improvements. Some of them were successfully implemented—for example, clearer grouping of elements in complex forms and redesigned loading states. However, the priority was not to disrupt the existing UX that users had grown accustomed to, so many ideas were saved for the backlog for the next iteration. +

+
+ +
+

Project List

+

+ The project table is one of the most frequently viewed screens. In the old version, there were many columns, a dense grid, and small text: the project status and name were difficult to read, especially on a laptop. +

+

In the new version:

+
    +
  • the columns have been reduced to what’s necessary for daily work;
  • +
  • statuses and actions have been moved to predictable areas within each row;
  • +
  • indentation and typography have been aligned with the rest of the panel using Ant Design.
  • +
+ +

It used to be

+
+ List of projects in the orchestrator — previous version of the table +
+ +

Now

+
+ List of Projects in the Orchestrator — Updated Table +
+
+ +
+

Results and Reflection

+

+ The projects screen became easier to scan: less visual noise, and it’s faster to find the right project and its status. After rolling out the new design to several clients, we received positive feedback and many ideas for further improvements. +

+

+ I stepped away from the project once Elizaveta felt confident in her role and was able to handle design tasks independently. I occasionally answered specific questions, but overall, the handoff went smoothly. +

+

+ A little over a year later, the team’s processes have changed significantly. According to the team, tools powered by neural networks—such as Claude Design—are now actively involved in the creation and handoff of designs. What we used to build manually using tokens, Storybook, and environment synchronization is now, to some extent, automated. +

+

+ I believe the work was completed fairly quickly and to a high standard—amid constant changes in the market and within the company, we managed not only to update the interface but also to establish a sustainable process that continued to function even without my involvement. +

+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + diff --git a/public/en/cases/site-with-ai-ide/index.html b/public/en/cases/site-with-ai-ide/index.html new file mode 100644 index 0000000..6b09227 --- /dev/null +++ b/public/en/cases/site-with-ai-ide/index.html @@ -0,0 +1,544 @@ + + + + + + + This Website — Developed with AI IDE — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Development · AI IDE

+

This website

+

Website Development Using AI IDE

+ +

+ +

+ +

Context

+

+ +

+ +

Task

+

+ +

+ +

Solution

+

+ +

+ +

Result

+

+ +

+
+
+ +
+ +
+ + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/task-distribution-concept/index.html b/public/en/cases/task-distribution-concept/index.html new file mode 100644 index 0000000..adbe7c4 --- /dev/null +++ b/public/en/cases/task-distribution-concept/index.html @@ -0,0 +1,884 @@ + + + + + + + Task Distribution Service Concept — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

UX/UI · Analytics

+

Task Assignment Service Concept

+

+ Product concept: setting, assigning, and tracking tasks with an emphasis on process transparency and seamless teamwork. +

+

+ The goal of the concept is to show how the team views the workflow without “black boxes”: who is working on what, where the bottlenecks are, and which statuses are critical for the next step. +

+

+ A detailed case study will be posted here later; the page is already linked to a portfolio entry on the website. +

+

Cover

+
+ Task Distribution Service Concept — Case Cover +
Current preview for the portfolio card
+
+
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/cases/task-tracker/index.html b/public/en/cases/task-tracker/index.html new file mode 100644 index 0000000..49a72b4 --- /dev/null +++ b/public/en/cases/task-tracker/index.html @@ -0,0 +1,1355 @@ + + + + + + + Task Tracker for a Back-Office System — Case Study + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Test Assignment · Back Office

+

Task Tracker for an Internal Back-Office System

+

Assignment

+

+ Implement an interface for planning and assigning tasks to employees in the operations department who work in a back-office system. There are a large number of diverse tasks that can be grouped into the following categories: Type-1, Type-2, Type-3, Type-4, Type-5, Type-6, Type-7. Each task has its own automatically assigned priority. Users have access only to tasks from specific categories. +

+

The following needs to be implemented:

+
    +
  • + Main screen: +
      +
    • where the user can view all open general tasks and select some for themselves.
    • +
    • A personal to-do list displaying both manually selected tasks and automatically assigned ones.
    • +
    • For a selected task, there is the option to assign it to another employee or return it to the general list.
    • +
    +
  • +
  • A screen where users can see what tasks each person is working on.
  • +
+ +

Introduction

+

+ Since I personally have extensive experience working with task trackers, I’m drawing on my own experience and that of my colleagues, while taking the project requirements into account. +

+ +

References

+
+ References +
+
    +
  • Task trackers (Trello, Monday, YouTrack, etc.)
  • +
  • Services for visual database management (Notion, Airtable, etc.)
  • +
+ +

Solution

+ +

Screencast

+
+
+ +
+
+ + + +

Tabs

+

On the All Tasks tab, the employee can view all available tasks.

+
+ Untitled +
+

+ The "My tasks" tab displays only those tasks assigned to the current employee. Tasks are grouped by status (the status is hidden in the task card). +

+
+ Untitled +
+

The “Task by Assignee” tab displays all tasks broken down by assignee.

+
+ Untitled +
+

Options for the current tab are located on the right-hand side.

+
+ Untitled +
+

+ All changes are saved when you leave the tab and when you close the entire Tasks page. These settings are individual, and each employee can customize the page as they see fit. +

+
    +
  • Group—allows you to create additional groupings based on task properties (category, priority, assignee, status, creation date, modification date).
  • +
  • View—allows you to change the display of current tasks to a different view: board, list, or table.
  • +
  • Filter—allows you to hide tasks based on their properties.
  • +
  • + Sort—allows you to determine the order in which tasks are displayed based on their properties. If sorting by status (High, Medium, Low) is enabled, you can choose either alphabetical order or reverse order, or you can create your own order. Dates and numbers are sorted in ascending or descending order. +
  • +
  • + Search on page — allows you to display only those tasks whose title or description contains a specific search word or phrase. (Search functionality can be improved by adding search operators if research demonstrates the need.) +
  • +
+

+ The page uses lazy loading for tasks. As you scroll down, new tasks will load. The number of tasks is displayed in the headers; this can be helpful for estimating how far you need to scroll to see everything. There may be a large number of tasks, in which case it might make sense to add pagination as well. +

+ +

Display Options (Views)

+

+ Different views of the same tasks can be useful for various contexts in which the Tasks page is used, as well as to accommodate employees’ personal preferences, which increases their efficiency. An employee who isn’t annoyed by the interface works more productively and contributes more. +

+
+ Untitled +
+ +

+ The Board view makes it easier to use the drag-and-drop feature (see below). It’s also visually easier to determine the number of tasks in a category, which I imagine could be helpful. When tasks are grouped by completion status, moving cards from left to right creates a natural horizontal flow of tasks from point A to point B. +

+
+ Untitled +
+

+ The List view allows you to see more information in a smaller screen area. At the same time, this view is visually and functionally simpler than the table view, which means less cognitive load on the user. Also, all task titles start at the far left, making it easy to scan the list. +

+
+ Untitled +
+

+ The Table view allows you to work with all task fields immediately, without having to open the task in a modal window or a separate page. The table allows you to customize the width and order of columns, which can be convenient in certain use cases. +

+
+ Untitled +
+ +
+
+ +
+
+ + +

Drag & Drop

+

+ In all views, you can drag and drop elements (cards, list items, and table rows and columns). This promotes more productive work and feels natural to the user. For example, if you need to distribute tasks among employees (for an admin or manager), change the status of multiple tasks at once, or do so within a short period of time. +

+

Board view

+
+
+ +
+
+ +

List view

+
+
+ +
+
+ +

Advanced interactive prototype in ProtoPie

+

+ A schematic representation of how Drag & Drop works. This type of prototype helps designers better understand the nuances that need to be considered in the user flow, as well as when preparing specifications for developers. Also, by testing this prototype, you may realize that the feature needs to work completely differently and decide to redesign it right away. This saves overall development time. +

+
+
+ +
+
+ +

+ In the prototype, only the blue card can be moved, and not all positions work correctly. You can view the recording, which is available on the same prototype page. +

+ +

Style

+

+ The visual style can be anything you like without changing the framework itself. For example, it could be a bit more colorful with more expressive fonts. +

+

Light-colored theme

+
+ Light-colored theme +
+ +

Neutral dark theme

+
+ Neutral Dark Theme +
+ + +

Research

+

+ During the Figma prototyping phase, you can conduct interviews with several employees—for example, 5 people. First, add real tasks from the current system to the prototype; about 20 should be enough. Next, you can hold a video call with the respondent’s screen shared or meet in person to present a few tasks and ask some questions. During the session, the respondent can interact with the prototype, but if something doesn’t work, the interviewer can note the attempt and explain that this feature isn’t working yet, as well as ask what the respondent expects to see next. +

+

Tasks

+
    +
  1. Find your current tasks on the Tasks page under the All Tasks tab
  2. +
  3. Assign tasks to yourself to work on
  4. +
  5. Find a task related to a specific topic (the interviewer provides an example) that they have already completed
  6. +
  7. Show tasks that belong only to a specific category and must be completed by the end of the week
  8. +
  9. Request that the priority of several tasks be changed at once
  10. +
  11. Rate the visual appeal of the interface on a scale of 1 to 10
  12. +
  13. Rate your overall impression of the proposed concept on a scale of 1 to 10
  14. +
  15. Suggest additional features that would be useful
  16. +
+

Analytics

+

To assess the relevance of the implemented features, the following metrics must be collected.

+
    +
  • + Number of page views for the "Tasks" page page_views_tasks with the option to break them down +
      +
    • + by tabs +
        +
      • All tasks all_tasks_tab
      • +
      • My tasks my_tasks_tab
      • +
      • Tasks by Assignment tasks_by_assign
      • +
      +
    • +
    • + by role +
        +
      • Admin admin
      • +
      • Manager manager
      • +
      • User user
      • +
      +
    • +
    • + by search attempts +
        +
      • Search Used search used
      • +
      +
    • +
    +
  • +
  • + Number of enabled custom settings custom_settings with the option to group +
      +
    • + by tabs +
        +
      • All tasks all_tasks_tab
      • +
      • My tasks my_tasks_tab
      • +
      • Tasks by Assignment tasks_by_assign
      • +
      +
    • +
    • + by role +
        +
      • Admin admin
      • +
      • Manager manager
      • +
      • User user
      • +
      +
    • +
    • + by type +
        +
      • Group group
      • +
      • Filter filter
      • +
      • Sort sort
      • +
      +
    • +
    +
  • +
  • + Number of included species View Used view_used with the option to break down +
      +
    • + by tabs +
        +
      • All tasks all_tasks_tab
      • +
      • My tasks my_tasks_tab
      • +
      • Tasks by Assignment tasks_by_assign
      • +
      +
    • +
    • + by role +
        +
      • Admin admin
      • +
      • Manager manager
      • +
      • User user
      • +
      +
    • +
    +
  • +
+

+ Then, over the next six months (or sooner, depending on user activity), we’ll be able to see the results and conduct a study (in the form of interviews) on the features that are used least frequently and those that have received complaints, in order to gather high-quality data and get a complete picture of what’s happening. +

+ +

Additional details

+

+ When creating mockups in Figma, the following were used: auto-layout features; interactive components; plugins (Iconify, Fitter, Protofly, Shadow Maker, Dark Side, etc.); and the Community file (+40 Types of Cursor). About 60% of the layers are named and organized for easier workflow. An 8pt grid is used, with additional sizes of 2pt and 4pt. Color styles are available for light and dark themes. The design adapts to changing window sizes (no mobile version). +

+ +
+
+ +
+
+

See also

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    An interface for training and using AI models in business processes: working with data, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Worked on the Lunacy interface as part of the Icons8 team: user flow, complex prototypes, and improvements to functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring and Coaching
    • +
    +

    Primo RPA orchestrator’s web dashboard: projects, deployment, and monitoring of RPA robots. Redesign of key screens for production without interrupting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+ +
+ + + + + + + + + + + + diff --git a/public/en/cases/template-elements-demo/index.html b/public/en/cases/template-elements-demo/index.html new file mode 100644 index 0000000..3af4e5e --- /dev/null +++ b/public/en/cases/template-elements-demo/index.html @@ -0,0 +1,1290 @@ + + + + + + + Case Study Elements Demo — template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+

Demo · template

+

Case Study Example: Demo Elements

+

Text blocks and elements for quickly building a longread

+

The media for the demo is located in public/assets/ (the page builder does not copy them). See public/assets/README.md.

+ +

Context

+

+ This section shows an example of a text block for the introduction. Here, you can briefly describe the product, the audience, and the project’s limitations. + The text is neutral: the goal is to quickly sketch out the structure and check the page’s visual rhythm. +

+ +

Task

+

+ We need to put together a demo longread with the main UI elements: link cards, media blocks, and examples of content layout. + It’s also important to check how the blocks behave on narrow and wide screens. +

+ +

Solution

+

Below are some ready-made examples that you can copy into real-world projects.

+ +

Link Cards

+ + +

Image: 50% of the original size (no preview)

+
+ Full-size demo GIF +
Image at reduced actual size, centered.
+
+ +

Image: width of the content (with preview)

+
+ Demo images based on content width +
Image matches the width of the content. Clicking opens the viewer.
+
+ +

Image: slightly wider than the content (with preview)

+
+ The demo image is wider than the content +
The image extends slightly beyond the content column.
+
+ +

Long screenshot: collapsed height and button inside the preview

+

+ Block data-case-tall-media: width is specified by the data-case-tall-width="wide" (like a “wide” image) or content (per column). When collapsed, the top part of the frame is visible; at the bottom of the preview, there’s a solid “fog.” An “Expand” / “Collapse” button with a chevron icon appears above the bottom of the frame, inside the border; when expanded, the fog disappears; when collapsed, the page scrolls back to the preview. The preview height is set .case-media-tall__clip via --case-tall-max (default min(70vh, 32rem)). +

+
+
+
+ Demo: Long vertical screenshot of the interface + +
+
+ +
+
+
Long image img-long.webp (source PNG next to it in public/assets/template-elements-demo/).
+
+ +

Gallery: grid based on content width

+

+ The number of columns on a wide screen is set via figure: the style="--case-gallery-cols: 3". Below these thresholds, the grid collapses to two or one column. Clicking on a thumbnail opens the general case viewer. +

+ + +

Gallery: Full-page grid with side margins

+

+ The case-media-gallery--wide — same logic for extending beyond a column as in the single-column case-media--wide. Here, there are four columns on a wide screen (--case-gallery-cols: 4). +

+ + +

Slider: one image, arrows, and dots

+

+ Block .case-media-slider displays slides one at a time: navigation using “prev/next” arrows, + paging using dots (the active one is highlighted in accent color). Keyboard support (←/→, Home/End on dots), + touch/mouse swipe, and lazy loading of adjacent frames via data-src. Clicking on the active + slide opens the general case viewer. +

+
+
+
+
+ Slider, Frame 1 +
+
+ Slider, Frame 2 +
+
+ Slider, Frame 3 +
+
+ Slider, Frame 4 +
+
+
+ + +
+
+
+ + + + +
+
+ +

+ The same slider, but with the modifier case-media--wide — extends beyond the content column, + like single wide images. The previous/next buttons and dots behave the same way. +

+
+
+
+
+ Wide slider, frame 1 +
+
+ Wide slider, frame 2 +
+
+ Wide slider, frame 3 +
+
+ Wide slider, frame 4 +
+
+ Wide Slider, Frame 5 +
+
+ Wide slider, frame 6 +
+
+ Wide slider, frame 7 +
+
+
+ + +
+
+
+ + + + + + + +
+
+ +

+ Slider with horizontal (wide) frames of the same size—all images + 1024×800. +

+
+
+
+
+ Wide Slider, Frame 1 +
+
+ Wide Slider, Frame 2 +
+
+ Wide Slider, Frame 3 +
+
+ Wide Slider, Frame 4 +
+
+ Wide slider, frame 5 +
+
+ Wide Slider, Frame 6 +
+
+ Wide Slider, Frame 7 +
+
+
+ + +
+
+
+ + + + + + + +
+
+ +

+ The same slider, but with vertical frames of the same size—all images + 600×800. +

+
+
+
+
+ Vertical slider, frame 1 +
+
+ Vertical slider, frame 2 +
+
+ Vertical slider, frame 3 +
+
+ Vertical slider, frame 4 +
+
+ Vertical slider, frame 5 +
+
+ Vertical slider, frame 6 +
+
+ Vertical slider, frame 7 +
+
+
+ + +
+
+
+ + + + + + + +
+
+ +

+ A separate example: a toggle for the frame-switching animation mode— + slide (slide), fade (crossfade), and none (no transition). + The mode is set by the attribute data-slider-anim on the shape. +

+
+
+ + + +
+
+
+
+ Animation example, frame 1 +
+
+ Animation example, frame 2 +
+
+ Animation example, frame 3 +
+
+ Animation example, frame 4 +
+
+
+ + +
+
+
+ + + + +
+
+ +

Callout: Important paragraph with an emoji

+ + +

Video: Card with a shadow and fixed small corners

+
+
+ +
+
An example of a case-style video card.
+
+ +

Result

+

+ The template is ready to be copied into a new case study: all that’s left is to replace the fish with the actual text and insert the project’s real links and media. +

+
+
+ +
+
+

Portfolio

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring & coaching
    • +
    +

    Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + + +
+ +
+ + + + + + + + + + + diff --git a/public/en/cookie-policy/index.html b/public/en/cookie-policy/index.html new file mode 100644 index 0000000..0048a35 --- /dev/null +++ b/public/en/cookie-policy/index.html @@ -0,0 +1,475 @@ + + + + + + + Cookie Policy — Nikolai Mamadaev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+ +
+ +
+ +
+ + + + + + + diff --git a/public/en/index.html b/public/en/index.html new file mode 100644 index 0000000..581d4e2 --- /dev/null +++ b/public/en/index.html @@ -0,0 +1,1512 @@ + + + + + + + Nikolay Mamadaev — Product Designer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + +
+
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+
+
+
+

Nikolay Mamadaev

+

+ Taming complex interfaces + + web + +

+ +
+ +
+
+ + + +
+
+

Portfolio

+
    +
  1. + + +
    +

    Primo AI Server

    +
      +
    • UX/UI
    • +
    • Web App
    • +
    +

    Interface for training and using AI models in business processes: data work, automation scenarios, and Smart OCR.

    +
    +
    +
  2. +
  3. + + +
    +

    Lunacy

    +
      +
    • UX/UI
    • +
    • Desktop App
    • +
    +

    Work on the Lunacy interface at Icons8: user flows, complex prototypes, improved functionality and interface logic.

    +
    +
    +
  4. + + +
  5. + + +
    +

    Primo Orc

    +
      +
    • Mentoring & coaching
    • +
    +

    Primo RPA orchestrator web console: projects, launch and control of RPA bots. Redesign of key screens for production without halting releases.

    +
    +
    +
  6. + + +
+
+
+ + + + +
+
+

Experience

+
    +
  1. +
    +
    + January 2026 — Present +
    +

    + Astratech +

    +

    + Development and support of digital systems and tools that boost team efficiency. +

    +
    +
  2. +
  3. + +
    + February 2023 — November 2025 + + 2 years 10 months +
    +

    + Primo RPA + +

    +

    + A business process automation platform with a suite of products: a studio for building bots, an orchestrator to manage them, an Idea Hub for process selection, and AI servers for models and data recognition. +

    +
    +
  4. +
  5. + +
    + July 2022 — January 2023 + + 7 months +
    +

    + Icons8 (Lunacy) + +

    +

    + A vector editor for UI/UX and web design with Sketch support, a built-in library of icons/photos/illustrations, and a set of AI tools to speed up routine work. +

    +
    +
  6. +
  7. + +
    + January 2019 — December 2021 + + 3 years +
    +

    + MANU Video Game Maker + +

    +

    + No-code/low-code platform for building 2D and 3D games without writing code. +

    +
    +
  8. + + + +
+
+ +
+
+
+ +
+
+

Skills

+
+
+
+

Nikolay M:

+

+ My experience covers both launching a product's first versions from scratch and evolving mature solutions — where the goal is not to break familiar flows but to refine them carefully. +

+ +
+
+
+
+

+ Below are examples of real business tasks and how I approached them +

+ +
+
+
+ +
+
+
+

+ +

+ +
+
+ +
+
+

+ +

+ +
+
+ +
+
+

+ +

+ +
+
+ +
+
+

+ +

+ +
+
+ +
+
+
+ +
+ +
+ + + + +
+ +
+ + + + + + + + + diff --git a/public/en/resume/index.html b/public/en/resume/index.html new file mode 100644 index 0000000..978d004 --- /dev/null +++ b/public/en/resume/index.html @@ -0,0 +1,947 @@ + + + + + + + Resume — Nikolai Mamadaev + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+ + +
+ +
+ + +
+
+
+
+ Nikolai Mamadaev +
+

Resume

+

Nikolai Mamadaev

+

Senior UX/UI Designer
+ Product Designer
+ AI Designer

+
+
+ +
+
+
Portfolio
+
mamadaevv.site
+
+
+
Salary
+
from 250,000 ₽
+
+
+
Work Format
+
Remote
+
+
+
Email
+
nik@mamadaevv.site
+
+
+
Social Media
+
@mamadaevv
+
+
+
Languages
+
English (B2)
+
+
+
+ +
+

Work Experience

+
    +
  1. +
    +

    Astratech

    +

    — Present

    +
    +

    Development and support of digital systems and tools to improve employee productivity.

    + +
    +
    +
    +

    Responsibilities

    +
      +
    • Creating production-ready interfaces
    • +
    • Breaking down ideas and concepts and turning them into design tasks
    • +
    +
    +
    +

    Achievements

    +
      +
    • Set up a configured environment for delivering interfaces to production
    • +
    • Ran more than 15 hypothesis tests on working prototypes without involving the development department
    • +
    • Sped up processing of user testing results by applying AI tools (summarization, classification, transcription)
    • +
    +
    +
    +

    Stack

    +
      +
    • Figma
    • +
    • HTML/CSS/JS
    • +
    • AI/LLM
    • +
    • GitHub
    • +
    • Gitea
    • +
    • Coolify
    • +
    • CI/CD
    • +
    • Hermes Agent
    • +
    • Zed IDE
    • +
    +
    +
    +
    +
  2. + +
  3. +
    +

    Primo RPA

    +

    — (2 years and 10 months)·primo-rpa.com

    +
    +

    A Russian enterprise-grade B2B RPA platform: a suite of desktop robot development environments and web services for management, analytics, and ML.
    + I worked as a lead designer on the entire product line, consisting of 6 products.

    +
    +

    AI Server Platform (SaaS)

    +

    A control panel for training, running, testing, and other operations involving neural networks deployed within the company’s internal network.

    +
    +
    +
    Responsibilities
    +
      +
    • Design a web interface for the local LLM control panel. Enable the following scenarios: dataset creation, training of narrowly focused models, rapid testing, and administration of deployment on the client’s local infrastructure.
    • +
    • Improve how Smart OCR is configured for more accurate data extraction from documents of various types
    • +
    • Managing the states of long-running processes: model training, progress, errors, rollback
    • +
    • Preparing interactive prototypes and overseeing implementation
    • +
    +
    +
    +
    Achievements
    +
      +
    • Created an intuitive interface for handling the complex setup of LLM models. This significantly lowered the barrier to entry for users without deep neural network expertise.
    • +
    • Reduced setup time from 3–5 days to 2 hours, based on conversations with users
    • +
    • Designed a document annotation interface that reduced support requests on this topic by 60%
    • +
    • Conducted 8 usability sessions and, based on the results, revised the key model training scenario
    • +
    +
    +
    +
    Stack
    +
      +
    • Figma
    • +
    • React
    • +
    • Ant Design
    • +
    • Hope UI
    • +
    • LLM core
    • +
    • Smart OCR
    • +
    +
    +
    +
    + +
    +

    Robot Automation Studio (Windows)

    +

    An environment for developing and debugging robots for business process automation.

    +
    +
    +
    Responsibilities
    +
      +
    • Designing the interface for a professional desktop IDE: workspace, toolbars, process tree, debugger
    • +
    • Developing the product’s component library and visual language
    • +
    • Designing debugging scenarios: breakpoints, step-by-step execution, variable inspection
    • +
    • Designing support for large activity trees and nesting
    • +
    • Working in tandem with the development team on component implementation, providing author supervision
    • +
    • Conducting regular design reviews within the team
    • +
    +
    +
    +
    Achievements
    +
      +
    • Updated the product’s visual language to the Windows 11 standard—components are now a generation more modern
    • +
    • Redesigned dozens of screens and modal windows, bringing them into a single token-based design system.
    • +
    • Built a library of more than 50 reusable components, making it possible to design new functionality scenarios while preserving established behavior patterns
    • +
    • Redrew all legacy raster icons as vectors. This produced a library of over 300 unique icons that display perfectly at any UI scale and DPI
    • +
    • Reduced the time it took the team to build a new screen from 2 days to 4 hours by using pre-built components
    • +
    +
    +
    +
    Stack
    +
      +
    • Desktop IDE
    • +
    • Windows 10/11
    • +
    • Figma
    • +
    • AI tools
    • +
    • DevExpress UI
    • +
    • Handy Controls
    • +
    +
    +
    +
    + +
    +

    Robot Automation Studio (Linux)

    +

    An environment for developing and debugging robots for business process automation.

    +
    +
    +
    Responsibilities
    +
      +
    • Designing the IDE interface for Linux, based on the selected feature set and the already implemented Windows version
    • +
    • Designing components with the technical constraints of the Avalonia Suki UI framework in mind
    • +
    • Preparing component behavior specifications for implementation
    • +
    +
    +
    +
    Achievements
    +
      +
    • Built a component library based on Suki UI that lets me quickly assemble interfaces in Figma and hand them off to developers
    • +
    • Developed a product style that meets international standards, which will support expansion into foreign markets
    • +
    • Handed developers design instructions for assembling screens, so they can build interfaces for new features on their own and ship releases according to the roadmap
    • +
    +
    +
    +
    Stack
    +
      +
    • Avalonia
    • +
    • Suki UI
    • +
    • Figma
    • +
    • AI tools
    • +
    • Cursor IDE
    • +
    • MobaXterm RDP
    • +
    • Linux
    • +
    +
    +
    +
    + +
    +

    Idea Hub Dashboard (SaaS)

    +

    Enables the collection and analysis of employee suggestions, performance evaluation, and monitoring of automation implementation status. Analytics on key performance indicators and a high-level view of automation.

    +
    +
    +
    Responsibilities
    +
      +
    • Designing dashboards and analytical views for robotization KPIs
    • +
    • Managing the idea lifecycle: submission → evaluation → prioritization → implementation → impact measurement
    • +
    • Section information architecture, working with dense tables, filters, and bulk operations
    • +
    • Designing a role-based model: employee, analyst, manager
    • +
    • Selecting data visualization types for specific management questions
    • +
    +
    +
    +
    Achievements
    +
      +
    • Integrated high-level analytics and work on a specific idea into a single continuous workflow without switching between sections
    • +
    • Designed data visualizations so that managers can see the impact of automation without having to manually export data to Excel
    • +
    • Reduced the process from idea submission to evaluation from 6 steps to 3
    • +
    • Designed 12 analytics widgets covering customers’ key questions about the ROI of automation
    • +
    +
    +
    +
    Stack
    +
      +
    • Figma
    • + + +
    +
    +
    +
    + +
    +

    Orchestrator Automation Management Tool (SaaS)

    +

    Ensures seamless system operation and management of hundreds of robots: launching, monitoring, and management.

    +
    +
    +
    Responsibilities
    +
      +
    • Designing a management interface for a fleet of hundreds of robots: schedules, queues, priorities, and monitoring
    • +
    • Developing state management and error handling for bulk launches
    • +
    • Designing a system for notifications and alerts regarding failures
    • +
    • Working with complex tables: sorting, filtering, bulk actions, saved views
    • +
    • Prototyping complex scenarios for validation prior to development
    • +
    +
    +
    +
    Achievements
    +
      +
    • Designed a system to manage hundreds of objects so that the operator doesn’t get lost in the scale: from the big picture to a specific robot in just 2 clicks
    • +
    • Aligned the service with a unified design system alongside the rest of the product line’s web products
    • +
    • Reduced operator response time to robot failures from 12 to 4 minutes thanks to a redesigned alert system
    • +
    • Consolidated 5 different robot list views into a single customizable one
    • +
    +
    +
    +
    Stack
    +
      +
    • Figma
    • +
    • Ant Design
    • +
    • Storybook
    • +
    • AI tools
    • + + +
    +
    +
    +
    + +
    +

    Q&A Portal (WEB)

    +

    A platform for posting questions and receiving answers from support

    +
    +
    +
    Responsibilities
    +
      +
    • Designing workflows for posting questions, providing answers, and searching the accumulated knowledge base
    • +
    • Designing the portal interface under the constraints of the PHP engine used for a QnA-type site.
    • +
    • Developing a role model: user, expert, support team, moderator
    • +
    • Designing search mechanics and suggestions for similar questions when posting
    • +
    +
    +
    +
    Achievements
    +
      +
    • Launched the portal using off-the-shelf components from the design system in 3 weeks instead of the planned 8
    • +
    • Reduced the support workload by approximately 25% by reusing existing answers
    • +
    • The suggestion feature for similar questions reduced duplicate questions by 45%
    • +
    • Ensured visual consistency with other products in the line—the portal does not look like a third-party service
    • +
    +
    +
    +
    Stack
    +
      +
    • PHP QnA Engine
    • +
    • Figma
    • +
    • Design system
    • + +
    +
    +
    +
    + +
    +

    Company-wide

    +
      + +
    • I developed and implemented a unified design system for web services, integrating the company’s brand identity with the new interface structure
    • + +
    • Reduced the average time to prepare mockups across the product line by 35%
    • +
    • Created and maintained a UI kit to ensure consistency and accelerate development
    • +
    • Conducted more than 20 user studies and usability sessions during my tenure
    • +
    • Oversaw implementation, participated in design reviews, product discussions, and release planning
    • +
    • Hired 4 interface designers for different company projects
    • +
    • Conducted more than 30 interviews, including introducing the WhiteBoard Interview format
    • +
    • Developed test assignments tailored to the needs of specific roles and tasks
    • +
    • Introduced an internal video call system based on Jitsi within the company’s internal network, together with the technical support department
    • + +
    +
    +
  4. + +
  5. +
    +

    Lunacy (Icons8)

    +

    July 2022 – January 2023 (7 months) · icons8.com/lunacy

    +
    +

    A graphic editor for working on interface designs and other mockups. Similar to Sketch App and Figma.

    +
    +
    +
    +

    Responsibilities

    +
      +
    • Designing the graphic editor’s interface: tools, property panels, working with the canvas and layers
    • +
    • Documenting solutions as user flows and handing them off to developers
    • +
    • Creating complex interactive prototypes in Protopie to demonstrate tool behavior
    • +
    +
    +
    +

    Achievements

    +
      +
    • Designed 6 functional blocks for the editor, including work with components and styles
    • +
    • Built a Protopie prototype for a new feature: editing multi-layered realistic object shadows with complex behavior logic, almost indistinguishable from the production solution. Not counting smaller, simple prototypes.
    • +
    • Validated 3 hypotheses for improving the editor with real users. 1 made it into the release.
    • +
    +
    +
    +

    Stack

    +
      +
    • Protopie
    • +
    • Figma
    • +
    • Design system
    • +
    • Avalonia Custom UI
    • +
    • Windows
    • +
    • Linux
    • +
    • macOS
    • +
    +
    +
    +
    +
  6. + +
  7. +
    +

    MANU Video Game Maker

    +

    — (3 years)·manu.co

    +
    +

    A game engine for creating video games without writing code.

    +
    +
    +
    +

    Responsibilities

    +
      +
    • End-to-end product design of the engine: from interaction concepts to the final UI
    • +
    • Designing interface behavior for complex user scenarios
    • +
    • User research: observation, in-depth interviews, surveys
    • +
    • Creating user personas and empathy maps
    • +
    • Working with product analytics and metrics tied to user scenarios
    • +
    • Layout of the website’s homepage on Tilda, designing the site’s information architecture together with the team
    • +
    • Supervising the work of the web graphic designer
    • +
    • Hiring for the product department: conducting interviews and selecting specialists
    • +
    • Leading the department and establishing workflows for the entire product team
    • +
    • Overseeing the creation of user documentation
    • +
    • Implementing automation tools (Integromat, Zapier) for internal tasks
    • +
    +
    +
    +

    Achievements

    +
      +
    • Led a large-scale redesign of the entire program: established a fundamentally new concept and style
    • +
    • Designed the interface behavior for over 100 complex user scenarios
    • +
    • Designed the product’s design system from scratch: 120 components
    • +
    • Received a lot of positive, and in some cases enthusiastic, user feedback—the product became much easier to navigate
    • +
    • Onboarding completion rates increased from 34% to 58% following the redesign
    • +
    • Conducted 25 in-depth interviews and 4 rounds of surveys, and revised 3 key scenarios based on the results
    • +
    • Hired 4 specialists for the product department and grew from a designer to a team lead
    • +
    • Established departmental processes: design reviews, backlog management, and handoff to development
    • +
    • Automated the team’s internal routines, freeing up time for product tasks
    • +
    +
    +
    +

    Stack

    +
      + +
    • Zapier
    • +
    • Figma
    • +
    • Miro
    • +
    • Mailchimp
    • +
    • Windows
    • +
    • Notion
    • +
    • Airtable
    • +
    +
    +
    +
    +
  8. + +
  9. +
    +

    Aegees Messenger

    +

    — (1 year and 3 months)·aegees.com

    +
    +

    A cross-platform messenger with secure storage and audio calls.

    +
    +
    +
    +

    Responsibilities

    +
      +
    • Designing the messenger for five platforms simultaneously: Android, iOS, macOS, Windows, and Linux
    • +
    • Developing use cases for secure storage and audio calls
    • +
    • Designing scenarios for registration, encryption, and access recovery
    • +
    • Adapting a unified product logic to each platform’s guidelines
    • +
    • Supervising the work of international illustrators in the preparation of graphic assets
    • +
    • Preparation of materials for app stores
    • +
    +
    +
    +

    Achievements

    +
      +
    • Developed a unified visual language for the product while adhering to the guidelines of five platforms simultaneously
    • +
    • Designed over 90 screens across five platform versions
    • +
    • Ensured a steady flow of graphic assets from external contractors without compromising the design style
    • +
    • Simplified the secure storage workflow so that people without technical training could use it
    • +
    • Worked in the sensitive areas of privacy and security, where a single interface error can cost user trust
    • +
    +
    +
    +

    Stack

    +
      +
    • Android
    • +
    • iOS
    • +
    • macOS
    • +
    • Windows
    • +
    • Linux
    • +
    +
    +
    +
    +
  10. + +
  11. +
    +

    Orion Innovation (formerly MERA)

    +

    July 2016 – October 2017 (1 year and 4 months)·orioninnovation.com

    +
    +

    Software development for external clients.

    +
    +
    +
    +

    Responsibilities

    +
      +
    • Worked in a design department with up to 8 people
    • +
    • Creating user personas and mapping product functionality based on user needs
    • +
    • Creating wireframes and designing the final UI
    • + +
    • Preparing mood boards and design concepts, and discussing them with clients
    • + +
    • Turning mockups into prototypes, writing scripts, and conducting qualitative usability testing
    • + + +
    • Participating in design reviews: analyzing colleagues’ work and defending your own decisions
    • + +
    +
    +
    +

    Achievements

    +
      +
    • Conducted 10 usability testing sessions, from writing the test script to implementing changes
    • +
    • Learned to work with the Smart TV platform, which was new to me, and brought the project to successful release in the app store
    • +
    • Conducted research and reverse-engineering of app design for the Roku TV platform and created a client app design development workflow that includes: idea formation, clickable prototype development, usability testing on the actual device, and design control in the app built by our developers
    • +
    • Communicated directly with international clients, without intermediaries
    • +
    • Actively participated in improving the company’s design department workflow
    • +
    • Introduced the team to using Miro (ex. Realtime Board) for storing research artifacts
    • + +
    +
    +
    +

    Stack

    +
      +
    • Sketch
    • +
    • Miro
    • +
    • Android
    • +
    • iOS
    • +
    • ROKU TV Box
    • +
    • Smart TV
    • +
    • Marvel App
    • +
    • ProtoPie
    • +
    • Framer
    • + + +
    +
    +
    +
    +
  12. + +
  13. +
    +

    SpaceshipApps Studio

    +

    — (1 year and 1 month)·spaceshipapps.ru

    +
    +

    Mobile app and website development studio.

    +
    +
    +
    +

    Responsibilities

    +
      +
    • Designing mobile and web products as part of the studio’s workflow
    • +
    • Worked across various sectors: social gaming, beauty services, food delivery, voice interfaces, legal services, and collaborative tools
    • +
    • Responsive web design and design of administrative dashboards
    • +
    • Illustrating icons and preparing graphics for projects
    • +
    • Preparing mockups for handoff to development
    • +
    +
    +
    +

    Achievements

    +
      +
    • Launched 6 products in one year: a geolocation-based social game with real-world quests, an app for booking beauty professionals, a food delivery service, a voice-based word game, a website and admin panel for a copyright registration service, and a mobile “whiteboard” for collaboration
    • +
    • Learned to quickly get up to speed in new subject areas and maintain quality while keeping up with the studio’s fast pace
    • +
    • Handled the full range of tasks: mobile, web, admin panels, and graphics
    • +
    • Laid the groundwork for transitioning from execution-focused design to product-focused design
    • +
    +
    +
    +

    Stack

    +
      +
    • Mobile
    • +
    • Web
    • +
    • Adobe Photoshop
    • +
    • After Effects
    • +
    • Adobe Illustrator
    • + + + +
    +
    +
    +
    +
  14. +
+
+ +
+

Skills

+
+
+
Design
+
UX/UI, product design, information architecture, responsive design, design systems
+
+
+
Research
+
Interviews, surveys, usability testing, JTBD, empathy maps, conversion, retention, NPS
+
+
+
Management
+
Team building, Agile, Kanban, project supervision, working with stakeholders
+
+
+
Tools
+
Figma, Protopie, Sketch, Adobe Creative Suite, Storybook
+
+
+
AI Tools
+
Hermes Agent, Perplexity, Cursor IDE, Zed IDE, Open Design, Codex, Claude Code
+
+
+
+ +
+

About Me

+

+ I specialize in SaaS products and desktop applications. I design clear, scalable, and efficient interfaces for complex workflows, so users solve their tasks faster and the product becomes more convenient and useful. +

+

+ I work across every stage of product design: researching user needs, shaping information architecture, designing user flows, building interactive prototypes with vibe coding, and creating scalable design systems with Figma + Storybook. +

+

+ I stay active in the community: I run the Telegram channel “Design Isn’t Pizza,” share resources on Figma Community, spoke about voice skill analytics at a Yandex meetup, and talked about a designer’s toolkit at a business forum. I’ve won hackathons more than once. +

+
+ +
+
+ +
+ +
+ + + + + + diff --git a/public/index.html b/public/index.html index 5724d78..fc84cdf 100644 --- a/public/index.html +++ b/public/index.html @@ -11,9 +11,9 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + @@ -89,10 +208,10 @@
+ +
+ + 100% +
-
Загрузка...
+
`; @@ -37,11 +48,15 @@ export function initCaseImageViewer() { const stage = overlay.querySelector("[data-imgv-stage]"); const viewImg = overlay.querySelector("[data-imgv-img]"); const loading = overlay.querySelector("[data-imgv-loading]"); + const loaderMsg = overlay.querySelector("[data-imgv-loader-msg]"); const zoomLabel = overlay.querySelector("[data-imgv-zoom-label]"); const zoomSlider = overlay.querySelector("[data-imgv-slider]"); const btnFit = overlay.querySelector("[data-imgv-fit]"); const btnOriginal = overlay.querySelector("[data-imgv-original]"); const btnClose = overlay.querySelector("[data-imgv-close]"); + const btnPrev = overlay.querySelector("[data-imgv-prev]"); + const btnNext = overlay.querySelector("[data-imgv-next]"); + const dotsWrap = overlay.querySelector("[data-imgv-dots]"); let scale = 1; let naturalW = 0; @@ -56,11 +71,33 @@ export function initCaseImageViewer() { let panScrollLeft = 0; let panScrollTop = 0; let panMoved = false; + let panDirX = 0; let closeTimer = 0; let openRaf = 0; const viewerAnimMs = 210; let centeredAnchor = null; + // Состояние листалки: группа картинок (например, одна галерея) + индекс в ней. + let groupImgs = []; + let groupIndex = -1; + + function resolveGroup(img) { + // Поднимаемся до ближайшего контейнера группы, пропуская отдельные ячейки/слайды. + const container = img.closest( + ".case-media-gallery, .case-media-slider, .case-media, figure", + ); + const scope = container || img.parentElement || document; + const list = Array.from( + scope.querySelectorAll("img[data-image-viewer]"), + ).filter((el) => { + if (el.hasAttribute("data-src")) return false; + const s = el.getAttribute("src") || el.currentSrc || ""; + if (isViewerThumbnailPlaceholder(s)) return false; + return true; + }); + return list; + } + function measureToolbarSpace() { const toolbarRect = overlay.querySelector(".case-image-viewer__toolbar")?.getBoundingClientRect(); const next = toolbarRect ? Math.ceil(toolbarRect.height + 22) : 0; @@ -160,7 +197,12 @@ export function initCaseImageViewer() { centeredAnchor = null; } - function openViewer(src, alt) { + function showAt(group, index) { + if (index < 0 || index >= group.length) return; + groupIndex = index; + const img = group[groupIndex]; + const src = img.getAttribute("src") || img.currentSrc || ""; + const alt = img.alt || ""; if (closeTimer) { window.clearTimeout(closeTimer); closeTimer = 0; @@ -171,15 +213,52 @@ export function initCaseImageViewer() { } overlay.classList.remove("is-open"); overlay.classList.add("is-loading"); + if (loading) loading.classList.remove("is-error"); + if (loaderMsg) loaderMsg.textContent = ""; centeredAnchor = null; viewImg.onload = null; viewImg.onerror = null; viewImg.src = src; - viewImg.alt = alt || ""; - lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; - overlay.hidden = false; - document.documentElement.classList.add("imgv-lock"); - active = true; + viewImg.alt = alt; + // Открываем просмотрщик сразу (поверх показывается спиннер), не дожидаясь загрузки. + openRaf = window.requestAnimationFrame(() => { + overlay.classList.add("is-open"); + openRaf = 0; + }); + + // Навигация внутри группы (галерея / слайдер). Скрываем, если картинка одна. + const multi = group.length > 1; + btnPrev.hidden = !multi; + btnNext.hidden = !multi; + if (multi) { + // Точки-пагинация в тулбаре. + if (dotsWrap.childElementCount !== group.length) { + dotsWrap.innerHTML = group + .map( + (_, i) => + ``, + ) + .join(""); + dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot) => { + dot.addEventListener("click", (e) => { + e.stopPropagation(); + const i = Number(dot.getAttribute("data-imgv-dot")); + showAt(group, i); + }); + }); + } + dotsWrap.hidden = false; + dotsWrap.querySelectorAll("[data-imgv-dot]").forEach((dot, i) => { + const active = i === groupIndex; + dot.classList.toggle("is-active", active); + dot.setAttribute("aria-selected", active ? "true" : "false"); + dot.tabIndex = active ? 0 : -1; + }); + btnPrev.disabled = groupIndex === 0; + btnNext.disabled = groupIndex === group.length - 1; + } else { + dotsWrap.hidden = true; + } const applyAfterLoad = () => { overlay.classList.remove("is-loading"); @@ -203,10 +282,10 @@ export function initCaseImageViewer() { const applyLoadError = () => { overlay.classList.remove("is-loading"); - if (loading) loading.textContent = "Не удалось загрузить"; + if (loading) loading.classList.add("is-error"); + if (loaderMsg) loaderMsg.textContent = "Не удалось загрузить"; }; - if (loading) loading.textContent = "Загрузка..."; if (viewImg.complete && viewImg.naturalWidth > 0) applyAfterLoad(); else { viewImg.onload = applyAfterLoad; @@ -228,7 +307,8 @@ export function initCaseImageViewer() { overlay.hidden = true; stage.classList.remove("is-center-y"); stage.classList.remove("is-centered"); - if (loading) loading.textContent = "Загрузка..."; + if (loading) loading.classList.remove("is-error"); + if (loaderMsg) loaderMsg.textContent = ""; viewImg.onload = null; viewImg.onerror = null; viewImg.removeAttribute("src"); @@ -244,12 +324,20 @@ export function initCaseImageViewer() { const t = e.target; if (!(t instanceof Element)) return; const img = t.closest("img[data-image-viewer]"); - if (!img || img.closest(".case-media--natural")) return; + if (!img) return; if (img.hasAttribute("data-src")) return; const src = img.currentSrc || img.getAttribute("src") || ""; if (!src || isViewerThumbnailPlaceholder(src)) return; e.preventDefault(); - openViewer(src, img.alt || ""); + const group = resolveGroup(img); + const index = group.indexOf(img); + groupImgs = group; + groupIndex = index >= 0 ? index : 0; + lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + overlay.hidden = false; + document.documentElement.classList.add("imgv-lock"); + active = true; + showAt(groupImgs, groupIndex >= 0 ? groupIndex : 0); }, true, ); @@ -265,8 +353,24 @@ export function initCaseImageViewer() { btnOriginal.addEventListener("click", () => zoomTo(1)); btnClose.addEventListener("click", closeViewer); + function navigateGroup(dir) { + if (!groupImgs.length) return; + const next = Math.min(Math.max(groupIndex + dir, 0), groupImgs.length - 1); + if (next === groupIndex) return; + showAt(groupImgs, next); + } + + btnPrev.addEventListener("click", (e) => { + e.stopPropagation(); + navigateGroup(-1); + }); + btnNext.addEventListener("click", (e) => { + e.stopPropagation(); + navigateGroup(1); + }); + overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeViewer(); + if (e.target === overlay && !overlay.classList.contains("is-loading")) closeViewer(); }); stage.addEventListener( @@ -300,7 +404,10 @@ export function initCaseImageViewer() { if (!active || !isPanning) return; const dx = e.clientX - panStartX; const dy = e.clientY - panStartY; - if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + panMoved = true; + panDirX = dx; + } stage.scrollLeft = panScrollLeft - dx; stage.scrollTop = panScrollTop - dy; e.preventDefault(); @@ -314,9 +421,16 @@ export function initCaseImageViewer() { } stage.addEventListener("pointerup", (e) => { + const horizSwipe = panMoved && Math.abs(panDirX) > 40 && groupImgs.length > 1; const shouldClose = !panMoved; stopPan(e.pointerId); - if (shouldClose && active) { + if (horizSwipe) { + e.preventDefault(); + navigateGroup(panDirX < 0 ? 1 : -1); + return; + } + // Пока грузится изображение — не закрываем по тапу/клику. + if (shouldClose && active && !overlay.classList.contains("is-loading")) { e.preventDefault(); closeViewer(); } @@ -365,6 +479,17 @@ export function initCaseImageViewer() { if (e.key === "0") { e.preventDefault(); zoomTo(fitScale()); + return; + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + navigateGroup(-1); + return; + } + if (e.key === "ArrowRight") { + e.preventDefault(); + navigateGroup(1); + return; } }); } diff --git a/public/js/modules/cases/media-slider.js b/public/js/modules/cases/media-slider.js new file mode 100644 index 0000000..98456d7 --- /dev/null +++ b/public/js/modules/cases/media-slider.js @@ -0,0 +1,209 @@ +// Слайдер изображений для лонгридов кейсов (одна картинка + стрелки + точки). +// Разметка: +//
+//
+//
+//
+// ... +//
+//
+// +// +//
+// +// ... +//
+//
+ +function clampIndex(i, len) { + if (len <= 0) return 0; + return Math.min(Math.max(i, 0), len - 1); +} + +function loadSlide(slide) { + if (!slide) return; + const img = slide.querySelector("img[data-src]"); + if (img && img.dataset.src) { + img.src = img.dataset.src; + delete img.dataset.src; + } +} + +function loadAround(slides, index) { + loadSlide(slides[index]); + loadSlide(slides[index - 1]); + loadSlide(slides[index + 1]); +} + +export function initCaseMediaSlider() { + const roots = document.querySelectorAll("[data-case-media-slider]"); + roots.forEach((root) => { + const viewport = root.querySelector("[data-slider-viewport]"); + const track = root.querySelector("[data-slider-track]"); + const prevBtn = root.querySelector("[data-slider-prev]"); + const nextBtn = root.querySelector("[data-slider-next]"); + const navBtns = [prevBtn, nextBtn].filter(Boolean); + + // Имитация :active для иконки (swipe-обработчик viewport сбрасывает нативный :active) + navBtns.forEach((btn) => { + const press = () => btn.classList.add("is-pressed"); + const release = () => btn.classList.remove("is-pressed"); + btn.addEventListener("pointerdown", press); + btn.addEventListener("mousedown", press); + btn.addEventListener("pointerup", release); + btn.addEventListener("mouseup", release); + btn.addEventListener("pointerleave", release); + btn.addEventListener("mouseleave", release); + btn.addEventListener("pointercancel", release); + }); + const dotsWrap = root.querySelector("[data-slider-dots]"); + if (!viewport || !track) return; + + // Авто-подстройка ширины под первую картинку — только для --natural (auto) слайдеров, + // и только если --slider-natural-w не задана вручную. Базовый/wide управляются CSS. + const firstImg = root.querySelector(".case-media-slider__slide img"); + const applyAutoWidth = (img) => { + if (root.style.getPropertyValue("--slider-natural-w")) return; + const nw = img.naturalWidth || 0; + if (nw > 0) root.style.setProperty("--slider-natural-w", `${nw}px`); + }; + if (root.classList.contains("case-media--natural") && firstImg) { + if (firstImg.complete && firstImg.naturalWidth > 0) { + applyAutoWidth(firstImg); + } else { + firstImg.addEventListener("load", () => applyAutoWidth(firstImg), { once: true }); + } + } + + const slides = Array.from(track.querySelectorAll(":scope > .case-media-slider__slide")); + const dots = dotsWrap + ? Array.from(dotsWrap.querySelectorAll(":scope > [data-slider-dot]")) + : []; + const count = slides.length; + if (count === 0) return; + + let current = 0; + + const reduceMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + // Режим анимации переключения: fade (по умолчанию) / slide / none + let mode = root.getAttribute("data-slider-anim") || "fade"; + function applyMode(m) { + mode = m; + root.classList.remove( + "case-media-slider--anim-slide", + "case-media-slider--anim-fade", + "case-media-slider--anim-none" + ); + root.classList.add(`case-media-slider--anim-${m}`); + if (m === "fade") { + track.style.transform = "none"; + } else { + track.style.transform = `translateX(${-100 * current}%)`; + } + } + applyMode(mode); + + function goTo(index, behavior) { + const next = clampIndex(index, count); + current = next; + if (mode !== "fade") { + const offset = -100 * next; + track.style.transform = `translateX(${offset}%)`; + } else { + track.style.transform = "none"; + } + track.style.transition = reduceMotion || behavior === "auto" + ? "none" + : ""; + slides.forEach((s, i) => { + s.classList.toggle("is-active", i === next); + s.setAttribute("aria-hidden", i === next ? "false" : "true"); + }); + dots.forEach((d, i) => { + const active = i === next; + d.classList.toggle("is-active", active); + d.setAttribute("aria-selected", active ? "true" : "false"); + d.tabIndex = active ? 0 : -1; + }); + if (prevBtn) prevBtn.disabled = next === 0; + if (nextBtn) nextBtn.disabled = next === count - 1; + loadAround(slides, next); + } + + prevBtn?.addEventListener("click", () => goTo(current - 1)); + nextBtn?.addEventListener("click", () => goTo(current + 1)); + + dots.forEach((dot, i) => { + dot.addEventListener("click", () => goTo(i)); + }); + + // Клавиатура: стрелки при фокусе внутри слайдера. + root.addEventListener("keydown", (e) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + goTo(current - 1); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + goTo(current + 1); + } + }); + + // Поддержка свопа по клику на точку при навигации с клавиатуры. + dotsWrap?.addEventListener("keydown", (e) => { + if (e.key === "Home") { + e.preventDefault(); + goTo(0); + } else if (e.key === "End") { + e.preventDefault(); + goTo(count - 1); + } + }); + + // Свайп на тач/мыши (только горизонтальный). + let drag = null; + viewport.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + drag = { x: e.clientX, moved: false }; + }); + viewport.addEventListener("pointermove", (e) => { + if (!drag) return; + if (Math.abs(e.clientX - drag.x) > 6) drag.moved = true; + }); + function endDrag(e) { + if (!drag) return; + const dx = e.clientX - drag.x; + if (drag.moved && Math.abs(dx) > 40) { + goTo(current + (dx < 0 ? 1 : -1)); + } + drag = null; + } + viewport.addEventListener("pointerup", endDrag); + viewport.addEventListener("pointercancel", endDrag); + + // Инициализация. + slides.forEach((s, i) => { + s.setAttribute("aria-hidden", i === 0 ? "false" : "true"); + s.classList.toggle("is-active", i === 0); + }); + goTo(0, "auto"); + + // UI-переключатель режима анимации (для демо-примера), если есть в разметке. + const animSwitch = root.querySelector("[data-slider-anim-switch]"); + if (animSwitch) { + const btns = Array.from(animSwitch.querySelectorAll("button")); + const sync = () => btns.forEach((b) => { + b.setAttribute("aria-pressed", String(b.dataset.mode === mode)); + }); + sync(); + btns.forEach((b) => { + b.addEventListener("click", () => { + applyMode(b.dataset.mode || "slide"); + sync(); + }); + }); + } + }); +} diff --git a/public/js/modules/cases/natural-image-size.js b/public/js/modules/cases/natural-image-size.js index cbf98be..a6fb4cf 100644 --- a/public/js/modules/cases/natural-image-size.js +++ b/public/js/modules/cases/natural-image-size.js @@ -1,5 +1,7 @@ export function initCaseNaturalImagesHalfSize() { - const images = Array.from(document.querySelectorAll(".case-media--natural img")); + const images = Array.from( + document.querySelectorAll(".case-media--natural img") + ).filter((img) => !img.closest(".case-media-slider")); if (!images.length) return; function applyHalfSize(img) { diff --git a/public/js/modules/header/nav-overflow.js b/public/js/modules/header/nav-overflow.js index a1f0c51..e0d5a6c 100644 --- a/public/js/modules/header/nav-overflow.js +++ b/public/js/modules/header/nav-overflow.js @@ -10,7 +10,20 @@ export function initNavOverflow() { const links = Array.from(nav.querySelectorAll("a")); const MENU_GAP = 6; - const VIEWPORT_PAD = 8; + function getEdgePad() { + const raw = getComputedStyle(document.documentElement).getPropertyValue("--edge-pad").trim(); + if (!raw) return 8; + // resolve через временный элемент, т.к. значение — max(...clamp...) + const probe = document.createElement("span"); + probe.style.all = "unset"; + probe.style.position = "fixed"; + probe.style.left = raw; + document.body.appendChild(probe); + const px = probe.getBoundingClientRect().left; + probe.remove(); + return Math.max(0, Math.round(px)); + } + let VIEWPORT_PAD = getEdgePad(); let openInFloatingMode = null; let resizeRaf = 0; @@ -139,6 +152,7 @@ export function initNavOverflow() { const onResize = () => { if (resizeRaf) cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(() => { + VIEWPORT_PAD = getEdgePad(); layoutLinks(); placeMoreMenu(); resizeRaf = 0; diff --git a/public/js/modules/mini-projects/masonry.js b/public/js/modules/mini-projects/masonry.js index 3e5397c..d3c8a9e 100644 --- a/public/js/modules/mini-projects/masonry.js +++ b/public/js/modules/mini-projects/masonry.js @@ -26,11 +26,23 @@ function initMiniProjectsMasonryList(list) { return 1; } - function readDeclaredCells(el) { - const raw = el.getAttribute("data-mp-cells"); - const n = raw == null ? 1 : Number.parseInt(String(raw), 10); - if (!Number.isFinite(n)) return 1; - return Math.min(3, Math.max(1, n)); + function readDeclaredCells(el, rowCells) { + const full = Number.parseInt(String(el.getAttribute("data-mp-cells")), 10) || 1; + if (rowCells <= 2) { + const xs = el.getAttribute("data-mp-cells-xs"); + if (xs != null) { + const n = Number.parseInt(String(xs), 10); + if (Number.isFinite(n)) return Math.min(Math.max(n, 1), rowCells); + } + } + if (rowCells <= 4) { + const sm = el.getAttribute("data-mp-cells-sm"); + if (sm != null) { + const n = Number.parseInt(String(sm), 10); + if (Number.isFinite(n)) return Math.min(Math.max(n, 1), rowCells); + } + } + return Math.min(Math.max(full, 1), rowCells); } function pickStartColumn(heights, rowCells, wCells) { @@ -90,8 +102,22 @@ function initMiniProjectsMasonryList(list) { const heights = Array(rowCells).fill(0); const positions = new Map(); - allItems.forEach((item) => { - const want = readDeclaredCells(item); + allItems.filter((item) => !item.hidden).forEach((item) => { + const want = readDeclaredCells(item, rowCells); + let hVal = 0; + if (rowCells <= 2) { + const xs = item.getAttribute("data-mp-height-xs"); + if (xs != null) hVal = Number.parseInt(xs, 10) || 0; + } + if (!hVal && rowCells <= 4) { + const sm = item.getAttribute("data-mp-height-sm"); + if (sm != null) hVal = Number.parseInt(sm, 10) || 0; + } + if (!hVal) { + const hAttr = item.getAttribute("data-mp-height"); + if (hAttr) hVal = Number.parseInt(hAttr, 10) || 0; + } + item.style.height = hVal > 0 ? `${hVal}px` : ""; const wCells = Math.min(want, rowCells); const c = pickStartColumn(heights, rowCells, wCells); let y = 0; @@ -113,7 +139,7 @@ function initMiniProjectsMasonryList(list) { fullHeight = Math.max(0, Math.round(Math.max(...heights, 0) - gap) + 1); collapsedHeight = 0; - baseItems.forEach((item) => { + baseItems.filter((item) => !item.hidden).forEach((item) => { const p = positions.get(item); if (!p) return; collapsedHeight = Math.max(collapsedHeight, Math.round(p.y + p.h) + 1); diff --git a/public/js/modules/theme/theme-controls.js b/public/js/modules/theme/theme-controls.js index bf12f0e..200c578 100644 --- a/public/js/modules/theme/theme-controls.js +++ b/public/js/modules/theme/theme-controls.js @@ -7,6 +7,7 @@ import { L_RANGE, RADIUS_IDS, THEME_MODE_IDS, + effectiveThemeMode, FONT_IDS, MESH_BG_IDS, applyThemeState, @@ -40,9 +41,13 @@ export function initThemeControls() { applyThemeState(state); ensureThemeFontLoaded(state.font); + function acc() { + return state[effectiveThemeMode(state)] || state.light; + } + function syncHueSlider() { - hueSlider.value = String(Math.round(state.h)); - hueSlider.setAttribute("aria-valuenow", String(Math.round(state.h))); + hueSlider.value = String(Math.round(acc().h)); + hueSlider.setAttribute("aria-valuenow", String(Math.round(acc().h))); } function persistAndApply() { @@ -65,8 +70,8 @@ export function initThemeControls() { } syncHueSlider(); - drawSLPlane(canvas, state.h); - updateKnob(knob, state.s, state.l); + drawSLPlane(canvas, acc().h); + updateKnob(knob, acc().s, acc().l); syncAriaRadioGroup(radiusBtns, "data-radius-option", state.radius); syncAriaRadioGroup(themeModeBtns, "data-theme-mode", state.mode); syncAriaRadioGroup(fontBtns, "data-font-option", state.font); @@ -75,6 +80,15 @@ export function initThemeControls() { bindOptionButtons(radiusBtns, "data-radius-option", RADIUS_IDS, "radius"); bindOptionButtons(fontBtns, "data-font-option", FONT_IDS, "font"); bindOptionButtons(themeModeBtns, "data-theme-mode", THEME_MODE_IDS, "mode"); + themeModeBtns.forEach((btn) => + btn.addEventListener("click", () => { + if (!panel.hidden) { + syncHueSlider(); + drawSLPlane(canvas, acc().h); + updateKnob(knob, acc().s, acc().l); + } + }), + ); if (meshBgBtns.length >= 2) bindOptionButtons(meshBgBtns, "data-mesh-bg-option", MESH_BG_IDS, "meshBg"); function setPanelOpen(open) { @@ -84,8 +98,8 @@ export function initThemeControls() { btn.setAttribute("aria-label", open ? "Закрыть настройки стиля" : "Настройки стиля"); }); if (open) { - drawSLPlane(canvas, state.h); - updateKnob(knob, state.s, state.l); + drawSLPlane(canvas, acc().h); + updateKnob(knob, acc().s, acc().l); syncAriaRadioGroup(themeModeBtns, "data-theme-mode", state.mode); syncAriaRadioGroup(fontBtns, "data-font-option", state.font); if (meshBgBtns.length >= 2) syncAriaRadioGroup(meshBgBtns, "data-mesh-bg-option", state.meshBg); @@ -95,6 +109,11 @@ export function initThemeControls() { subscribePrefersColorSchemeChange(() => { if (state.mode !== "system") return; applyThemeState(state); + if (!panel.hidden) { + syncHueSlider(); + drawSLPlane(canvas, acc().h); + updateKnob(knob, acc().s, acc().l); + } }); panelBtns.forEach((btn) => { @@ -143,9 +162,10 @@ export function initThemeControls() { if (r.width <= 0 || r.height <= 0) return; const x = clampNum(((clientX - r.left) / r.width) * 100, 0, 100, 0) / 100; const y = clampNum(((clientY - r.top) / r.height) * 100, 0, 100, 0) / 100; - state.s = Math.round(PICK.sMin + x * S_RANGE); - state.l = Math.round(PICK.lMin + (1 - y) * L_RANGE); - updateKnob(knob, state.s, state.l); + const a = acc(); + a.s = Math.round(PICK.sMin + x * S_RANGE); + a.l = Math.round(PICK.lMin + (1 - y) * L_RANGE); + updateKnob(knob, a.s, a.l); persistAndApply(); } @@ -168,10 +188,11 @@ export function initThemeControls() { }); hueSlider.addEventListener("input", () => { - state.h = clampNum(hueSlider.value, 0, 360, state.h); + const a = acc(); + a.h = clampNum(hueSlider.value, 0, 360, a.h); syncHueSlider(); - drawSLPlane(canvas, state.h); - updateKnob(knob, state.s, state.l); + drawSLPlane(canvas, a.h); + updateKnob(knob, a.s, a.l); persistAndApply(); }); } diff --git a/public/js/modules/theme/theme-core.js b/public/js/modules/theme/theme-core.js index a16e4d3..63a5b4a 100644 --- a/public/js/modules/theme/theme-core.js +++ b/public/js/modules/theme/theme-core.js @@ -27,7 +27,14 @@ export const S_RANGE = PICK.sMax - PICK.sMin; export const L_RANGE = PICK.lMax - PICK.lMin; export function defaultThemeState() { - return { mode: "system", h: 210, s: 90, l: 40, radius: "medium", font: "geologica", meshBg: "on" }; + return { + mode: "system", + light: { h: 210, s: 90, l: 40 }, + dark: { h: 210, s: 90, l: 40 }, + radius: "medium", + font: "geologica", + meshBg: "on", + }; } export function effectiveThemeMode(state) { @@ -35,23 +42,53 @@ export function effectiveThemeMode(state) { return state.mode; } +export function activeAccent(state) { + const key = effectiveThemeMode(state); + return state[key] || state.light || { h: 210, s: 90, l: 40 }; +} + +function parseAccent(obj, base) { + if (!obj) return { ...base }; + return { + h: clampNum(obj.h ?? obj.hue, 0, 360, base.h), + s: clampNum(obj.s ?? obj.sat, PICK.sMin, PICK.sMax, base.s), + l: clampNum(obj.l ?? obj.light, PICK.lMin, PICK.lMax, base.l), + }; +} + export function readThemeState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return defaultThemeState(); const o = JSON.parse(raw); const base = defaultThemeState(); - const hRaw = o.h ?? o.hue; - const h = clampNum(hRaw, 0, 360, base.h); - const s = clampNum(o.s ?? o.sat, PICK.sMin, PICK.sMax, base.s); - const lRaw = o.l ?? o.light; - const l = clampNum(lRaw, PICK.lMin, PICK.lMax, base.l); const mode = o.mode === "light" || o.mode === "dark" || o.mode === "system" ? o.mode : base.mode; const radius = RADIUS_IDS.includes(o.radius) ? o.radius : base.radius; const font = normalizeFontId(o.font ?? base.font); const meshBg = MESH_BG_IDS.includes(o.meshBg) ? o.meshBg : base.meshBg; - return { mode, h, s, l, radius, font, meshBg }; + + let light = base.light; + let dark = base.dark; + if (o.light || o.dark) { + light = parseAccent(o.light, base.light); + dark = parseAccent(o.dark, base.dark); + } else if ( + o.h !== undefined || + o.hue !== undefined || + o.s !== undefined || + o.sat !== undefined || + o.l !== undefined || + o.light !== undefined + ) { + // legacy: единый акцент → копируем в обе темы + const h = clampNum(o.h ?? o.hue, 0, 360, base.light.h); + const s = clampNum(o.s ?? o.sat, PICK.sMin, PICK.sMax, base.light.s); + const l = clampNum(o.l ?? o.light, PICK.lMin, PICK.lMax, base.light.l); + light = { h, s, l }; + dark = { h, s, l }; + } + return { mode, light, dark, radius, font, meshBg }; } catch { return defaultThemeState(); } @@ -62,9 +99,8 @@ export function writeThemeState(state) { STORAGE_KEY, JSON.stringify({ mode: state.mode, - h: state.h, - s: state.s, - l: state.l, + light: state.light, + dark: state.dark, radius: state.radius, font: state.font, meshBg: state.meshBg, @@ -110,14 +146,16 @@ export function relativeLuminance(rgb) { export function applyThemeState(state) { const root = document.documentElement; - root.dataset.theme = effectiveThemeMode(state); + const mode = effectiveThemeMode(state); + root.dataset.theme = mode; root.dataset.radius = state.radius; root.dataset.font = state.font; root.dataset.meshBg = state.meshBg === "off" ? "off" : "on"; - root.style.setProperty("--theme-h", String(state.h)); - root.style.setProperty("--theme-s", String(state.s)); - root.style.setProperty("--theme-l", String(state.l)); - const rgb = hslToRgb(state.h, state.s, state.l); + const acc = activeAccent(state); + root.style.setProperty("--theme-h", String(acc.h)); + root.style.setProperty("--theme-s", String(acc.s)); + root.style.setProperty("--theme-l", String(acc.l)); + const rgb = hslToRgb(acc.h, acc.s, acc.l); const lum = relativeLuminance(rgb); root.style.setProperty("--on-accent", lum > 0.58 ? "hsl(220 14% 12%)" : "hsl(0 0% 98%)"); window.dispatchEvent(new CustomEvent("site-theme-applied", { bubbles: true })); diff --git a/public/js/modules/ui/content-edit/content-edit-storage.js b/public/js/modules/ui/content-edit/content-edit-storage.js index df7a8c4..e2a8db2 100644 --- a/public/js/modules/ui/content-edit/content-edit-storage.js +++ b/public/js/modules/ui/content-edit/content-edit-storage.js @@ -1,28 +1,29 @@ -const API = "/api/content-edits"; - -export function getPagePath() { - const path = window.location.pathname.replace(/^\//, ""); - if (!path || path.endsWith("/")) return "index.html"; - return path; -} - -export async function loadEdits(page = getPagePath()) { - const res = await fetch(`${API}?page=${encodeURIComponent(page)}`); - if (!res.ok) throw new Error("load failed"); - return res.json(); -} - -export async function saveEdits(payload) { - const res = await fetch(`${API}?page=${encodeURIComponent(payload.page)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error("save failed"); - return res.json(); -} - -export function isContentEditHost() { - const host = window.location.hostname; - return host === "localhost" || host === "127.0.0.1"; -} +const API = "/api/content-edits"; + +export function getPagePath() { + const path = window.location.pathname.replace(/^\//, ""); + if (!path || path.endsWith("/")) return "index.html"; + return path; +} + +export async function loadEdits(page = getPagePath()) { + const res = await fetch(`${API}?page=${encodeURIComponent(page)}`); + if (!res.ok) throw new Error("load failed"); + return res.json(); +} + +export async function saveEdits(payload) { + const res = await fetch(`${API}?page=${encodeURIComponent(payload.page)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error("save failed"); + return res.json(); +} + +export function isContentEditHost() { + const host = window.location.hostname; + const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "dev.mamadaevv.site", "f2ogw6fzi6trnp7cp43x8wsd.31.129.103.242.sslip.io"]; + return ALLOWED_HOSTS.includes(host); +} diff --git a/public/js/modules/ui/hero-3d-tilt.js b/public/js/modules/ui/hero-3d-tilt.js new file mode 100644 index 0000000..76b39da --- /dev/null +++ b/public/js/modules/ui/hero-3d-tilt.js @@ -0,0 +1,64 @@ +// 3D-наклон портрета на главной по движению курсора. +// Эффект "парящей карточки": карточка поворачивается к курсору, +// слои дают параллакс, поверх ложится мягкий блик. + +const MAX_TILT = 9; // градусов по каждой оси +const GLIDE = 320; // ms возврата в покой + +function isTouchOrReduced() { + const touch = window.matchMedia("(hover: none), (pointer: coarse)").matches; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return touch || reduced; +} + +export function initHero3DTilt() { + const shell = document.querySelector(".hero__portrait-shell"); + if (!shell || isTouchOrReduced()) return; + + let rafId = null; + let targetX = 0; + let targetY = 0; + let currentX = 0; + let currentY = 0; + let active = false; + + function onMove(e) { + const rect = shell.getBoundingClientRect(); + const px = (e.clientX - rect.left) / rect.width; // 0..1 + const py = (e.clientY - rect.top) / rect.height; // 0..1 + targetY = (px - 0.5) * 2 * MAX_TILT; // rotateY + targetX = (0.5 - py) * 2 * MAX_TILT; // rotateX + shell.style.setProperty("--tilt-px", `${px * 100}%`); + shell.style.setProperty("--tilt-py", `${py * 100}%`); + if (!active) { + active = true; + shell.classList.add("hero__portrait-shell--tilting"); + } + if (!rafId) rafId = requestAnimationFrame(tick); + } + + function tick() { + currentX += (targetX - currentX) * 0.18; + currentY += (targetY - currentY) * 0.18; + shell.style.setProperty("--tilt-rx", `${currentX.toFixed(2)}deg`); + shell.style.setProperty("--tilt-ry", `${currentY.toFixed(2)}deg`); + const done = + Math.abs(targetX - currentX) < 0.01 && Math.abs(targetY - currentX) < 0.01; + if (done && !active) { + rafId = null; + } else { + rafId = requestAnimationFrame(tick); + } + } + + function onLeave() { + active = false; + targetX = 0; + targetY = 0; + shell.classList.remove("hero__portrait-shell--tilting"); + if (!rafId) rafId = requestAnimationFrame(tick); + } + + shell.addEventListener("mousemove", onMove); + shell.addEventListener("mouseleave", onLeave); +} diff --git a/public/js/modules/ui/image-load-placeholder.js b/public/js/modules/ui/image-load-placeholder.js index da4ce31..c07b1cb 100644 --- a/public/js/modules/ui/image-load-placeholder.js +++ b/public/js/modules/ui/image-load-placeholder.js @@ -3,7 +3,7 @@ import { isTinyPlaceholderSrc } from "../shared/image-src.js"; const HOST_SELECTOR = ".case-media, .case-media-gallery__item, .case-link-card__media, .case-media-tall__clip, .mp-card__media, .portfolio-card__cover, figure"; -const EXCLUDE_ANCESTOR = ".contact-card__icon, .cookie-notice, .case-image-viewer, [data-no-img-placeholder]"; +const EXCLUDE_ANCESTOR = ".contact-card__icon, .cookie-notice, .case-image-viewer, .case-media-slider, [data-no-img-placeholder]"; function shouldWatch(img) { if (!(img instanceof HTMLImageElement)) return false; diff --git a/public/js/modules/ui/scroll-reveal.js b/public/js/modules/ui/scroll-reveal.js new file mode 100644 index 0000000..2d6cbc9 --- /dev/null +++ b/public/js/modules/ui/scroll-reveal.js @@ -0,0 +1,27 @@ +import { prefersReducedMotion } from "../shared/media.js"; + +const THRESHOLD = 0.15; +const ROOT_MARGIN = "0px 0px -120px 0px"; + +export function initScrollReveal() { + const targets = document.querySelectorAll("[data-reveal]"); + if (!targets.length) return; + + if (prefersReducedMotion()) { + targets.forEach((el) => el.classList.add("is-visible")); + return; + } + + const obs = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + e.target.classList.add("is-visible"); + obs.unobserve(e.target); + } + }, + { threshold: THRESHOLD, rootMargin: ROOT_MARGIN }, + ); + + targets.forEach((el) => obs.observe(el)); +} diff --git a/public/resume/index.html b/public/resume/index.html new file mode 100644 index 0000000..d6c967d --- /dev/null +++ b/public/resume/index.html @@ -0,0 +1,947 @@ + + + + + + + Резюме — Николай Мамадаев + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ Николай Мамадаев +
+

Резюме

+

Николай Мамадаев

+

Senior UX/UI Designer
+ Product Designer
+ AI Designer

+
+
+ +
+
+
Портфолио
+
mamadaevv.site
+
+
+
Зарплата
+
от 250 000 ₽
+
+
+
Формат работы
+
удалённо, гибрид
+
+
+
Почта
+
nik@mamadaevv.site
+
+
+
Соц.сети
+
@mamadaevv
+
+
+
Знание языков
+
Английский (B2)
+
+
+
+ +
+

О себе

+

+ Специализируюсь на SaaS-продуктах и desktop-приложениях. Проектирую понятные, масштабируемые и эффективные интерфейсы для сложных рабочих процессов — чтобы пользователи быстрее решали свои задачи, а продукт становился удобнее и полезнее. +

+

+ Работаю на всех этапах продуктового дизайна: изучаю потребности пользователей, формирую информационную архитектуру, проектирую пользовательские сценарии, создаю интерактивные прототипы используя вайбкоддинг и масштабируемые дизайн-системы в связке Figma + Storybook. +

+

+ Участвую в жизни сообщества: веду Telegram-канал «Дизайн не пицца», выкладываю полезности в Figma Community, выступал на митапе Яндекса с докладом про аналитику голосовых навыков и на бизнес-форуме с рассказом про инструменты дизайнера. Неоднократно побеждал на хакатонах. +

+
+ +
+

Опыт работы

+
    +
  1. +
    +

    Astratech

    +

    — Настоящее время

    +
    +

    Разработка и поддержка цифровых систем и инструментов для повышения эффективности сотрудников.

    + +
    +
    +
    +

    Обязанности

    +
      +
    • Создание готовых к продакшену интерфейсов
    • +
    • Разбор идей и концепций и трансформация их в дизайнерские задачи
    • +
    +
    +
    +

    Достижения

    +
      +
    • Реализовал настроенную среду доставки интерфейсов до продакшена
    • +
    • Провёл более 15 тестов гипотез на рабочих прототипах без привлечения отдела разработки
    • +
    • Ускорил процесс обработки результатов пользовательского тестирования за счёт применения AI инструментов (суммаризация, классификация, трансрибация)
    • +
    +
    +
    +

    Стэк

    +
      +
    • Figma
    • +
    • HTML/CSS/JS
    • +
    • AI/LLM
    • +
    • GitHub
    • +
    • Gitea
    • +
    • Coolify
    • +
    • CI/CD
    • +
    • Hermes Agent
    • +
    • Zed IDE
    • +
    +
    +
    +
    +
  2. + +
  3. +
    +

    Primo RPA

    +

    — (2 года и 10 месяцев)·primo-rpa.com

    +
    +

    Российская B2B RPA-платформа корпоративного уровня: линейка из desktop-среды разработки роботов и веб-сервисов управления, аналитики и ML.
    + Работал ведущим дизайнером на всю продуктовую линейку из 6 продуктов.

    +
    +

    AI Server platform (SaaS)

    +

    Панель управления для обучения/запуска/тестирования и других операций с нейросетями, развёрнутыми внутри контура компании.

    +
    +
    +
    Обязанности
    +
      +
    • Спроектировать web интерфейс панели управления локальных LLM. Реализовать возможность выполнения сценариев: создание датасетов, обучение узконаправленных моделей, быстрое тестирование и администрирование развёртыванием на локальной инфраструктуре клиента.
    • +
    • Улучшить способ конфигурирования работы Smart OCR для более точного извлечения данных из документов различного типа
    • +
    • Работа с состояниями долгих процессов: обучение модели, прогресс, ошибки, откат
    • +
    • Подготовка интерактивных прототипов и авторский надзор за реализацией
    • +
    +
    +
    +
    Достижения
    +
      +
    • Создал интуитивный интерфейс для работы со сложной настройкой LLM моделей. Таким образом существенно снизил порог входа для пользователей без глубокой экспертизы в работе нейросетей.
    • +
    • Сократил время настройки с 3-5 дней до 2 часов по результатам общения с пользователями
    • +
    • Спроектировал интерфейс разметки документов, который снял 60% обращений в поддержку по этой теме
    • +
    • Провёл 8 юзабилити-сессий, по итогам переработал ключевой сценарий обучения модели
    • +
    +
    +
    +
    Стэк
    +
      +
    • Figma
    • +
    • React
    • +
    • Ant Design
    • +
    • Hope UI
    • +
    • LLM core
    • +
    • Smart OCR
    • +
    +
    +
    +
    + +
    +

    Robot automation studio (Windows)

    +

    Среда для разработки и отладки роботов для автоматизации бизнес-процессов.

    +
    +
    +
    Обязанности
    +
      +
    • Проектирование интерфейса профессиональной desktop-IDE: рабочая область, панели инструментов, дерево процесса, отладчик
    • +
    • Развитие библиотеки компонентов и визуального языка продукта
    • +
    • Проработка сценариев отладки: точки останова, пошаговое выполнение, просмотр переменных
    • +
    • Проектирование работы с большими деревьями активностей и вложенностью
    • +
    • Работа в паре с разработкой по реализации компонентов, авторский надзор
    • +
    • Ведение регулярных дизайн-ревью внутри команды
    • +
    +
    +
    +
    Достижения
    +
      +
    • Обновил визуальный язык продукта до уровня Windows 11 — компоненты стали на поколение современнее
    • +
    • Переработал 10-ки экранов и модальных окон, приведя их к единой дизайн-системе основанной на токенах.
    • +
    • Собрал библиотеку из более чем 50-ти компонентов, которые можно переиспользовать и проектировать сценарии нового функционала с сохранением заложенных паттернов поведения
    • +
    • Перерисовал все старые растровые иконки в новые векторные. Таким образом появилась библиотека уникальных иконок количеством более 300 штук. Идеально отображаются на любом масштабе UI и DPI
    • +
    • Сократил время сборки нового экрана командой с 2 дней до 4 часов за счёт готовых компонентов
    • +
    +
    +
    +
    Стэк
    +
      +
    • Desktop IDE
    • +
    • Windows 10/11
    • +
    • Figma
    • +
    • AI инструменты
    • +
    • DevExpress UI
    • +
    • Handy Controls
    • +
    +
    +
    +
    + +
    +

    Robot automation studio (Linux)

    +

    Среда для разработки и отладки роботов для автоматизации бизнес-процессов.

    +
    +
    +
    Обязанности
    +
      +
    • Разработка интерфейса IDE для платформы Linux с учётом выбранного функционала на основе реализованной версии под Windows
    • +
    • Проектирование компонентов с учётом технических ограничений фреймворка Avalonia Suki UI
    • +
    • Подготовка спецификаций поведения компонентов для реализации
    • +
    +
    +
    +
    Достижения
    +
      +
    • Собрал библиотеку компонентов основанную на библиотеке Suki UI из которых можно быстро собирать интерфейс в Figma и передавать разработчикам
    • +
    • Разработал стиль программы, которая соответствует международному уровню, что положительно скажется при экспансии зарубежных рынков
    • +
    • Передал дизайн-инструкции по сборке экранов разработчикам, чтобы они могли самостоятельно собирать интерфейс под новые функции и выпускать релизы согласно роадмэп
    • +
    +
    +
    +
    Стэк
    +
      +
    • Avalonia
    • +
    • Suki UI
    • +
    • Figma
    • +
    • AI инструменты
    • +
    • Cursor IDE
    • +
    • MobaXterm RDP
    • +
    • Linux
    • +
    +
    +
    +
    + +
    +

    Idea Hub Dashboard (SaaS)

    +

    Обеспечивает сбор и анализ предложений сотрудников, оценка эффективности и мониторинг статуса реализации автоматизаций. Аналитика по ключевым показателям эффективности и верхнеуровневый взгляд на роботизацию.

    +
    +
    +
    Обязанности
    +
      +
    • Проектирование дашбордов и аналитических представлений по KPI роботизации
    • +
    • Проработка жизненного цикла идеи: подача → оценка → приоритизация → реализация → замер эффекта
    • +
    • Информационная архитектура раздела, работа с плотными таблицами, фильтрами и массовыми операциями
    • +
    • Проектирование ролевой модели: сотрудник, аналитик, руководитель
    • +
    • Подбор типов визуализации данных под конкретные управленческие вопросы
    • +
    +
    +
    +
    Достижения
    +
      +
    • Свёл верхнеуровневую аналитику и работу с конкретной идеей в один непрерывный сценарий без переключения между разделами
    • +
    • Спроектировал представление данных так, что руководитель видит эффект от роботизации без ручных выгрузок в Excel
    • +
    • Сократил путь от подачи идеи до её оценки с 6 шагов до 3
    • +
    • Спроектировал 12 виджетов аналитики, покрывающих основные вопросы заказчиков о ROI роботизации
    • +
    +
    +
    +
    Стэк
    +
      +
    • Figma
    • + + +
    +
    +
    +
    + +
    +

    Orchestrator Automation Management Tool (SaaS)

    +

    Обеспечивает слаженную работу системы и управление сотнями роботов: запуски, мониторинг и менеджмент.

    +
    +
    +
    Обязанности
    +
      +
    • Проектирование интерфейса управления парком из сотен роботов: расписания, очереди, приоритеты, мониторинг
    • +
    • Проработка состояний и обработки ошибок при массовых запусках
    • +
    • Проектирование системы уведомлений и алертов о сбоях
    • +
    • Работа со сложными таблицами: сортировка, фильтрация, массовые действия, сохранённые представления
    • +
    • Прототипирование сложных сценариев для валидации до начала разработки
    • +
    +
    +
    +
    Достижения
    +
      +
    • Спроектировал управление сотнями объектов так, что оператор не теряется в масштабе: от общей картины до конкретного робота за 2 клика
    • +
    • Привёл сервис к единой дизайн-системе вместе с остальными веб-продуктами линейки
    • +
    • Сократил время реакции оператора на сбой робота с 12 до 4 минут за счёт переработанной системы алертов
    • +
    • Свёл 5 разных представлений списка роботов к одному настраиваемому
    • +
    +
    +
    +
    Стэк
    +
      +
    • Figma
    • +
    • Ant Design
    • +
    • Storybook
    • +
    • AI инструменты
    • + + +
    +
    +
    +
    + +
    +

    QnA portal (WEB)

    +

    Площадка для постинга вопросов и получения ответов от службы поддержки

    +
    +
    +
    Обязанности
    +
      +
    • Проектирование сценариев публикации вопроса, ответа и поиска по накопленной базе знаний
    • +
    • Дизайн интерфейса портала в условиях ограничения используемого PHP движка для QnA типа сайта.
    • +
    • Проработка ролевой модели: пользователь, эксперт, служба поддержки, модератор
    • +
    • Проектирование механики поиска и подсказок похожих вопросов при публикации
    • +
    +
    +
    +
    Достижения
    +
      +
    • Запустил портал на готовых компонентах дизайн-системы за 3 недели вместо запланированных 8
    • +
    • Снизил нагрузку на поддержку примерно на 25% за счёт переиспользования уже данных ответов
    • +
    • Механика подсказок похожих вопросов сократила количество дублей на 45%
    • +
    • Обеспечил визуальную преемственность с продуктами линейки — портал не выглядит сторонним сервисом
    • +
    +
    +
    +
    Стэк
    +
      +
    • PHP QnA Engine
    • +
    • Figma
    • +
    • Дизайн-система
    • + +
    +
    +
    +
    + +
    +

    Общее по компании

    +
      + +
    • Собрал и внедрил единую дизайн-систему для веб-сервисов, соединив фирменный стиль компании с новой структурой интерфейсов
    • + +
    • Сократил среднее время подготовки макетов по линейке на 35%
    • +
    • Создавал и поддерживал UI-kit для консистентности и ускорения разработки
    • +
    • Провёл более 20 пользовательских исследований и юзабилити-сессий за время работы
    • +
    • Вёл авторский надзор за реализацией, участвовал в дизайн-ревью, продуктовых обсуждениях и планировании релизов
    • +
    • Нанял 4 дизайнеров интерфейсов для разных проектов компании
    • +
    • Провел больше 30 собеседований, в том числе внедрил формат WhiteBoard Interview
    • +
    • Разрабатывал тестовые задания с учётом потребностей конкретной должности и задач
    • +
    • Внедрил внутреннюю систему видеозвонков на основе Jitsi во внутреннем контуре компании совместно с отделом технической поддержки компании
    • + +
    +
    +
  4. + +
  5. +
    +

    Lunacy (Icons8)

    +

    — (7 месяцев)·icons8.com/lunacy

    +
    +

    Графический редактор для работы над дизайном интерфейсов и прочих макетов. Аналог Sketch App и Figma.

    +
    +
    +
    +

    Обязанности

    +
      +
    • Проектирование интерфейса графического редактора: инструменты, панели свойств, работа с холстом и слоями
    • +
    • Оформление решений в виде юзерфлоу и передача разработчикам
    • +
    • Создание сложных интерактивных прототипов в Protopie для демонстрации поведения инструментов
    • +
    +
    +
    +

    Достижения

    +
      +
    • Спроектировал 6 функциональных блоков редактора, включая работу с компонентами и стилями
    • +
    • Собрал прототип нового функционала в Protopie для редактирования многослойной реалистичной тени от объекта со сложной логикой поведения, практически не отличимый от решения в продакшене. Не считая мелких простых прототипов.
    • +
    • Провалидировал 3 гипотезы относительно улучшений работы редактора на реальных пользователях. 1 ушла в релиз.
    • +
    +
    +
    +

    Стэк

    +
      +
    • Protopie
    • +
    • Figma
    • +
    • Дизайн-система
    • +
    • Avalonia Custom UI
    • +
    • Windows
    • +
    • Linux
    • +
    • macOS
    • +
    +
    +
    +
    +
  6. + +
  7. +
    +

    MANU Video Game Maker

    +

    — (3 года)·manu.co

    +
    +

    Игровой движок для создания видеоигр без написания кода.

    +
    +
    +
    +

    Обязанности

    +
      +
    • Продуктовый дизайн движка целиком: от концепции взаимодействия до конечного UI
    • +
    • Проектирование поведения интерфейса для сложных пользовательских сценариев
    • +
    • Исследования пользователей: наблюдение, глубинные интервью, опросы
    • +
    • Составление портретов потенциальных пользователей и карт эмпатии
    • +
    • Работа с продуктовой аналитикой и метриками, завязанными на пользовательские сценарии
    • +
    • Вёрстка главной страницы сайта на Tilda, проектирование информационной архитектуры сайта вместе с командой
    • +
    • Контроль работы веб-графического дизайнера
    • +
    • Найм в продуктовый отдел: проведение собеседований и отбор специалистов
    • +
    • Руководство отделом, построение рабочих процессов всей продуктовой команды
    • +
    • Кураторство работ по созданию пользовательской документации
    • +
    • Внедрение инструментов автоматизации (Integromat, Zapier) для внутренних задач
    • +
    +
    +
    +

    Достижения

    +
      +
    • Провёл масштабный редизайн всей программы: заложил принципиально новую концепцию работы и стилистику
    • +
    • Спроектировал поведение интерфейса для 100+ сложных пользовательских сценариев
    • +
    • Спроектировал дизайн-систему продукта с нуля: 120 компонентов
    • +
    • Получили массу положительных, местами восторженных отзывов пользователей — в продукте стало значительно легче ориентироваться
    • +
    • Завершаемость онбординга выросла с 34% до 58% после редизайна
    • +
    • Провёл 25 глубинных интервью и 4 волны опросов, на их основе переработал 3 ключевых сценария
    • +
    • Нанял 4 специалистов в продуктовый отдел и вырос из дизайнера в тимлида команды
    • +
    • Выстроил процессы отдела: дизайн-ревью, работа с бэклогом, передача в разработку
    • +
    • Автоматизировал внутренние рутины команды, освободив время на продуктовые задачи
    • +
    +
    +
    +

    Стэк

    +
      + +
    • Zapier
    • +
    • Figma
    • +
    • Miro
    • +
    • Mailchimp
    • +
    • Windows
    • +
    • Notion
    • +
    • Airtable
    • +
    +
    +
    +
    +
  8. + +
  9. +
    +

    Aegees Messenger

    +

    — (1 год и 3 месяца)·aegees.com

    +
    +

    Кроссплатформенный мессенджер с защищенным хранилищем и аудиозвонками.

    +
    +
    +
    +

    Обязанности

    +
      +
    • Дизайн мессенджера сразу под пять платформ: Android, iOS, macOS, Windows, Linux
    • +
    • Проработка сценариев защищённого хранилища и аудиозвонков
    • +
    • Проектирование сценариев регистрации, шифрования и восстановления доступа
    • +
    • Адаптация единой продуктовой логики под гайдлайны каждой платформы
    • +
    • Контроль работы зарубежных иллюстраторов по подготовке графических материалов
    • +
    • Подготовка материалов для магазинов приложений
    • +
    +
    +
    +

    Достижения

    +
      +
    • Выстроил единый визуальный язык продукта при соблюдении гайдлайнов пяти платформ одновременно
    • +
    • Спроектировал более 90 экранов в пяти платформенных вариантах
    • +
    • Обеспечил стабильный поток графических материалов от внешних подрядчиков без потери стилистики
    • +
    • Упростил сценарий защищённого хранилища так, чтобы им пользовались люди без технической подготовки
    • +
    • Работал в чувствительной области приватности и безопасности, где ошибка в интерфейсе стоит доверия пользователя
    • +
    +
    +
    +

    Стэк

    +
      +
    • Android
    • +
    • iOS
    • +
    • macOS
    • +
    • Windows
    • +
    • Linux
    • +
    +
    +
    +
    +
  10. + +
  11. +
    +

    Orion Innovation (ex MERA)

    +

    — (1 год и 4 месяца)·orioninnovation.com

    +
    +

    Разработка ПО для внешних заказчиков.

    +
    +
    +
    +

    Обязанности

    +
      +
    • Работа в дизайн-отделе численностью до 8 человек
    • +
    • Создание анкет персон и составление карты функциональности продукта в соответствии с потребностями персон
    • +
    • Вайрфреймы и отрисовка конечного UI интерфейсов
    • + +
    • Подготовка мудбордов и дизайн-концепций, обсуждение их с клиентами
    • + +
    • Линковка мокапов в прототипы, написание сценариев и проведение качественных юзабилити-тестирований
    • + + +
    • Участие в дизайн-ревью: разбор работ коллег и защита собственных решений
    • + +
    +
    +
    +

    Достижения

    +
      +
    • Провёл 10 сессий юзабилити-тестирования от написания сценария до внесения правок
    • +
    • Освоил новую для себя платформу Smart TV и довёл проект до успешной публикации в магазине приложений
    • +
    • Провёл исследования и реверс-инжиниринг дизайна приложений для платформы Roku TV и создал рабочий процесс разработки дизайна приложения заказчика, включающий: формирование идеи, разработку кликабельных прототипов, тестирование юзабилити на рабочем устройстве, контроль дизайна в приложении, разработанном нашими разработчиками
    • +
    • Вёл коммуникацию с зарубежными заказчиками напрямую, без посредников
    • +
    • Активно участвовал в улучшении рабочего процесса дизайн-отдела компании
    • +
    • Внедрил в команду использование Miro (ex. Realtime Board) для хранения артефактов исследований
    • + +
    +
    +
    +

    Стэк

    +
      +
    • Sketch
    • +
    • Miro
    • +
    • Android
    • +
    • iOS
    • +
    • ROKU TV Box
    • +
    • Smart TV
    • +
    • Marvel App
    • +
    • ProtoPie
    • +
    • Framer
    • + + +
    +
    +
    +
    +
  12. + +
  13. +
    +

    SpaceshipApps Studio

    +

    — (1 год и 1 месяц)·spaceshipapps.ru

    +
    +

    Студия разработки мобильных приложений и сайтов.

    +
    +
    +
    +

    Обязанности

    +
      +
    • Дизайн мобильных и веб-продуктов в потоковом режиме студии
    • +
    • Работа с разными доменами: соцгейминг, бьюти-услуги, доставка еды, голосовые интерфейсы, легал-сервисы, коллаборативные инструменты
    • +
    • Адаптивный веб-дизайн и дизайн административных панелей
    • +
    • Отрисовка иконок и подготовка графики для проектов
    • +
    • Подготовка макетов к передаче в разработку
    • +
    +
    +
    +

    Достижения

    +
      +
    • Выпустил 6 продуктов за год: геолокационная социальная игра с квестами в реальном мире, приложение записи к бьюти-специалистам, сервис доставки еды, голосовая игра со словами, сайт и админка сервиса регистрации авторского права, мобильная «белая доска» для совместной работы
    • +
    • Научился быстро входить в новую предметную область и держать качество при высоком темпе студии
    • +
    • Закрыл весь спектр задач: мобайл, веб, админ-панели, графика
    • +
    • Заложил базу для перехода от исполнительского дизайна к продуктовому
    • +
    +
    +
    +

    Стэк

    +
      +
    • Мобайл
    • +
    • Веб
    • +
    • Adobe Photoshop
    • +
    • After Effects
    • +
    • Adobe Illustrator
    • + + + +
    +
    +
    +
    +
  14. +
+
+ +
+

Навыки

+
+
+
Проектирование
+
UX/UI, product design, информационная архитектура, адаптивный дизайн, дизайн-системы
+
+
+
Исследования
+
Интервью, опросы, usability testing, JTBD, карты эмпатии, конверсия, retention, NPS
+
+
+
Управление
+
Построение команды, Agile, Kanban, авторский надзор, работа со стейкхолдерами
+
+
+
Инструменты
+
Figma, Protopie, Sketch, Adobe Creative Suite, Storybook
+
+
+
AI инструменты
+
Hermes Agent, Perplexity, Cursor IDE, Zed IDE, Open Design, Codex, Claude Code
+
+
+
+ +
+
+ +
+ +
+ + + + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..8980daf --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,86 @@ +# robots.txt — mamadaevv.site +# Портфолио Николая Мамадаева, продуктового дизайнера. + +Sitemap: https://mamadaevv.site/sitemap.xml + +# --- Классические поисковые системы --- +User-agent: * +Allow: / +Disallow: /_lab/ +Disallow: /assets/favicon/ +Allow: /assets/ + +# --- Поисковые системы с ИИ-функциями --- +User-agent: Google-Extended +Allow: / + +User-agent: Applebot-Extended +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: DuckAssist +Allow: / + +User-agent: PerplexityBot +Allow: / + +# --- Краулеры больших языковых моделей (обучение и ответы) --- +User-agent: GPTBot +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: Claude-User +Allow: / + +User-agent: Google-CloudVertexBot +Allow: / + +User-agent: Meta-ExternalAgent +Allow: / + +User-agent: Meta-ExternalFetcher +Allow: / + +User-agent: MistralAI-User +Allow: / + +User-agent: Amazonbot +Allow: / + +User-agent: cohere-ai +Allow: / + +User-agent: Diffbot +Allow: / + +User-agent: Timpibot +Allow: / + +# --- Архивирование и исследования --- +User-agent: CCBot +Allow: / + +User-agent: Applebot +Allow: / + +# --- Служебные страницы: не индексировать никому --- +User-agent: * +Disallow: /cases/template-elements-demo/ +Disallow: /cases/design-system/ +Disallow: /cases/mini-projects-showcase/ +Disallow: /en/cases/template-elements-demo/ +Disallow: /en/cases/design-system/ +Disallow: /en/cases/mini-projects-showcase/ diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..0f1c18b --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,116 @@ + + + + https://mamadaevv.site/cases/ai-assistant-figma/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/ai-platform-primo-rpa-2/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/ai-platform-primo-rpa-v2/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/ai-platform-primo-rpa/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/card-columns-obsidian/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/lunacy/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/manu-game-maker/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/mobile-clinic-app/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/obsidian-plugin-columns-view/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/orc-automation-panel/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/site-with-ai-ide/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/task-distribution-concept/ + 2026-09-15 + + + + + + https://mamadaevv.site/cases/task-tracker/ + 2026-09-15 + + + + + + https://mamadaevv.site/cookie-policy/ + 2026-09-16 + + + + + + https://mamadaevv.site/ + 2026-09-19 + + + + + + https://mamadaevv.site/resume/ + 2026-09-21 + + + + + diff --git a/pulse.mjs b/pulse.mjs new file mode 100644 index 0000000..da19ac2 --- /dev/null +++ b/pulse.mjs @@ -0,0 +1,20 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1500}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +await p.evaluate(()=>document.querySelector('#sec-all-shaders')?.scrollIntoView()); +await new Promise(r=>setTimeout(r,1500)); +const r = await p.evaluate(()=>{ + const cells=[...document.querySelectorAll('[data-shader-cell="pulsingBorder"]')]; + const info=cells.map(c=>{const m=c.querySelector('.card-shader')?.paperShaderMount; return {mounted:!!m, speed:m?.currentSpeed, frame:m?Math.round(m.getCurrentFrame()):null};}); + // проверим, что noise-текстура задалась + const m0=cells[0]?.querySelector('.card-shader')?.paperShaderMount; + const texLoc=m0?m0.gl.getUniform(m0.program, m0.gl.getUniformLocation(m0.program,'u_noiseTexture')):null; + return { cellCount: cells.length, info, noiseTexSet: texLoc!==null && texLoc!==undefined }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,8)); +await b.close(); diff --git a/pure.mjs b/pure.mjs new file mode 100644 index 0000000..b032584 --- /dev/null +++ b/pure.mjs @@ -0,0 +1,14 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; return {frame:m.getCurrentFrame(), speed:m.currentSpeed, inVp:m.isInViewport, cs:m.currentSpeed}; }); +await new Promise(r=>setTimeout(r,2000)); +const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; return {frame:m.getCurrentFrame(), speed:m.currentSpeed, inVp:m.isInViewport}; }); +console.log('A', JSON.stringify(a)); console.log('C', JSON.stringify(c)); +console.log('FRAME GROWS:', c.frame>a.frame, ' (', a.frame.toFixed(1), '->', c.frame.toFixed(1), ')'); +await b.close(); diff --git a/raf.mjs b/raf.mjs new file mode 100644 index 0000000..ad5514f --- /dev/null +++ b/raf.mjs @@ -0,0 +1,32 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,300)); +// достанем mount из WeakMap нельзя, но проверим через глобальный хук: переопределим requestAnimationFrame до загрузки? уже поздно. +// Вместо этого — вручную создадим ShaderMount и проверим frame рост + readPixels внутри rAF +const res = await p.evaluate(async()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const wrap = document.querySelector('[data-shader-demo] .card-shader'); + const cv = wrap.querySelector('canvas'); + // создадим свой mount того же шейдера в НОВЫЙ контейнер чтобы не конфликтовать + const host = document.createElement('div'); host.style.cssText='position:fixed;left:0;top:0;width:200px;height:120px;z-index:9999'; + document.body.appendChild(host); + const m = new mod.ShaderMount(host, mod.meshGradientFragmentShader, { u_colors:['#7c5cff','#00c2c7','#ff5c8a'], u_colorsCount:3, u_distortion:0.6, u_swirl:0.4, u_grainMixer:0.08, u_grainOverlay:0.04 }, {preserveDrawingBuffer:true}, 0.2); + await new Promise(r=>setTimeout(r,500)); + const frame1 = m.getCurrentFrame(); + await new Promise(r=>setTimeout(r,500)); + const frame2 = m.getCurrentFrame(); + // читаем пиксель через свой canvas с preserveDrawingBuffer + const c2 = host.querySelector('canvas'); + const gl = c2.getContext('webgl', {preserveDrawingBuffer:true}) || c2.getContext('webgl2',{preserveDrawingBuffer:true}); + let px='n/a'; + if(gl){ const a=new Uint8Array(4); gl.readPixels(10,10,1,1,gl.RGBA,gl.UNSIGNED_BYTE,a); px=Array.from(a); } + m.dispose(); host.remove(); + return { frame1, frame2, animated: frame2>frame1, px }; +}); +console.log(JSON.stringify(res,null,1)); +await b.close(); diff --git a/readback.mjs b/readback.mjs new file mode 100644 index 0000000..b4a46c7 --- /dev/null +++ b/readback.mjs @@ -0,0 +1,27 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(async ()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod; + const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative'; + document.body.appendChild(host); + const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString); + const uniforms = { u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8, u_grainMixer:0.08, u_grainOverlay:0.04 }; + const m = new ShaderMount(host, meshGradientFragmentShader, uniforms, { preserveDrawingBuffer:true, antialias:true }, 1.0); + const gl=m.gl, pr=m.program; + const f=(n)=>{const l=gl.getUniformLocation(pr,n);const v=new Float32Array(4);gl.getUniform(pr,l,v);return Array.from(v).map(x=>+x.toFixed(3));}; + const fi=(n)=>{const l=gl.getUniformLocation(pr,n);const v=new Float32Array(1);gl.getUniform(pr,l,v);return v[0];}; + return { + u_swirl: fi('u_swirl'), + u_distortion: fi('u_distortion'), + u_colorsCount: fi('u_colorsCount'), + u_color0: f('u_colors[0]'), + u_color1: f('u_colors[1]'), + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/readu.mjs b/readu.mjs new file mode 100644 index 0000000..3340e99 --- /dev/null +++ b/readu.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + const out=[]; + for(let i=0;i<4;i++){ + const loc=gl.getUniformLocation(m.program,'u_colors['+i+']'); + const v=new Float32Array(4); + gl.getUniform(m.program,loc,v); + out.push(Array.from(v).map(x=>+x.toFixed(3))); + } + const cnt=gl.getUniformLocation(m.program,'u_colorsCount'); + const cv=new Int32Array(1); gl.getUniform(m.program,cnt,cv); + return { uColors: out, count: cv[0] }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/real.mjs b/real.mjs new file mode 100644 index 0000000..bcb5aae --- /dev/null +++ b/real.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +// принудительно рендерим два кадра с разным временем и берём toDataURL +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const cv=m.canvasElement; + m.render(0); const a=cv.toDataURL('image/png'); + m.render(8000); const c=cv.toDataURL('image/png'); + return { lenA:a.length, lenC:c.length, same: a===c, headA:a.slice(0,40) }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/rendercheck.mjs b/rendercheck.mjs new file mode 100644 index 0000000..c69ceed --- /dev/null +++ b/rendercheck.mjs @@ -0,0 +1,18 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('pageerror',e=>errs.push(e.message)); p.on('console',m=>{if(m.type()==='error')errs.push(m.text());}); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + m.render(performance.now()); m.render(performance.now()); + const W=m.canvasElement.width,H=m.canvasElement.height; const buf=new Uint8Array(W*H*4); gl.readPixels(0,0,W,H,gl.RGBA,gl.UNSIGNED_BYTE,buf); + let bright=0,maxc=0; for(let i=0;i120)bright++; maxc=Math.max(maxc,mx);} + return { brightPixels: bright, maxChannel: maxc }; +}); +console.log(JSON.stringify(r,null,1)); +console.log('ERRS', errs.slice(0,5)); +await b.close(); diff --git a/res.mjs b/res.mjs new file mode 100644 index 0000000..6a13cfa --- /dev/null +++ b/res.mjs @@ -0,0 +1,19 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl, pr=m.program; + const gf=(n)=>gl.getUniform(pr, gl.getUniformLocation(pr,n)); + return { + u_resolution: gf('u_resolution'), + u_pixelRatio: gf('u_pixelRatio'), + linkStatus: gl.getProgramParameter(pr, gl.LINK_STATUS), + vsLog: gl.getShaderInfoLog(gl.getShader(gl.VERTEX_SHADER)), + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/res2.mjs b/res2.mjs new file mode 100644 index 0000000..9bb4910 --- /dev/null +++ b/res2.mjs @@ -0,0 +1,14 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl, pr=m.program; + const gf=(n)=>gl.getUniform(pr, gl.getUniformLocation(pr,n)); + return { u_resolution: gf('u_resolution'), u_pixelRatio: gf('u_pixelRatio'), canvasW: m.canvasElement.width, canvasH: m.canvasElement.height }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index 7ad4861..e2e3148 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -4,10 +4,212 @@ import path from "node:path"; const rootDir = process.cwd(); const srcPagesDir = path.join(rootDir, "src", "pages"); const srcPartialsDir = path.join(rootDir, "src", "partials"); +const srcI18nDir = path.join(rootDir, "src", "i18n"); const outDir = path.join(rootDir, "public"); +// Локали: дефолтная (ru) собирается в корень public/, остальные — в public//. +const LOCALES = ["ru", "en"]; +const DEFAULT_LOCALE = "ru"; + +// Liquid Glass: инлайн SVG displacement-фильтр, инъецируется во все страницы. +// PNG-карта — мелкий равномерный шум (см. статью Хабра), даёт «живое» стекло в Chromium. +// В Safari/Firefox url()-фильтр в backdrop-filter не рендерится — там остаётся чистый blur. +const LIQUID_GLASS_FILTER = ``; + +// --- SEO --- +const SITE_ORIGIN = "https://mamadaevv.site"; +const OG_IMAGE_DEFAULT = `${SITE_ORIGIN}/assets/og/og-default.png`; +const AUTHOR_NAME_RU = "Николай Мамадаев"; +const AUTHOR_NAME_EN = "Nikolai Mamadaev"; +const SITE_NAME_RU = "Николай Мамадаев — продуктовый дизайнер"; +const SITE_NAME_EN = "Nikolai Mamadaev — Product Designer"; +// Служебные страницы-каталоги: закрыты от индексации. +const NOINDEX_PAGES = new Set([ + "cases/design-system", + "cases/template-elements-demo", + "cases/mini-projects-showcase", +]); + +const titleRe = /([\s\S]*?)<\/title>/i; +const descRe = /<meta\s+name="description"\s+content="([\s\S]*?)"\s*\/?>/i; + +function decodeEntities(str) { + return String(str) + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function extractMeta(html) { + const t = html.match(titleRe); + const d = html.match(descRe); + return { + title: t ? decodeEntities(t[1]).trim() : "", + description: d ? decodeEntities(d[1]).trim() : "", + }; +} + +/** Путь страницы на сайте (без ведущего слэша, с закрывающим). */ +function sitePath(relNorm, loc) { + const base = relNorm + .replace(/\.html$/, "") + .replace(/(^|\/)index$/, "/"); + const norm = relNorm.startsWith("cases/") + ? `cases/${path.basename(relNorm, ".html")}/` + : base === "resume" ? "resume/" + : base === "cookie-policy" ? "cookie-policy/" + : base === "" || base === "/" ? "" + : `${base.replace(/\/$/, "")}/`; + return loc === DEFAULT_LOCALE ? norm : `${loc}/${norm}`; +} + +/** Канонический URL страницы. */ +function canonicalFor(relNorm, loc) { + const p = sitePath(relNorm, loc); + return `${SITE_ORIGIN}/${p}`; +} + +/** Дата изменения страницы (mtime исходника) в формате YYYY-MM-DD. */ +function lastModFor(relNorm) { + const src = path.join(srcPagesDir, relNorm); + const enSrc = src.replace(/\.html$/, ".en.html"); + let mtime = 0; + if (fs.existsSync(src)) mtime = fs.statSync(src).mtimeMs; + if (fs.existsSync(enSrc)) mtime = Math.max(mtime, fs.statSync(enSrc).mtimeMs); + return new Date(mtime || Date.now()).toISOString().slice(0, 10); +} + +/** JSON-LD для страницы: Person+WebSite на главной, CreativeWork на кейсах, BreadcrumbList везде. */ +function buildJsonLd(relNorm, loc, meta, canonical) { + const isRu = loc === DEFAULT_LOCALE; + const authorName = isRu ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; + const siteName = isRu ? SITE_NAME_RU : SITE_NAME_EN; + const graph = []; + const personId = `${SITE_ORIGIN}/#person`; + const websiteId = `${SITE_ORIGIN}/#website`; + + const person = { + "@type": "Person", + "@id": personId, + name: authorName, + url: SITE_ORIGIN, + jobTitle: isRu ? "Продуктовый дизайнер" : "Product Designer", + description: isRu + ? "Продуктовый дизайнер с опытом более 10 лет. Проектирую цифровые продукты, интерфейсы и дизайн-системы." + : "Product designer with 10+ years of experience. Designing digital products, interfaces and design systems.", + knowsAbout: isRu + ? ["UX/UI-дизайн", "продуктовый дизайн", "дизайн-системы", "прототипирование", "Figma", "no-code"] + : ["UX/UI design", "product design", "design systems", "prototyping", "Figma", "no-code"], + sameAs: ["https://t.me/mamadaevv", "https://vk.com/mamadaevv", "https://github.com/mamadaevv"], + }; + graph.push(person); + + graph.push({ + "@type": "WebSite", + "@id": websiteId, + url: `${SITE_ORIGIN}/`, + name: siteName, + inLanguage: loc, + author: { "@id": personId }, + publisher: { "@id": personId }, + }); + + const isCase = relNorm.startsWith("cases/"); + const pageNode = { + "@type": isCase ? "CreativeWork" : "WebPage", + "@id": `${canonical}#page`, + url: canonical, + name: meta.title, + description: meta.description, + isPartOf: { "@id": websiteId }, + author: { "@id": personId }, + inLanguage: loc, + }; + if (isCase && !NOINDEX_PAGES.has(relNorm.replace(/\.html$/, ""))) { + pageNode.creator = { "@id": personId }; + pageNode.datePublished = lastModFor(relNorm); + pageNode.about = isRu + ? "UX/UI-дизайн и проектирование интерфейсов" + : "UX/UI design and interface design"; + } + graph.push(pageNode); + + // Хлебные крошки для вложенных страниц. + const parts = sitePath(relNorm, loc).split("/").filter(Boolean); + if (parts.length > 0) { + const crumbs = [{ "@type": "ListItem", position: 1, name: siteName, item: `${SITE_ORIGIN}/` }]; + let acc = SITE_ORIGIN; + let pos = 2; + for (const part of parts) { + if (part === loc) continue; + acc += `/${part}`; + crumbs.push({ + "@type": "ListItem", + position: pos++, + name: part === "cases" ? (isRu ? "Кейсы" : "Cases") : meta.title || part, + item: `${acc}/`, + }); + } + if (crumbs.length > 1) { + graph.push({ + "@type": "BreadcrumbList", + "@id": `${canonical}#breadcrumbs`, + itemListElement: crumbs, + }); + } + } + + return { + "@context": "https://schema.org", + "@graph": graph, + }; +} + +/** Генерация sitemap.xml с hreflang-альтернатами. */ +function buildSitemap(pageFiles) { + const urls = []; + const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); + for (const pagePath of pageFiles) { + const relNorm = path.relative(srcPagesDir, pagePath).split(path.sep).join("/"); + const noSlug = relNorm.replace(/\.html$/, ""); + if (NOINDEX_PAGES.has(noSlug)) continue; + const lastmod = lastModFor(relNorm); + const locUrl = canonicalFor(relNorm, DEFAULT_LOCALE); + const alternates = LOCALES.map( + (loc) => + ` <xhtml:link rel="alternate" hreflang="${loc}" href="${canonicalFor(relNorm, loc)}" />` + ); + urls.push( + ` <url>\n <loc>${locUrl}</loc>\n <lastmod>${lastmod}</lastmod>\n` + + ` <xhtml:link rel="alternate" hreflang="x-default" href="${locUrl}" />\n` + + alternates.join("\n") + + `\n </url>` + ); + } + return ( + `<?xml version="1.0" encoding="UTF-8"?>\n` + + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n` + + ` xmlns:xhtml="http://www.w3.org/1999/xhtml">\n` + + urls.join("\n") + + `\n</urlset>\n` + ); +} + const includeRe = /\{\{\>\s*([a-z0-9-]+)\s*\}\}/gi; const varRe = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g; +const i18nRe = /\{\{\s*i18n:([\w.-]+)\s*\}\}/g; function readUtf8(filePath) { return fs.readFileSync(filePath, "utf8"); @@ -37,20 +239,38 @@ function loadPartials() { return partialMap; } -function renderTemplate(input, partials, vars, stack = []) { +function loadI18n() { + const map = {}; + for (const loc of LOCALES) { + const file = path.join(srcI18nDir, `${loc}.json`); + map[loc] = fs.existsSync(file) ? JSON.parse(readUtf8(file)) : {}; + } + return map; +} + +function resolveI18n(key, dict, fallback) { + if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return String(dict[key]); + if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return String(fallback[key]); + return `{{i18n:${key}}}`; +} + +function renderTemplate(input, partials, vars, dict, fallback, loc, stack = []) { const withIncludes = input.replace(includeRe, (_, name) => { const key = String(name).trim(); - if (!partials.has(key)) { + const partialKey = loc && loc !== DEFAULT_LOCALE && partials.has(`${key}.${loc}`) ? `${key}.${loc}` : key; + if (!partials.has(partialKey)) { throw new Error(`Partial "${key}" not found`); } - if (stack.includes(key)) { - throw new Error(`Circular partial include: ${[...stack, key].join(" -> ")}`); + if (stack.includes(partialKey)) { + throw new Error(`Circular partial include: ${[...stack, partialKey].join(" -> ")}`); } - const partialContent = partials.get(key); - return renderTemplate(partialContent, partials, vars, [...stack, key]); + const partialContent = partials.get(partialKey); + return renderTemplate(partialContent, partials, vars, dict, fallback, loc, [...stack, partialKey]); }); - return withIncludes.replace(varRe, (_, name) => { + const withI18n = withIncludes.replace(i18nRe, (_, key) => resolveI18n(String(key).trim(), dict, fallback)); + + return withI18n.replace(varRe, (_, name) => { const key = String(name).trim(); if (Object.prototype.hasOwnProperty.call(vars, key)) { return String(vars[key]); @@ -66,8 +286,9 @@ function ensureDir(dirPath) { /** На странице кейса убираем из HTML карусели карточку со ссылкой на текущий файл (href уже с подставленным casesPrefix). */ function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) { if (!omitHrefBasename) return html; - const hrefDouble = `href="${omitHrefBasename}"`; - const hrefSingle = `href='${omitHrefBasename}'`; + const slug = omitHrefBasename.replace(/\.html$/, ""); + const hrefDouble = `href="${slug}/"`; + const hrefSingle = `href='${slug}/'`; const itemClass = "projects-carousel__item"; let search = 0; while (true) { @@ -92,33 +313,121 @@ function stripCurrentCasePortfolioCarouselItem(html, omitHrefBasename) { } } +/** Базовые переменны для страницы в дефолтной локали (как было до i18n). */ +function baseVars(relNorm, depth) { + if (relNorm.startsWith("cases/")) { + return { assetPrefix: "../../", casesPrefix: "../" }; + } + if (relNorm === "resume.html") { + return { assetPrefix: "../", casesPrefix: "" }; + } + if (relNorm === "cookie-policy.html") { + return { assetPrefix: "../", casesPrefix: "" }; + } + return { + assetPrefix: depth === 0 ? "" : "../".repeat(depth), + casesPrefix: depth === 0 ? "cases/" : "", + }; +} + function build() { const partials = loadPartials(); - const pageFiles = walkHtmlFiles(srcPagesDir); + const i18n = loadI18n(); + const defaultDict = i18n[DEFAULT_LOCALE] || {}; + const localeSuffixRe = new RegExp(`\\.(${LOCALES.join("|")})\\.html$`, "i"); + const pageFiles = walkHtmlFiles(srcPagesDir).filter((p) => !localeSuffixRe.test(p)); for (const pagePath of pageFiles) { const rel = path.relative(srcPagesDir, pagePath); - const outPath = path.join(outDir, rel); + const relNorm = rel.split(path.sep).join("/"); const pageDirRel = path.dirname(rel); const depth = pageDirRel === "." ? 0 : pageDirRel.split(path.sep).length; + const isCase = relNorm.startsWith("cases/"); + const isResume = relNorm === "resume.html"; + const isCookiePolicy = relNorm === "cookie-policy.html"; + const omitFile = isCase ? path.basename(pagePath) : null; - const vars = { - assetPrefix: depth === 0 ? "" : "../".repeat(depth), - casesPrefix: depth === 0 ? "cases/" : "", - }; + for (const loc of LOCALES) { + const dict = i18n[loc] || {}; + const isDefault = loc === DEFAULT_LOCALE; + const langPrefix = isDefault ? "" : `${loc}/`; - const source = readUtf8(pagePath); - let rendered = renderTemplate(source, partials, vars); - const relNorm = rel.split(path.sep).join("/"); - if (relNorm.startsWith("cases/")) { - const omitFile = path.basename(pagePath); - rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile); + // Переведённый исходник страницы для локали, если есть. + let sourcePath = pagePath; + if (!isDefault) { + const override = pagePath.replace(/\.html$/, `.${loc}.html`); + if (fs.existsSync(override)) sourcePath = override; + } + const source = readUtf8(sourcePath); + + // --- SEO-переменные для head-seo / json-ld --- + const noSlug = relNorm.replace(/\.html$/, ""); + const isNoindex = NOINDEX_PAGES.has(noSlug); + const canonicalUrl = canonicalFor(relNorm, loc); + const metaSource = extractMeta(source); + const pageTitle = metaSource.title || (isDefault ? SITE_NAME_RU : SITE_NAME_EN); + const pageDesc = metaSource.description || pageTitle; + const isCasePage = relNorm.startsWith("cases/"); + + const vars = baseVars(relNorm, depth); + vars.canonicalUrl = canonicalUrl; + vars.canonicalRu = canonicalFor(relNorm, "ru"); + vars.canonicalEn = canonicalFor(relNorm, "en"); + vars.authorName = isDefault ? AUTHOR_NAME_RU : AUTHOR_NAME_EN; + vars.siteName = isDefault ? SITE_NAME_RU : SITE_NAME_EN; + vars.metaRobots = isNoindex ? "noindex, nofollow" : "index, follow"; + vars.themeColor = "#0f1115"; + vars.ogType = isCasePage ? "article" : "website"; + vars.ogLocale = isDefault ? "ru_RU" : "en_US"; + vars.ogLocaleAlt = isDefault ? "en_US" : "ru_RU"; + vars.ogTitle = pageTitle; + vars.ogDescription = pageDesc; + vars.ogImage = OG_IMAGE_DEFAULT; + vars.ogImageAlt = pageTitle; + vars.jsonLd = JSON.stringify(buildJsonLd(relNorm, loc, { title: pageTitle, description: pageDesc }, canonicalUrl), null, 2); + + // Префикс до КОРНЯ ЛОКАЛИ (без en-сдвига) — для навигационных ссылок, + // чтобы они вели в ту же локаль (/en/), а не в корень сайта (/). + vars.pagePrefix = vars.assetPrefix; + // Для не-дефолтной локали всё содержимое уходит на уровень глубже (public/<lang>/), + // поэтому к assetPrefix добавляем один ../, а пустой casesPrefix — в ../cases/. + if (!isDefault) { + vars.assetPrefix = vars.assetPrefix === "" ? "../" : vars.assetPrefix + "../"; + if (vars.casesPrefix === "") vars.casesPrefix = "../cases/"; + } + vars.lang = loc; + + let outPath; + if (isCase) { + const slug = path.basename(pagePath, ".html"); + outPath = path.join(outDir, langPrefix, "cases", slug, "index.html"); + } else if (isResume) { + outPath = path.join(outDir, langPrefix, "resume", "index.html"); + } else if (isCookiePolicy) { + outPath = path.join(outDir, langPrefix, "cookie-policy", "index.html"); + } else { + outPath = path.join(outDir, langPrefix, rel); + } + + let rendered = renderTemplate(source, partials, vars, dict, defaultDict, loc); + // Подменяем язык в <html lang="..."> под текущую локаль. + rendered = rendered.replace(/<html\s+lang="ru"/, `<html lang="${loc}"`); + if (isCase) { + rendered = stripCurrentCasePortfolioCarouselItem(rendered, omitFile); + } + // Liquid Glass: инъецируем инлайн SVG displacement-фильтр сразу после <body>. + const lgSvg = LIQUID_GLASS_FILTER.replace(/{{LG_ASSET_PREFIX}}/g, vars.assetPrefix); + rendered = rendered.replace(/(<body[^>]*>)/, `$1\n ${lgSvg}\n`); + ensureDir(path.dirname(outPath)); + fs.writeFileSync(outPath, rendered, "utf8"); } - ensureDir(path.dirname(outPath)); - fs.writeFileSync(outPath, rendered, "utf8"); } - console.log(`Built ${pageFiles.length} pages into public/`); + const sitemapXml = buildSitemap(pageFiles); + fs.writeFileSync(path.join(outDir, "sitemap.xml"), sitemapXml, "utf8"); + + console.log(`Built ${pageFiles.length} pages × ${LOCALES.length} locales into public/`); + console.log(`Generated sitemap.xml (${sitemapXml.split("<url>").length - 1} urls)`); } build(); diff --git a/scripts/deploy-vps.mjs b/scripts/deploy-vps.mjs deleted file mode 100644 index 7cddeeb..0000000 --- a/scripts/deploy-vps.mjs +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Инкрементальный деплой public/ на VPS в Docker volume nginx. - * Загружает только изменённые файлы через tar-пайп над SSH (одно соединение). - * - * Использование: - * node scripts/deploy-vps.mjs [sourceDir] - */ - -import path from "node:path"; -import fs from "node:fs"; -import os from "node:os"; -import { execFile, spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); - -const SSH_KEY = path.join(os.homedir(), ".ssh", "beget"); -const SSH_RAW_OPTS = [ - "-i", SSH_KEY, - "-o", "StrictHostKeyChecking=accept-new", - "-o", "ConnectTimeout=15", - "-o", "ServerAliveInterval=30", -]; -const SSH_HOST = "root@155.212.186.39"; -const TARGET_DIR = "/var/lib/docker/volumes/s9mrcps8xw02r1dak0fsznix-www-data/_data"; -const MANIFEST_PATH = path.join(projectRoot, ".deploy-manifest.json"); - -const SOURCE_DIR = (() => { - const raw = process.argv[2] ?? "public"; - return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw); -})(); - -function ssh(args) { - return new Promise((resolve, reject) => { - execFile("ssh", [...SSH_RAW_OPTS, SSH_HOST, ...args], { maxBuffer: 1024 * 1024 }, (err, stdout) => { - if (err) reject(new Error((err.stderr || "").trim() || err.message)); - else resolve((stdout || "").trim()); - }); - }); -} - -function scanFiles(dir) { - const entries = []; - const walk = (current, relative) => { - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const rel = relative ? `${relative}/${entry.name}` : entry.name; - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - walk(full, rel); - } else if (entry.isFile()) { - const stat = fs.statSync(full); - entries.push({ - relPath: rel.replace(/\\/g, "/"), - mtime: stat.mtimeMs, - size: stat.size, - }); - } - } - }; - walk(dir, ""); - return entries; -} - -function loadManifest() { - try { - return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8")); - } catch { - return {}; - } -} - -function saveManifest(manifest) { - fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8"); -} - -/** Загрузить изменённые файлы одним tar-пайпом через SSH */ -function uploadViaTar(filePaths, sourceDir) { - return new Promise((resolve, reject) => { - const listPath = path.join(os.tmpdir(), `deploy-files-${Date.now()}.txt`); - fs.writeFileSync(listPath, filePaths.join("\n"), "utf-8"); - - // cwd вместо -C — обходит баг bsdtar с кириллицей в пути - const tar = spawn("tar", [ - "-c", - `--files-from=${listPath}`, - "--format=ustar", - ], { cwd: sourceDir }); - - const sshProc = spawn("ssh", [ - ...SSH_RAW_OPTS, - SSH_HOST, - `tar xf - -C ${TARGET_DIR}`, - ]); - - tar.stdout.pipe(sshProc.stdin); - - let stderrChunks = []; - sshProc.stderr.on("data", (d) => { stderrChunks.push(d); }); - tar.stderr.on("data", (d) => { stderrChunks.push(d); }); - - function cleanup() { - try { fs.unlinkSync(listPath); } catch { /* ok */ } - } - - sshProc.on("close", (code) => { - cleanup(); - if (code !== 0) { - const msg = Buffer.concat(stderrChunks).toString().trim(); - reject(new Error(msg || `ssh exited with code ${code}`)); - } else { - resolve(); - } - }); - - sshProc.on("error", (err) => { cleanup(); reject(err); }); - tar.on("error", (err) => { cleanup(); sshProc.kill(); reject(err); }); - }); -} - -async function deploy() { - // 1. Убедиться, что целевая директория существует - console.log("Проверка соединения..."); - await ssh([`mkdir -p ${TARGET_DIR}`]); - - // 2. Сравнить локальные файлы с манифестом - console.log("Сканирование..."); - const localFiles = scanFiles(SOURCE_DIR); - const manifest = loadManifest(); - - const changedPaths = []; - for (const f of localFiles) { - const prev = manifest[f.relPath]; - if (!prev || prev.mtime !== f.mtime || prev.size !== f.size) { - changedPaths.push(f.relPath); - } - } - - const localPaths = new Set(localFiles.map((f) => f.relPath)); - const removedPaths = Object.keys(manifest).filter((p) => !localPaths.has(p)); - - if (changedPaths.length === 0 && removedPaths.length === 0) { - console.log("Изменений нет — деплой не требуется."); - return; - } - - console.log(`Изменено/новых: ${changedPaths.length}, удалено: ${removedPaths.length}`); - - // 3. Загрузить изменённые файлы одним tar-пайпом - if (changedPaths.length > 0) { - console.log("Загрузка файлов..."); - await uploadViaTar(changedPaths, SOURCE_DIR); - console.log("Загрузка завершена."); - } - - // 4. Удалить файлы, которых больше нет локально - if (removedPaths.length > 0) { - console.log("Удаление лишних файлов..."); - const rmList = removedPaths.map((p) => `${TARGET_DIR}/${p}`).join(" "); - await ssh([`rm -f ${rmList}`]); - await ssh([`find ${TARGET_DIR} -type d -empty -delete 2>/dev/null || true`]); - } - - // 5. Обновить манифест - saveManifest( - Object.fromEntries(localFiles.map((f) => [f.relPath, { mtime: f.mtime, size: f.size }])), - ); -} - -const relDir = path.relative(projectRoot, SOURCE_DIR); - -deploy() - .then(() => { - console.log(`Готово: ${relDir}/ → ${SSH_HOST}:${TARGET_DIR}`); - }) - .catch((err) => { - console.error("Ошибка деплоя:", err.message || err); - process.exit(1); - }); diff --git a/scripts/generate-resume-pdf.mjs b/scripts/generate-resume-pdf.mjs new file mode 100644 index 0000000..50c49b3 --- /dev/null +++ b/scripts/generate-resume-pdf.mjs @@ -0,0 +1,63 @@ +import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(__dirname, '../public/assets/resume/Mamadaev_Nik__CV__Product_Designer_UX-UI.pdf'); +const PAGE = process.argv[2] || 'http://localhost:4175/resume/'; +const FONT = process.argv[3] || 'geologica'; + +const browser = await puppeteer.launch({ headless: true }); +const page = await browser.newPage(); + +await page.goto(PAGE, { waitUntil: 'networkidle0', timeout: 30000 }); +await page.waitForSelector('.resume-doc', { timeout: 5000 }); + +await page.evaluate((font) => { + localStorage.setItem('siteThemeV1', JSON.stringify({ font })); + document.documentElement.dataset.font = font; + const href = `assets/fonts/${font}.css`; + if (!document.querySelector(`link[data-theme-font="${font}"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.dataset.themeFont = font; + document.head.appendChild(link); + } +}, FONT); +await page.evaluate(() => document.fonts.ready); +await new Promise(r => setTimeout(r, 1500)); + +const fontInfo = await page.evaluate(() => ({ + dataFont: document.documentElement.dataset.font, + computedFont: getComputedStyle(document.body).fontFamily, +})); +console.log('Font:', fontInfo); + +await page.evaluate(() => { + document.querySelectorAll('header, footer, nav, .fab-stack, .cookie-notice, .skip-link').forEach(el => el.remove()); + const doc = document.querySelector('.resume-doc'); + if (doc) { + doc.style.removeProperty('max-width'); + doc.style.width = '100%'; + doc.style.margin = '0'; + doc.style.setProperty('background', 'transparent', 'important'); + doc.style.border = 'none'; + document.documentElement.style.setProperty('background', 'transparent', 'important'); + document.body.style.background = 'transparent'; + document.body.innerHTML = ''; + document.body.appendChild(doc); + document.body.style.margin = '0'; + document.body.style.padding = '0'; + } +}); + +await page.pdf({ + path: OUT, + format: 'A4', + printBackground: true, + margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' } +}); + +await browser.close(); +console.log('PDF saved:', OUT); diff --git a/scripts/serve-dev.mjs b/scripts/serve-dev.mjs index d38db5d..39fb5ae 100644 --- a/scripts/serve-dev.mjs +++ b/scripts/serve-dev.mjs @@ -1,214 +1,254 @@ -/** - * Локальный сервер: public/ + BrowserSync + API правок контента (только localhost). - */ -import browserSync from "browser-sync"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.join(__dirname, ".."); -const publicDir = path.join(root, "public"); -const editsDir = path.join(root, "texts", "content-edits"); -const PENDING_FILE = path.join(editsDir, "pending.json"); - -const PAGE_RE = /^[\w./-]+\.html$/; - -function ensureEditsDir() { - fs.mkdirSync(editsDir, { recursive: true }); -} - -function isLocalRequest(req) { - const host = (req.headers.host || "").split(":")[0]; - return host === "localhost" || host === "127.0.0.1"; -} - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); - req.on("error", reject); - }); -} - -function sendJson(res, status, data) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json; charset=utf-8"); - res.end(JSON.stringify(data)); -} - -function emptyPending() { - return { version: 2, updatedAt: null, items: [] }; -} - -function readPending() { - if (!fs.existsSync(PENDING_FILE)) return emptyPending(); - try { - const data = JSON.parse(fs.readFileSync(PENDING_FILE, "utf8")); - if (!Array.isArray(data.items)) data.items = []; - data.version = 2; - return data; - } catch { - return emptyPending(); - } -} - -function writePending(data) { - data.version = 2; - data.updatedAt = new Date().toISOString(); - data.items = data.items.map((item, index) => ({ - ...item, - n: index + 1, - })); - fs.writeFileSync(PENDING_FILE, JSON.stringify(data, null, 2) + "\n", "utf8"); - return data; -} - -function legacyFileToPage(filename) { - const base = filename.replace(/\.json$/i, ""); - return `${base.replace(/--/g, "/")}.html`; -} - -function migrateLegacyFiles() { - if (!fs.existsSync(editsDir)) return; - const pending = readPending(); - let changed = false; - - for (const name of fs.readdirSync(editsDir)) { - if (name === "pending.json" || name === "README.md" || name === ".gitkeep") continue; - if (!name.endsWith(".json")) continue; - - const legacyPath = path.join(editsDir, name); - let legacy; - try { - legacy = JSON.parse(fs.readFileSync(legacyPath, "utf8")); - } catch { - continue; - } - - const page = legacy.page || legacyFileToPage(name); - const legacyItems = Array.isArray(legacy.items) ? legacy.items : []; - for (const item of legacyItems) { - if (!(item.instruction || "").trim()) continue; - pending.items.push({ ...item, page }); - changed = true; - } - fs.unlinkSync(legacyPath); - changed = true; - } - - if (changed) writePending(pending); -} - -function itemsForPage(pending, page) { - return pending.items - .filter((item) => item.page === page && (item.instruction || "").trim()) - .map((item, index) => ({ ...item, n: index + 1 })); -} - -function mergePageItems(page, pageItems) { - const pending = readPending(); - const trimmed = pageItems - .filter((item) => (item.instruction || "").trim()) - .map((item) => ({ ...item, page })); - const others = pending.items.filter((item) => item.page !== page); - pending.items = [...others, ...trimmed]; - return writePending(pending); -} - -function contentEditsApi(req, res, next) { - const url = new URL(req.url || "/", "http://localhost"); - - if (!url.pathname.startsWith("/api/content-edits")) { - next(); - return; - } - - if (!isLocalRequest(req)) { - sendJson(res, 403, { error: "Forbidden" }); - return; - } - - ensureEditsDir(); - migrateLegacyFiles(); - - const page = url.searchParams.get("page") || ""; - - if (req.method === "GET") { - const pending = readPending(); - if (!page) { - sendJson(res, 200, pending); - return; - } - if (!PAGE_RE.test(page)) { - sendJson(res, 400, { error: "Invalid page" }); - return; - } - sendJson(res, 200, { - version: 2, - page, - updatedAt: pending.updatedAt, - items: itemsForPage(pending, page), - }); - return; - } - - if (req.method === "PUT") { - readBody(req) - .then((body) => { - let payload; - try { - payload = JSON.parse(body); - } catch { - sendJson(res, 400, { error: "Invalid JSON" }); - return; - } - const payloadPage = payload.page || page; - if (!PAGE_RE.test(payloadPage)) { - sendJson(res, 400, { error: "Invalid page" }); - return; - } - if (page && payloadPage !== page) { - sendJson(res, 400, { error: "Page mismatch" }); - return; - } - if (!Array.isArray(payload.items)) { - sendJson(res, 400, { error: "items must be array" }); - return; - } - const pending = mergePageItems(payloadPage, payload.items); - sendJson(res, 200, { - version: 2, - page: payloadPage, - updatedAt: pending.updatedAt, - items: itemsForPage(pending, payloadPage), - }); - }) - .catch(() => sendJson(res, 500, { error: "Write failed" })); - return; - } - - sendJson(res, 405, { error: "Method not allowed" }); -} - -ensureEditsDir(); -if (!fs.existsSync(PENDING_FILE)) { - writePending(emptyPending()); -} -migrateLegacyFiles(); - -browserSync.create().init({ - server: { - baseDir: publicDir, - middleware: [contentEditsApi], - }, - files: [path.join(publicDir, "**", "*")], - port: 4175, - ui: { port: 4176 }, - notify: false, - open: false, - ghostMode: false, -}); - -console.log("serve-dev: http://localhost:4175 (public/, reload only public/**)"); +/** + * Локальный сервер: public/ + API правок контента (только localhost). + * Фолбэк: нативный http без browser-sync (portscanner глючит на Node 23+). + */ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, ".."); +const publicDir = path.join(root, "public"); +const editsDir = path.join(root, "texts", "content-edits"); +const PENDING_FILE = path.join(editsDir, "pending.json"); + +const PAGE_RE = /^[\w./-]+\.html$/; + +function ensureEditsDir() { + fs.mkdirSync(editsDir, { recursive: true }); +} + +function isLocalRequest(req) { + const host = (req.headers.host || "").split(":")[0]; + const ALLOWED = ["localhost", "127.0.0.1", "dev.mamadaevv.site", "f2ogw6fzi6trnp7cp43x8wsd.31.129.103.242.sslip.io"]; + return ALLOWED.includes(host); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJson(res, status, data) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(data)); +} + +function emptyPending() { + return { version: 2, updatedAt: null, items: [] }; +} + +function readPending() { + if (!fs.existsSync(PENDING_FILE)) return emptyPending(); + try { + const data = JSON.parse(fs.readFileSync(PENDING_FILE, "utf8")); + if (!Array.isArray(data.items)) data.items = []; + data.version = 2; + return data; + } catch { + return emptyPending(); + } +} + +function writePending(data) { + data.version = 2; + data.updatedAt = new Date().toISOString(); + data.items = data.items.map((item, index) => ({ + ...item, + n: index + 1, + })); + fs.writeFileSync(PENDING_FILE, JSON.stringify(data, null, 2) + "\n", "utf8"); + return data; +} + +function legacyFileToPage(filename) { + const base = filename.replace(/\.json$/i, ""); + return `${base.replace(/--/g, "/")}.html`; +} + +function migrateLegacyFiles() { + if (!fs.existsSync(editsDir)) return; + const pending = readPending(); + let changed = false; + + for (const name of fs.readdirSync(editsDir)) { + if (name === "pending.json" || name === "README.md" || name === ".gitkeep") continue; + if (!name.endsWith(".json")) continue; + + const legacyPath = path.join(editsDir, name); + let legacy; + try { + legacy = JSON.parse(fs.readFileSync(legacyPath, "utf8")); + } catch { + continue; + } + + const page = legacy.page || legacyFileToPage(name); + const legacyItems = Array.isArray(legacy.items) ? legacy.items : []; + for (const item of legacyItems) { + if (!(item.instruction || "").trim()) continue; + pending.items.push({ ...item, page }); + changed = true; + } + fs.unlinkSync(legacyPath); + changed = true; + } + + if (changed) writePending(pending); +} + +function itemsForPage(pending, page) { + return pending.items + .filter((item) => item.page === page && (item.instruction || "").trim()) + .map((item, index) => ({ ...item, n: index + 1 })); +} + +function mergePageItems(page, pageItems) { + const pending = readPending(); + const trimmed = pageItems + .filter((item) => (item.instruction || "").trim()) + .map((item) => ({ ...item, page })); + const others = pending.items.filter((item) => item.page !== page); + pending.items = [...others, ...trimmed]; + return writePending(pending); +} + +function contentEditsApi(req, res, next) { + const url = new URL(req.url || "/", "http://localhost"); + + if (!url.pathname.startsWith("/api/content-edits")) { + next(); + return; + } + + if (!isLocalRequest(req)) { + sendJson(res, 403, { error: "Forbidden" }); + return; + } + + ensureEditsDir(); + migrateLegacyFiles(); + + const page = url.searchParams.get("page") || ""; + + if (req.method === "GET") { + const pending = readPending(); + if (!page) { + sendJson(res, 200, pending); + return; + } + if (!PAGE_RE.test(page)) { + sendJson(res, 400, { error: "Invalid page" }); + return; + } + sendJson(res, 200, { + version: 2, + page, + updatedAt: pending.updatedAt, + items: itemsForPage(pending, page), + }); + return; + } + + if (req.method === "PUT") { + readBody(req) + .then((body) => { + let payload; + try { + payload = JSON.parse(body); + } catch { + sendJson(res, 400, { error: "Invalid JSON" }); + return; + } + const payloadPage = payload.page || page; + if (!PAGE_RE.test(payloadPage)) { + sendJson(res, 400, { error: "Invalid page" }); + return; + } + if (page && payloadPage !== page) { + sendJson(res, 400, { error: "Page mismatch" }); + return; + } + if (!Array.isArray(payload.items)) { + sendJson(res, 400, { error: "items must be array" }); + return; + } + const pending = mergePageItems(payloadPage, payload.items); + sendJson(res, 200, { + version: 2, + page: payloadPage, + updatedAt: pending.updatedAt, + items: itemsForPage(pending, payloadPage), + }); + }) + .catch(() => sendJson(res, 500, { error: "Write failed" })); + return; + } + + sendJson(res, 405, { error: "Method not allowed" }); +} + +ensureEditsDir(); +if (!fs.existsSync(PENDING_FILE)) { + writePending(emptyPending()); +} +migrateLegacyFiles(); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".mp4": "video/mp4", + ".webm": "video/webm", +}; + +function serveStatic(req, res) { + const url = new URL(req.url || "/", "http://localhost"); + let filePath = path.join(publicDir, decodeURIComponent(url.pathname)); + if (filePath.endsWith(path.sep)) filePath = path.join(filePath, "index.html"); + + fs.stat(filePath, (err, stat) => { + if (err || !stat.isFile()) { + const fallback = path.join(publicDir, "index.html"); + fs.readFile(fallback, (e2, data) => { + if (e2) { res.writeHead(404); res.end("Not found"); return; } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(data); + }); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const mime = MIME[ext] || "application/octet-stream"; + fs.readFile(filePath, (e2, data) => { + if (e2) { res.writeHead(500); res.end("Error"); return; } + res.writeHead(200, { "Content-Type": mime }); + res.end(data); + }); + }); +} + +const server = http.createServer((req, res) => { + contentEditsApi(req, res, () => serveStatic(req, res)); +}); + +server.listen(4175, () => { + console.log("serve-dev: http://localhost:4175 (public/)"); +}); diff --git a/scripts/sync-gitverse.mjs b/scripts/sync-gitverse.mjs deleted file mode 100644 index 4eff033..0000000 --- a/scripts/sync-gitverse.mjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Копирует содержимое папки public в папку gitverse. - * Файлы `.png` не копируются (на GitVerse достаточно WebP и прочих ассетов). - * - * По умолчанию: - * node scripts/sync-gitverse.mjs - * (копирует projectRoot/public -> projectRoot/gitverse) - * - * Можно передать свои пути: - * node scripts/sync-gitverse.mjs <sourceDir> <targetDir> - */ - -import path from "node:path"; -import os from "node:os"; -import fsp from "node:fs/promises"; -import { execFile, spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); -const PRESERVE_IN_TARGET = new Set([".git", ".gitverse"]); - -function shouldSkipGitverseFile(fileName) { - return path.extname(fileName).toLowerCase() === ".png"; -} -const DEFAULT_SSH_KEY_PATH = path.join(os.homedir(), ".ssh", "id_ed25519_work"); -const GITVERSE_SITE_URL = "https://mamadaevv.gitverse.site/me/"; - -/** Системный браузер по умолчанию (Windows / macOS / Linux). */ -function openUrlInDefaultBrowser(url) { - if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return; - const opts = { detached: true, stdio: "ignore" }; - let child; - if (process.platform === "win32") { - child = spawn("cmd", ["/c", "start", "", url], opts); - } else if (process.platform === "darwin") { - child = spawn("open", [url], opts); - } else { - child = spawn("xdg-open", [url], opts); - } - child?.unref(); -} - -function resolveDir(input, fallback) { - const raw = input ?? fallback; - return path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw); -} - -async function ensureDirExists(dirPath, label) { - try { - const st = await fsp.stat(dirPath); - if (!st.isDirectory()) { - throw new Error(`${label} не является папкой: ${dirPath}`); - } - } catch (error) { - if (error && error.code === "ENOENT") { - throw new Error(`${label} не найдена: ${dirPath}`); - } - throw error; - } -} - -async function cleanTargetDir(targetDir) { - await fsp.mkdir(targetDir, { recursive: true }); - const entries = await fsp.readdir(targetDir, { withFileTypes: true }); - - for (const entry of entries) { - if (PRESERVE_IN_TARGET.has(entry.name)) continue; - const fullPath = path.join(targetDir, entry.name); - await fsp.rm(fullPath, { recursive: true, force: true }); - } -} - -async function copyDirFiltered(sourceDir, targetDir) { - await fsp.mkdir(targetDir, { recursive: true }); - const entries = await fsp.readdir(sourceDir, { withFileTypes: true }); - for (const entry of entries) { - const srcPath = path.join(sourceDir, entry.name); - const dstPath = path.join(targetDir, entry.name); - if (entry.isDirectory()) { - await copyDirFiltered(srcPath, dstPath); - continue; - } - if (!entry.isFile() || shouldSkipGitverseFile(entry.name)) continue; - await fsp.copyFile(srcPath, dstPath); - } -} - -function runGit(args, cwd) { - return new Promise((resolve, reject) => { - const gitEnv = { - ...process.env, - GIT_SSH_COMMAND: `ssh -i "${DEFAULT_SSH_KEY_PATH}" -o IdentitiesOnly=yes`, - }; - execFile("git", args, { cwd, encoding: "utf8", env: gitEnv }, (error, stdout, stderr) => { - if (error) { - reject(new Error((stderr || error.message || "").trim())); - return; - } - resolve((stdout || "").trim()); - }); - }); -} - -function makeCommitMessage() { - const now = new Date(); - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, "0"); - const d = String(now.getDate()).padStart(2, "0"); - const hh = String(now.getHours()).padStart(2, "0"); - const mm = String(now.getMinutes()).padStart(2, "0"); - return `sync public ${y}-${m}-${d} ${hh}:${mm}`; -} - -async function commitAndPush(targetDir) { - const gitDir = path.join(targetDir, ".git"); - await ensureDirExists(gitDir, "Git репозиторий (.git)"); - try { - await fsp.access(DEFAULT_SSH_KEY_PATH); - } catch { - throw new Error(`SSH-ключ не найден: ${DEFAULT_SSH_KEY_PATH}`); - } - - await runGit(["add", "."], targetDir); - const changed = await runGit(["status", "--porcelain"], targetDir); - - if (!changed) { - console.log("Изменений для коммита нет."); - return; - } - - const message = makeCommitMessage(); - await runGit(["commit", "-m", message], targetDir); - await runGit(["push"], targetDir); - console.log(`Коммит и пуш выполнены: ${message}`); - openUrlInDefaultBrowser(GITVERSE_SITE_URL); - console.log(`Открыт в браузере: ${GITVERSE_SITE_URL}`); -} - -async function main() { - const sourceDir = resolveDir(process.argv[2], "public"); - const targetDir = resolveDir(process.argv[3], "gitverse"); - - await ensureDirExists(sourceDir, "Исходная папка"); - - if (sourceDir === targetDir) { - throw new Error("Исходная и целевая папки совпадают."); - } - - await cleanTargetDir(targetDir); - await copyDirFiltered(sourceDir, targetDir); - - console.log( - `Синхронизировано: ${path.relative(projectRoot, sourceDir)} -> ${path.relative(projectRoot, targetDir)} (сохранены .git и .gitverse)`, - ); - await commitAndPush(targetDir); -} - -main().catch((error) => { - console.error("Ошибка копирования:", error.message || error); - process.exit(1); -}); diff --git a/scripts/translate.mjs b/scripts/translate.mjs new file mode 100644 index 0000000..b829800 --- /dev/null +++ b/scripts/translate.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * translate.mjs — CLI-обёртка над apinex.bond /v1/tools/translator + * + * Использование: + * node scripts/translate.mjs --target EN --text "Привет" + * node scripts/translate.mjs --target EN --source RU --file page.html --out page.en.html + * cat strings.txt | node scripts/translate.mjs --target EN --batch + * node scripts/translate.mjs --list-languages + * + * Ключ берётся из HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY, иначе из /root/.hermes/.env. + * + * Для HTML (--html или авто) шаблонные токены {{...}} и содержимое <code> защищаются + * HTML-комментариями, чтобы переводчик их не трогал (они восстанавливаются после). + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const API_URL = "https://api.apinex.bond/v1/tools/translator"; +const LANG_URL = "https://api.apinex.bond/v1/tools/translator/languages"; +const ENV_PATH = "/root/.hermes/.env"; + +// ---- аргументы ---- +function parseArgs(argv) { + const a = { + target: null, + source: null, + html: null, + batch: false, + file: null, + out: null, + text: null, + list: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => argv[++i]; + switch (arg) { + case "--target": + case "-t": + a.target = next(); + break; + case "--source": + case "-s": + a.source = next(); + break; + case "--html": + a.html = true; + break; + case "--no-html": + a.html = false; + break; + case "--batch": + a.batch = true; + break; + case "--file": + case "-f": + a.file = next(); + break; + case "--out": + case "-o": + a.out = next(); + break; + case "--text": + a.text = next(); + break; + case "--list-languages": + case "--list": + a.list = true; + break; + default: + if (!a.text && !arg.startsWith("-")) a.text = arg; + } + } + return a; +} + +// ---- ключ ---- +function getKey() { + const fromEnv = process.env.HERMES_CUSTOM_APINEX_API_KEY || process.env.APINEX_API_KEY; + if (fromEnv) return fromEnv; + try { + const txt = readFileSync(ENV_PATH, "utf8"); + const m = txt.match(/^\s*HERMES_CUSTOM_APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m) + || txt.match(/^\s*APINEX_API_KEY\s*=\s*"?([^"\n]+)"?/m); + if (m) return m[1].trim(); + } catch {} + throw new Error("API key not found (HERMES_CUSTOM_APINEX_API_KEY / APINEX_API_KEY / " + ENV_PATH + ")"); +} + +// ---- защита токенов, <script>, <style> и <code> для HTML ---- +function protect(html) { + const store = []; + let out = html + // <script>/<style> целиком: переводчик не должен трогать JS/CSS + .replace(/<script[\s\S]*?<\/script>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; }) + .replace(/<style[\s\S]*?<\/style>/gi, (m) => { store.push(m); return `<!--SCP${store.length - 1}-->`; }) + .replace(/\{\{[\s\S]*?\}\}/g, (m) => { store.push(m); return `<!--TPL${store.length - 1}-->`; }) + .replace(/<code>([\s\S]*?)<\/code>/g, (_m, inner) => { store.push(inner); return `<code><!--CPL${store.length - 1}--></code>`; }); + return { out, store }; +} +function restore(html, store) { + return html + .replace(/<!--SCP(\d+)-->/g, (_m, i) => store[+i]) + .replace(/<!--TPL(\d+)-->/g, (_m, i) => store[+i]) + .replace(/<code><!--CPL(\d+)--><\/code>/g, (_m, i) => `<code>${store[+i]}</code>`); +} + +// ---- сам перевод ---- +async function translateTexts(texts, { target, source, html }) { + const body = { + text: texts, + target_lang: target, + preserve_formatting: true, + }; + if (source) body.source_lang = source; + if (html) body.tag_handling = "html"; + + const res = await fetch(API_URL, { + method: "POST", + headers: { + Authorization: "Bearer " + getKey(), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Translator HTTP ${res.status}: ${err}`); + } + const data = await res.json(); + return data.translations.map((t) => t.text); +} + +async function listLanguages() { + const res = await fetch(LANG_URL, { headers: { Authorization: "Bearer " + getKey() } }); + if (!res.ok) throw new Error(`Languages HTTP ${res.status}`); + return res.json(); +} + +// ---- main ---- +async function main() { + const a = parseArgs(process.argv.slice(2)); + + if (a.list) { + const langs = await listLanguages(); + console.log(JSON.stringify(langs, null, 2)); + return; + } + + if (!a.target) { + console.error("Usage: translate.mjs --target EN [--source RU] [--html] [--file|-f path] [--text|-t str] [--batch] [--out|-o path]"); + process.exit(2); + } + + let raw; + if (a.file) raw = readFileSync(resolve(__dirname, "..", a.file), "utf8"); + else if (a.text) raw = a.text; + else raw = readFileSync(0, "utf8"); // stdin + + const useHtml = a.html ?? (raw.includes("<") && raw.includes(">")); + let texts; + if (a.batch) { + texts = raw.split(/\r?\n/).filter((l) => l.trim().length); + } else { + texts = [raw]; + } + + let results; + if (useHtml) { + const protectedTexts = texts.map((t) => protect(t)); + const translated = await translateTexts(protectedTexts.map((p) => p.out), { + target: a.target, + source: a.source, + html: true, + }); + results = translated.map((t, i) => restore(t, protectedTexts[i].store)); + } else { + results = await translateTexts(texts, { target: a.target, source: a.source, html: false }); + } + + const output = a.batch ? results.join("\n") : results[0]; + if (a.out) writeFileSync(resolve(__dirname, "..", a.out), output, "utf8"); + else process.stdout.write(output + (a.batch ? "" : "")); + if (!a.out) process.stdout.write("\n"); +} + +main().catch((e) => { + console.error("ERROR:", e.message); + process.exit(1); +}); diff --git a/selfraf.mjs b/selfraf.mjs new file mode 100644 index 0000000..fed592d --- /dev/null +++ b/selfraf.mjs @@ -0,0 +1,14 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +const errs=[]; p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +// берём toDataURL двух кадров с интервалом — мой rAF должен сдвинуть currentFrame +const a = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; return m.canvasElement.toDataURL('image/png').length; }); +await new Promise(r=>setTimeout(r,1500)); +const c = await p.evaluate(()=>{ const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; return m.canvasElement.toDataURL('image/png').length; }); +console.log('dataURL length t1:', a, ' t2:', c, ' CHANGED:', a!==c); +console.log('errs', errs.slice(0,3)); +await b.close(); diff --git a/setframe.mjs b/setframe.mjs new file mode 100644 index 0000000..d4f41bd --- /dev/null +++ b/setframe.mjs @@ -0,0 +1,15 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const cv=m.canvasElement; + m.setFrame(0); const a=cv.toDataURL('image/png'); + m.setFrame(10000); const c=cv.toDataURL('image/png'); + return { same: a===c, lenA:a.length, lenC:c.length }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/setu.mjs b/setu.mjs new file mode 100644 index 0000000..7e29a0f --- /dev/null +++ b/setu.mjs @@ -0,0 +1,17 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + m.setUniforms({ u_colorsCount: 4, u_colors: [[0.486,0.361,1,1],[0,0.761,0.78,1],[1,0.361,0.541,1],[1,0.702,0.278,1]] }); + gl.drawArrays(gl.TRIANGLES,0,6); + const px=new Uint8Array(4); gl.readPixels(360,220,1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + const cnt=gl.getUniformLocation(m.program,'u_colorsCount'); const cv=new Int32Array(1); gl.getUniform(m.program,cnt,cv); + return { center:Array.from(px), count: cv[0] }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/setu2.mjs b/setu2.mjs new file mode 100644 index 0000000..198039e --- /dev/null +++ b/setu2.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(async ()=>{ + const mod = await import('/vendor/paper-shaders/index.js'); + const { ShaderMount, meshGradientFragmentShader, getShaderColorFromString } = mod; + const host = document.createElement('div'); host.style.cssText='width:300px;height:200px;position:relative'; + document.body.appendChild(host); + const colors = ['#7c5cff','#00c2c7','#ff5c8a','#ffb347'].map(getShaderColorFromString); + const m = new ShaderMount(host, meshGradientFragmentShader, {}, { preserveDrawingBuffer:true, antialias:true }, 1.0); + m.setUniforms({ u_colors: colors, u_colorsCount: colors.length, u_distortion:1.2, u_swirl:0.8 }); + const gl=m.gl, pr=m.program; + const fi=(n)=>{const l=gl.getUniformLocation(pr,n);const v=new Float32Array(1);gl.getUniform(pr,l,v);return v[0];}; + const f=(n)=>{const l=gl.getUniformLocation(pr,n);const v=new Float32Array(4);gl.getUniform(pr,l,v);return Array.from(v).map(x=>+x.toFixed(3));}; + return { u_swirl: fi('u_swirl'), u_colorsCount: fi('u_colorsCount'), u_color0: f('u_colors[0]') }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/shadow.mjs b/shadow.mjs new file mode 100644 index 0000000..cefb31e --- /dev/null +++ b/shadow.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1400}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +await p.evaluate(()=>document.querySelector('#sec-shader-grid')?.scrollIntoView()); +await new Promise(r=>setTimeout(r,800)); +const r = await p.evaluate(()=>{ + const shaderCard=document.querySelector('#sec-shader-grid .neu-card--shader'); + const plainCard=document.querySelector('#sec-shader-grid .neu-card:not(.neu-card--shader)'); + const cs=getComputedStyle(shaderCard); + const after=getComputedStyle(shaderCard,'::after'); + const plain=getComputedStyle(plainCard); + const countShadows=(s)=>s.split(/,(?![^()]*\))/).filter(x=>x.trim()).length; + return { + shaderBoxShadow: cs.boxShadow.slice(0,80)+'...', shaderShadowCount: countShadows(cs.boxShadow), + afterBoxShadow: after.boxShadow.slice(0,80)+'...', afterShadowCount: countShadows(after.boxShadow), + afterZ: after.zIndex, beforeZ: getComputedStyle(shaderCard,'::before').zIndex, + plainShadowCount: countShadows(plain.boxShadow), + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/shchk.mjs b/shchk.mjs new file mode 100644 index 0000000..43dcc12 --- /dev/null +++ b/shchk.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const info = await p.evaluate(()=>{ + const hosts=[...document.querySelectorAll('#sec-shader .card-shader-host')]; + return hosts.map(h=>{ + const cv=h.querySelector('canvas'); + return { sel:h.dataset.shader, hasCanvas:!!cv, w:cv?.width, h:cv?.height, mounted:h.dataset.mounted }; + }); +}); +// снимем пиксель чтобы убедиться что шейдер реально рисует (не пустой canvas) +const pix = await p.evaluate(()=>{ + const cv=document.querySelector('#sec-shader .card-shader-host canvas'); + if(!cv) return 'no-canvas'; + const gl=cv.getContext('webgl2')||cv.getContext('webgl'); + if(!gl) return 'no-gl'; + const px=new Uint8Array(4); gl.readPixels(Math.floor(cv.width/2),Math.floor(cv.height/2),1,1,gl.RGBA,gl.UNSIGNED_BYTE,px); + return Array.from(px).join(','); +}); +console.log(JSON.stringify({info, centerPixel:pix, errs}, null, 1)); +await b.close(); diff --git a/shclick.mjs b/shclick.mjs new file mode 100644 index 0000000..bf112b3 --- /dev/null +++ b/shclick.mjs @@ -0,0 +1,22 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +// подпишемся на клик по ссылке внутри страницы +await p.evaluate(()=>{ + window.__clicked=false; + document.querySelector('#sec-shader a.contact-card').addEventListener('click', ()=>{ window.__clicked=true; }, true); +}); +const link = await p.$('#sec-shader a.contact-card'); +const box = await link.boundingBox(); +await p.mouse.click(box.x+box.width/2, box.y+box.height/2); +await new Promise(r=>setTimeout(r,300)); +const clicked = await p.evaluate(()=>window.__clicked); +// hover тоже +await p.mouse.move(box.x+box.width/2, box.y+box.height/2); +await new Promise(r=>setTimeout(r,400)); +const hover = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-shader a.contact-card')).transform); +console.log(JSON.stringify({clicked, hover},null,1)); +await b.close(); diff --git a/shelem.mjs b/shelem.mjs new file mode 100644 index 0000000..12eb7bb --- /dev/null +++ b/shelem.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const card=document.querySelector('#sec-shader a.contact-card'); + const rect=card.getBoundingClientRect(); + const x=rect.x+rect.width/2, y=rect.y+rect.height/2; + const el=document.elementFromPoint(x,y); + const path=[]; + let e=el; while(e && path.length<5){ path.push(e.className||e.tagName); e=e.parentElement; } + // что у canvas + const cv=card.querySelector('canvas'); + return { + topEl: el.className||el.tagName, + path, + canvasPE: cv?getComputedStyle(cv).pointerEvents:'', canvasZ: cv?getComputedStyle(cv).zIndex:'', + cardZ: getComputedStyle(card).zIndex, cardPos: getComputedStyle(card).position, + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/shelem2.mjs b/shelem2.mjs new file mode 100644 index 0000000..c95d14b --- /dev/null +++ b/shelem2.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +await p.evaluate(()=>document.querySelector('#sec-shader').scrollIntoView()); +await new Promise(r=>setTimeout(r,400)); +await p.evaluate(()=>{ + window.__clicked=false; + const c=document.querySelector('#sec-shader a.contact-card'); + c.addEventListener('click', ()=>{ window.__clicked=true; }, true); +}); +const box = await p.$eval('#sec-shader a.contact-card', el=>{const r=el.getBoundingClientRect();return {x:r.x,y:r.y,w:r.width,h:r.height};}); +const x=box.x+box.w/2, y=box.y+box.h/2; +await p.mouse.move(x,y); +await new Promise(r=>setTimeout(r,400)); +const hover = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-shader a.contact-card')).transform); +await p.mouse.click(x,y); +await new Promise(r=>setTimeout(r,300)); +const clicked = await p.evaluate(()=>window.__clicked); +const top = await p.evaluate((x,y)=>{const e=document.elementFromPoint(x,y);return e?(e.className||e.tagName):'null';}, x, y); +console.log(JSON.stringify({hover, clicked, topElAtCenter: top},null,1)); +await b.close(); diff --git a/shelem3.mjs b/shelem3.mjs new file mode 100644 index 0000000..6191fa6 --- /dev/null +++ b/shelem3.mjs @@ -0,0 +1,24 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +const meta = await p.evaluate(()=>{ + const c=document.querySelector('#sec-shader a.contact-card'); + const r=c.getBoundingClientRect(); + return { docH: document.body.scrollHeight, viewH: window.innerHeight, + cardTop: r.top, cardBottom: r.bottom, cardRect: {x:r.x,y:r.y,w:r.width,h:r.height} }; +}); +console.log('before scroll', JSON.stringify(meta)); +await p.evaluate(()=>document.querySelector('#sec-shader a.contact-card').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +const after = await p.evaluate(()=>{ + const c=document.querySelector('#sec-shader a.contact-card'); + const r=c.getBoundingClientRect(); + const cx=r.x+r.width/2, cy=r.y+r.height/2; + const e=document.elementFromPoint(cx,cy); + return { cardTop: r.top, cardBottom: r.bottom, topEl: e?(e.className||e.tagName):'null', cx, cy }; +}); +console.log('after scroll', JSON.stringify(after)); +await b.close(); diff --git a/shfin.mjs b/shfin.mjs new file mode 100644 index 0000000..5b80fbe --- /dev/null +++ b/shfin.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const card=document.querySelector('#sec-shader .card-shader-host .contact-card'); + const cs=getComputedStyle(card); + const wrap=document.querySelector('#sec-shader .card-shader'); + return { boxShadow: cs.boxShadow.slice(0,40), glow: cs.backgroundImage.slice(0,30), + shaderLayer: getComputedStyle(wrap).position, shaderZ: getComputedStyle(wrap).zIndex, + contentZ: getComputedStyle(card.querySelector('.contact-card__body')).zIndex }; +}); +console.log(JSON.stringify({r, errs},null,1)); +await b.close(); diff --git a/shfinal.mjs b/shfinal.mjs new file mode 100644 index 0000000..ab5bf6a --- /dev/null +++ b/shfinal.mjs @@ -0,0 +1,23 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +const link = await p.$('#sec-shader a.contact-card'); +await link.hover(); +await new Promise(r=>setTimeout(r,400)); +const hover = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-shader a.contact-card')).transform); +let popup='none'; +p.once('popup', ()=>{ popup='popup'; }); +await link.click({delay:50}); +await new Promise(r=>setTimeout(r,300)); +const geo = await p.evaluate(()=>{ + const li=document.querySelector('#sec-shader li'); const card=li.querySelector('.contact-card'); + const cv=card.querySelector('canvas'); + return { liH:Math.round(li.getBoundingClientRect().height), cardH:Math.round(card.getBoundingClientRect().height), + canvasInCard: card.contains(cv), canvasPE: cv?getComputedStyle(cv).pointerEvents:'none' }; +}); +console.log(JSON.stringify({hover, popup, geo, errs},null,1)); +await b.close(); diff --git a/shfix.mjs b/shfix.mjs new file mode 100644 index 0000000..4d69513 --- /dev/null +++ b/shfix.mjs @@ -0,0 +1,26 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const info = await p.evaluate(()=>{ + const host=document.querySelector('#sec-shader .card-shader-host'); + const li=host; const card=host.querySelector('.contact-card'); const canvas=host.querySelector('canvas'); + const lr=getComputedStyle(li), cr=getComputedStyle(card), cvr=getComputedStyle(canvas); + return { + liDisplay: lr.display, liPosition: lr.position, liW: li.getBoundingClientRect().width, + cardW: card.getBoundingClientRect().width, cardH: card.getBoundingClientRect().height, + canvasPointer: cvr.pointerEvents, canvasZ: cvr.zIndex, canvasInset: cvr.position, + cardPointer: cr.pointerEvents, + }; +}); +console.log(JSON.stringify(info,null,1)); +// тест клика по ссылке +const link = await p.$('#sec-shader a.contact-card'); +const box = await link.boundingBox(); +await p.mouse.click(box.x+box.width/2, box.y+box.height/2); +await new Promise(r=>setTimeout(r,300)); +const url = p.url(); +console.log('after click url:', url, '(same page expected, external link opens new tab)'); +await b.close(); diff --git a/shlog.mjs b/shlog.mjs new file mode 100644 index 0000000..14a5bca --- /dev/null +++ b/shlog.mjs @@ -0,0 +1,21 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +const logs=[]; +p.on('console', m=>logs.push('['+m.type()+'] '+m.text())); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const gl=m.gl; + // проверим компиляцию шейдеров напрямую + const vs=gl.getParameter(gl.VERTEX_SHADER); + // считаем context lost + const lost = gl.isContextLost(); + // размер drawing buffer + const dbw=gl.drawingBufferWidth, dbh=gl.drawingBufferHeight; + return { contextLost: lost, dbW: dbw, dbH: dbh, glError: gl.getError() }; +}); +console.log('R', JSON.stringify(r)); +console.log('LOGS:\n'+logs.slice(0,15).join('\n')); +await b.close(); diff --git a/shot.mjs b/shot.mjs new file mode 100644 index 0000000..13237d1 --- /dev/null +++ b/shot.mjs @@ -0,0 +1,12 @@ +import puppeteer from 'puppeteer'; +import fs from 'fs'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1500,deviceScaleFactor:1}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1800)); +const el = await p.$('#sec-shader-grid'); +const box = await el.boundingBox(); +await el.screenshot({ path: '/tmp/matrix.png' }); +console.log('saved', box?.width, box?.height); +await b.close(); diff --git a/shot_tmp.js b/shot_tmp.js new file mode 100644 index 0000000..50c9c02 --- /dev/null +++ b/shot_tmp.js @@ -0,0 +1,14 @@ +const puppeteer = require('puppeteer'); +(async () => { + const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox','--disable-setuid-sandbox'] }); + const page = await browser.newPage(); + await page.setViewport({ width: 1278, height: 900, deviceScaleFactor: 1 }); + await page.goto('http://localhost:4175/cases/ai-platform-primo-rpa/', { waitUntil: 'networkidle0' }); + await page.waitForSelector('[data-mini-projects-list]'); + const el = await page.$('[data-mini-projects-list]'); + await el.evaluate(e => e.scrollIntoView({block:'center'})); + await new Promise(r => setTimeout(r, 600)); + await el.screenshot({ path: '/tmp/related_1278.png' }); + await browser.close(); + console.log('shot saved'); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/shotcmp.mjs b/shotcmp.mjs new file mode 100644 index 0000000..e434031 --- /dev/null +++ b/shotcmp.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +import crypto from 'crypto'; +import fs from 'fs'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +await p.evaluate(()=>document.querySelector('[data-shader-demo]').scrollIntoView({block:'center'})); +await new Promise(r=>setTimeout(r,500)); +const h1 = await p.screenshot({encoding:'binary'}); await fs.promises.writeFile('/tmp/s1.png', h1); +await new Promise(r=>setTimeout(r,1500)); +const h2 = await p.screenshot({encoding:'binary'}); await fs.promises.writeFile('/tmp/s2.png', h2); +const m1=crypto.createHash('md5').update(h1).digest('hex'); const m2=crypto.createHash('md5').update(h2).digest('hex'); +console.log('bytes', h1.length, h2.length, 'md5equal', m1===m2); +await b.close(); diff --git a/shreal.mjs b/shreal.mjs new file mode 100644 index 0000000..b26c5d4 --- /dev/null +++ b/shreal.mjs @@ -0,0 +1,16 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1000)); +await p.evaluate(()=>{ window.__clicked=false; const c=document.querySelector('#sec-shader a.contact-card'); c.addEventListener('click',()=>window.__clicked=true,true); }); +const link = await p.$('#sec-shader a.contact-card'); +await link.hover(); +await new Promise(r=>setTimeout(r,400)); +const hover = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-shader a.contact-card')).transform); +await link.click({delay:50}); +await new Promise(r=>setTimeout(r,300)); +const clicked = await p.evaluate(()=>window.__clicked); +console.log(JSON.stringify({hover, clicked},null,1)); +await b.close(); diff --git a/shver.mjs b/shver.mjs new file mode 100644 index 0000000..6a621f8 --- /dev/null +++ b/shver.mjs @@ -0,0 +1,32 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1000}); +const errs=[]; p.on('console',m=>{if(m.type()==='error')errs.push(m.text())}); p.on('pageerror',e=>errs.push('PE '+e.message)); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1500)); +const geo = await p.evaluate(()=>{ + const li=document.querySelector('#sec-shader li'); + const card=li.querySelector('.contact-card'); + const canvas=li.querySelector('canvas'); + return { + liH: Math.round(li.getBoundingClientRect().height), + cardH: Math.round(card.getBoundingClientRect().height), + canvasInCard: canvas ? card.contains(canvas) : false, + canvasPE: canvas ? getComputedStyle(canvas).pointerEvents : 'no-canvas', + cardPE: getComputedStyle(card).pointerEvents, + }; +}); +// hover + click test +const link = await p.$('#sec-shader a.contact-card'); +const box = await link.boundingBox(); +await p.mouse.move(box.x+box.width/2, box.y+box.height/2); +await new Promise(r=>setTimeout(r,200)); +const hoverTransform = await p.evaluate(()=>getComputedStyle(document.querySelector('#sec-shader a.contact-card')).transform); +// click -> should open new tab (we block navigation, just check no error) +const newTarget = await Promise.race([ + new Promise(res=>p.once('popup', t=>res('popup'))), + new Promise(res=>setTimeout(()=>res('none'), 800)) +]); +console.log(JSON.stringify({geo, hoverTransform, newTarget, errs}, null, 1)); +await b.close(); diff --git a/shvis.mjs b/shvis.mjs new file mode 100644 index 0000000..59decf7 --- /dev/null +++ b/shvis.mjs @@ -0,0 +1,17 @@ +import puppeteer from 'puppeteer'; +import sharp from 'sharp'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1280,height:1400,deviceScaleFactor:1}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,2000)); +const el = await p.$('#sec-shader'); +await el.screenshot({ path: '/tmp/shader-sec.png' }); +// analyse average + stddev of color +const {data,info} = await sharp('/tmp/shader-sec.png').raw().toBuffer({resolveWithObject:true}); +let n=info.width*info.height, rs=0,gs=0,bs=0; const cols=new Set(); +for(let i=0;i<data.length;i+=3){ rs+=data[i]; gs+=data[i+1]; bs+=data[i+2]; + if(i%900===0) cols.add(`${data[i]>>4},${data[i+1]>>4},${data[i+2]>>4}`); } +console.log('avg rgb', Math.round(rs/n),Math.round(gs/n),Math.round(bs/n)); +console.log('distinct coarse colors sampled:', cols.size); +await b.close(); diff --git a/size.mjs b/size.mjs new file mode 100644 index 0000000..b937708 --- /dev/null +++ b/size.mjs @@ -0,0 +1,19 @@ +import puppeteer from 'puppeteer'; +const b = await puppeteer.launch({ args:['--no-sandbox','--disable-setuid-sandbox','--use-gl=angle','--enable-unsafe-swiftshader','--ignore-gpu-blocklist'] }); +const p = await b.newPage(); +await p.setViewport({width:1100,height:1300}); +await p.goto('http://10.200.201.3:4175/_lab/neu-card.html',{waitUntil:'load',timeout:60000}); +await new Promise(r=>setTimeout(r,1200)); +const r = await p.evaluate(()=>{ + const m=document.querySelector('[data-shader-demo] .card-shader').paperShaderMount; + const wrap=m.parentElement; + return { + canvasW:m.canvasElement.width, canvasH:m.canvasElement.height, + canvasCSS: getComputedStyle(m.canvasElement).width+'x'+getComputedStyle(m.canvasElement).height, + parentW:m.parentWidth, parentH:m.parentHeight, + wrapRect: (()=>{const r=wrap.getBoundingClientRect();return {w:r.width,h:r.height};})(), + hasRO: !!m.resizeObserver, + }; +}); +console.log(JSON.stringify(r,null,1)); +await b.close(); diff --git a/src/i18n/en.json b/src/i18n/en.json new file mode 100644 index 0000000..dfd4b4e --- /dev/null +++ b/src/i18n/en.json @@ -0,0 +1,20 @@ +{ + "nav.aria": "Page sections", + "nav.projects": "Portfolio", + "nav.experience": "Experience", + "nav.skills": "Skills", + "nav.miniProjects": "Activity", + "nav.contact": "Contact", + "skip.home": "Skip to main content", + "skip.case": "Skip to case content", + "logo.aria": "Home", + "logo.text": "NM", + "more.sections": "More sections", + "back.home": "Home", + "footer.credit": "Website design and development — Nikolay Mamadaev", + "footer.designSystem": "Design System", + "footer.cookiePolicy": "Cookie policy", + "lang.aria": "Choose language", + "lang.ru": "Русский", + "lang.en": "English" +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json new file mode 100644 index 0000000..b665c17 --- /dev/null +++ b/src/i18n/ru.json @@ -0,0 +1,20 @@ +{ + "nav.aria": "Разделы страницы", + "nav.projects": "Портфолио", + "nav.experience": "Опыт работы", + "nav.skills": "Навыки", + "nav.miniProjects": "Активность", + "nav.contact": "Контакты", + "skip.home": "К основному содержимому", + "skip.case": "К содержимому кейса", + "logo.aria": "На главную", + "logo.text": "НМ", + "more.sections": "Ещё разделы", + "back.home": "На главную", + "footer.credit": "Дизайн и разработка сайта — Николай Мамадаев", + "footer.designSystem": "Design System", + "footer.cookiePolicy": "Политика cookie", + "lang.aria": "Выбор языка", + "lang.ru": "Русский", + "lang.en": "English" +} diff --git a/src/pages/cases/ai-assistant-figma.en.html b/src/pages/cases/ai-assistant-figma.en.html new file mode 100644 index 0000000..4db2577 --- /dev/null +++ b/src/pages/cases/ai-assistant-figma.en.html @@ -0,0 +1,133 @@ +<!DOCTYPE html> +<html lang="ru" data-theme="dark" data-radius="medium" data-font="merriweather"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> + <meta + name="description" + content="Case Study: AI Assistant—a Figma plugin with AI features." + /> + <title>AI Assistant — Figma Plugin — Case Study + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Figma · Plugin

+

AI Assistant

+

A Figma plugin with AI features

+ +

+ +

+ +

Context

+

+ +

+ +

Task

+

+ +

+ +

Solution

+

+ +

+ +

Result

+

+ +

+
+
+ + {{> mini-projects-related}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + + diff --git a/src/pages/cases/ai-assistant-figma.html b/src/pages/cases/ai-assistant-figma.html new file mode 100644 index 0000000..0c454ca --- /dev/null +++ b/src/pages/cases/ai-assistant-figma.html @@ -0,0 +1,133 @@ + + + + + + + AI assistant — Плагин для Figma — кейс + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Figma · Plugin

+

AI assistant

+

Плагин для Figma с AI-функциями

+ +

+ +

+ +

Контекст

+

+ +

+ +

Задача

+

+ +

+ +

Решение

+

+ +

+ +

Результат

+

+ +

+
+
+ + {{> mini-projects-related}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + + diff --git a/src/pages/cases/ai-platform-primo-rpa-2.en.html b/src/pages/cases/ai-platform-primo-rpa-2.en.html new file mode 100644 index 0000000..b6db16a --- /dev/null +++ b/src/pages/cases/ai-platform-primo-rpa-2.en.html @@ -0,0 +1,422 @@ + + + + + + + AI Server in Primo RPA — Case Study (Draft 2) + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Product Design · AI

+

User Interface Development for the Primo RPA AI Server

+

+ Intelligent document
processing platform + and AI agent orchestration +

+ +
+

Overview

+

+ A service was launched within the company that significantly improved the workflow for text recognition models on documents: loading a dataset from company documents, field tagging, model training, and the subsequent use of the model in other products. This approach provided higher recognition quality and met security requirements—data remained within the company’s network. As work on the product progressed, functionality was added to fine-tune the base neural networks for the company’s specific tasks and to work with skills such as summarization, extraction, generation, and other scenarios. +

+

+ I was invited to join the project as a UX/UI designer; prior to that, the developers had been building the interface on their own. As a result of this work, the service’s barrier to entry was lowered—significantly more people could use the product without specialized training in AI and OCR. +

+ +
+ +
+

Before and After

+

+ The service was built using Ant Design on top of React with the Hope UI visual theme for Bootstrap. The company already had several web products and wanted to unify and refresh the user interface, so the development team relied on components from those systems. +

+ +

+ I immersed myself in the product and, together with the team, analyzed all the functionality in detail. +

+ +

+ I mapped out user flows based on current scenarios: these showed the team where confusion arose in the interface and user journeys. Based on this, we redesigned the configuration processes themselves: we automated and hid some functions to reduce the risk of errors and eliminate unnecessary routine tasks. +

+

+ The new approach changed both the perception of the product and the setup process. The interface guides users through the setup steps without disrupting their usual workflows: a fully linear wizard isn’t feasible here—users still need to go back and refine settings to ensure high-quality results. +

+

Questions for the team regarding the current status

+

The answers will help quantify the impact of the implementation with facts and figures.

+
    +
  • Has the number of support requests decreased?
  • +
  • Are users able to complete project setup on their own from start to finish?
  • +
  • Does the new interface allow users to create new types of projects based on existing patterns?
  • +
+
+ +
+

Interface Patterns

+

+ Below are the key areas of the product: how they support learning and model-working scenarios, and how they differ from the previous approach. +

+ +

Field Mapping

+

+ Mapping links document fields to the target data structure. It was important to make the relationship visual and reversible so that users don’t lose context when making edits. +

+
+
+ +
+
Field Mapping in the Interface
+
+ +

Dataset, Training, and Quality Control

+

+ The dataset editor, training launch, and quality screens form a single mental path: data preparation → training → result verification. +

+ +
+ Dataset Editor — Current Version of the Interface +
Dataset Editor—Current Version
+
+
+ Dataset Editor — Previous Version for Comparison +
Previous version of the editor
+
+
+ Model Training Screen Using a Dataset +
Training on a dataset
+
+
+ Quality Control of the Model After Training +
Quality and result verification
+
+ +

Inference in the project

+

+ Special Focus: Running and Monitoring the Model Within the Project: The user must understand the status of the inference run and stay in sync with the rest of the settings. +

+
+ Model inference interface within the project +
Inference in the project
+
+
+
+
+ Inference in the project — full-screen interface + +
+
+ +
+
+
Full vertical screenshot of the inference screen
+
+
+ Previous version of the inference screen in the project +
Previous version of inference
+
+ + + +

Full-Screen Mode and Import

+

+ This isn’t about “long tasks,” but rather about processes in a separate context: some block work on the project until they’re finished, while others run in parallel in a separate window, allowing you to continue configuring project settings. We moved this functionality to a separate window because there were several entry points for these tasks, and a standard modal wasn’t enough—you had to complete a separate process elsewhere and then return to the project settings. This approach reduced the amount of development and testing required. +

+

+ Below are three full-screen canvas options: import in a separate window, panel mode, and the minimalist “zen” mode. +

+
+ A modal window for importing a dataset on a separate screen +
Import in a modal window
+
+
+ Full-screen mode with side panels +
Full-screen mode with panels
+
+
+ Full-screen mode without distracting panels +
Minimal mode without unnecessary chrome
+
+ +

Future Development

+

+ Several major items remained on the roadmap: scenarios related to the LLM (lists, statuses, actions), a step-by-step training wizard with progress tracking and undo functionality, and a full-featured OCR engine with region marking and on-page quality previews. These were not included in the minimum set for the interface’s first production release, but the established patterns should accommodate them without major rework. +

+
+ +
+

Process and Handoff

+

+ Work proceeded through regular synchronization with the development team: mockups, specifications, and a phased handoff to the front end. After I left the project, the team looked for the next designer and structured the onboarding process based on the existing design assets and layout guidelines. +

+ +

Questions for the Next Designer

+
    +
  • What turned out to be unclear during the first two weeks on the project?
  • +
  • Which artifacts and handover guidelines proved most useful?
  • +
  • What had to be redesigned and why.
  • +
  • How would you describe the current state of UX in three sentences?
  • +
+
+ +
+

Conclusion

+

+ The AI Server interface in Primo RPA was transformed from an “engineer-oriented developer interface” into a product with intuitive configuration logic and user guidance. The focus on user flows and routine automation reduced the implementation burden and paved the way for broader adoption within the company and among clients. +

+
+
+
+ + {{> projects-related}}{{> projects-section}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + diff --git a/src/pages/cases/ai-platform-primo-rpa-2.html b/src/pages/cases/ai-platform-primo-rpa-2.html index 5aa2361..916f489 100644 --- a/src/pages/cases/ai-platform-primo-rpa-2.html +++ b/src/pages/cases/ai-platform-primo-rpa-2.html @@ -191,12 +191,12 @@ controls preload="metadata" playsinline - poster="../assets/ai-platform-primo-rpa/fields-maping.webp" + poster="{{assetPrefix}}assets/ai-platform-primo-rpa/fields-maping.webp" data-case-video-player style="display: block; width: 100%; height: auto; margin: 0" > - - + + Ваш браузер не поддерживает видео. @@ -217,7 +217,7 @@
Редактор датасета — текущая версия интерфейса
Редактор датасета — предыдущая версия для сравнения
Экран обучения модели по датасету
Контроль качества модели после обучения
Интерфейс inference модели внутри проекта
Inference в проекте — полный экран интерфейса
Предыдущая версия экрана inference в проекте
Модальное окно импорта датасета на отдельном экране
Полноэкранный режим с боковыми панелями
Полноэкранный режим без отвлекающих панелейДальнейшее развитие

- В дорожной карте оставались отдельные крупные куски: сценарии вокруг LLM (списки, статусы, действия), пошаговый мастер обучения с прогрессом и отменой, а также полноценный контур OCR с разметкой регионов и превью качества на странице. Они не входили в минимальный набор для первого промышленного релиза интерфейса, но заложенные паттерны должны их принять без ломки навигации. + В дорожной карте оставались отдельные крупные куски: сценарии вокруг LLM (списки, статусы, действия), пошаговый мастер обучения с прогрессом и отменой, а также полноценный контур OCR с разметкой регионов и превью качества на странице. Они не входили в минимальный набор для первого промышленного релиза интерфейса, но заложенные паттерны должны их принять без больших переделок.

@@ -412,9 +412,8 @@
- {{> projects-section-mp}}{{> projects-section}} + {{> projects-related}}{{> projects-section}} {{> contact-section}} -{{> footer-spacer}} {{> footer}} {{> cookie-notice}} {{> nav-more-floating}} diff --git a/src/pages/cases/ai-platform-primo-rpa-v2.en.html b/src/pages/cases/ai-platform-primo-rpa-v2.en.html new file mode 100644 index 0000000..6bfd9e5 --- /dev/null +++ b/src/pages/cases/ai-platform-primo-rpa-v2.en.html @@ -0,0 +1,551 @@ + + + + + + + AI Server in Primo RPA — Case Study + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} + {{> fab-stack}} +
+
+

Product Design · AI

+

Simplified Primo RPA AI Server setup: customers configure the AI service without an implementation team

+

+ A platform for intelligent document
+ processing and AI agent orchestration. Requests to the implementation team have dropped by 40%, and the time required to set up the first project has been reduced from ~3 days to ~4 hours. +

+ +
+ The updated dataset markup editor—the service's main screen following the redesign +
+ +
+
Product
+
AI Server. Neural networks within the company’s internal network, configured via a web interface
+
Role
+
UX/UI Designer (the only one on the project)
+
Objective
+
Lower the barrier to entry so that the client can configure the service themselves
+
Stack
+
Ant Design + React, styled with Hope UI
+
Format
+
Redesign of a live product; releases continued uninterrupted
+
+ +
+

Only experts could handle the configuration. This slowed down sales

+

+ The AI Server is deployed on the client’s internal servers. The product was actively tested, and the feedback revealed an unpleasant truth: only experts and the Primo RPA implementation team could configure it. Every new client consumed hours of technical support. The product was losing its appeal and didn’t scale well. +

+ +

+ I started by getting to know the team and the configuration experts. I divided the scenarios into primary and secondary ones, created a visual representation of user behavior, and worked with the development team to identify areas where the interface could be simplified. I had already worked with AI and similar web platforms, so I quickly got up to speed on the subject matter and was able to speak the same language as the development team. +

+
+ +
+

Constraints

+
    +
  • Customers were already using the product. It wasn’t possible to halt development and completely rebuild the interface.
  • +
  • Changes had to be incorporated into the release plan; no one allocated time for a separate “redesign sprint.”
  • +
  • Strict tech stack: Ant Design on top of React, styled after Hope UI. We only created new components as a last resort; first, we made the most of the existing library.
  • +
+
+ + + +
+

Dataset Editor: the screen where users spend most of their time

+

+ This is where users spend most of their time. Annotating fields on hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor didn’t fit the overall style, looked cluttered, and made working with fields confusing. +

+

+ I broke the editor down into microscenarios, organized them by priority and interdependencies. Together with the team, we designed a smoother and more intuitive user experience, reserved space for future features, and aligned the design with the service’s overall style. +

+
+ Dataset Markup Editor — the document being marked up in the center, with field and tool panels on either side +
Dataset markup editor—the document being marked up is in the center, with field and tool panels on either side
+
+

+ The page became symmetrical. Almost all the space is devoted to the image being annotated, and actions are grouped by panels: the desired function can be found quickly. +

+
+ A schematic overview of the editor's layout—the central workspace and side panels +
Schematic layout of the editor—central workspace and side panels, default mode
+
+

+ For advanced users, I’ve added a “Full Screen” mode. The side panels collapse, and you work using keyboard shortcuts and the bottom panel. This is a lifesaver on small monitors. +

+
+ Full-screen editor mode with side panels open +
Full-screen editor mode with side panels expanded
+
+
+ Full-screen mode without panels—only the document being marked up and the bottom panel +
Full-screen mode without panels—only the document being edited and the bottom toolbar are visible on the screen
+
+
+ Four Full-Screen Mode Frame Options +
Four full-screen layout options: both panels, no panels, left panel only, right panel only
+
+
+ Bottom toolbar — switch between images and quick markup actions +
Bottom toolbar—switch between images and quick markup actions
+
+
+ +
+

Dozens of fields on a single scan, without sacrificing document space

+

+ Related task: Configure unique field settings for the dataset. A large area is needed for the document scan, with dozens of fields, each having its own types and dependent settings. +

+
+
+ +
+
Field mapping prototype—the user selects a field on the document scan and configures its parameters in a compact panel
+
+

+ The interface turned out to be compact. The mechanics aren’t immediately obvious at first glance, but after a short learning curve, working with it becomes quick and comfortable. +

+
+ +
+

Training cards: a pattern that has spread throughout the product

+

+ After annotating the dataset, the model undergoes fine-tuning. We analyzed all user actions and explored several page layouts. We settled on a card feed. +

+
+ Model Training Section — Vertical Card Strip +
The model training section is a vertical card feed, with a new training setup card at the top
+
+

+ At the beginning of the feed is a configuration card: the annotation results for the active dataset, parameters, and the option to start fine-tuning. +

+
+ Training Configuration Card — Dataset Quality Matrix and Run Parameters Block +
Training configuration card—dataset quality matrix and launch parameters section
+
+ +

+ A separate card appears for each running process. You can start multiple processes with different parameters and compare which configuration performed better. +

+
+ Active Learning Process Card — Status, Progress, and Metrics +
Card for a running training process—status, progress, and metrics organized into logical blocks
+
+
+ Learning Process Card Template +
The framework of a training process card—a layout of information blocks and actions
+
+

+ Cards can be collapsed; the title and status remain visible in the header. +

+
+ A collapsed training card—a single line showing the name and status of the process +
Collapsed training card—a single line showing the process name and status
+
+

+ Patterns from these cards were later applied to other sections of the product. It became easier to maintain a consistent style. +

+
+ +
+

Long processes—in a separate tab

+

+ Some tasks run in parallel with other work. For such scenarios, a modal window opens on a separate page. +

+ +
+ Importing a dataset in a modal window on a separate page +
Importing a dataset in a modal window on a separate page—a full-screen upload form
+
+

+ Meanwhile, a placeholder with a button to navigate to the process remains on the project page. +

+
+ Active Import Placeholder Within a Project +
Placeholder for active import within the project—progress indicator and "Go to Process" button
+
+
+ +
+

What I Decided Not to Do, and Why

+
    +
  • I chose not to disable buttons on individual pages during active processes. A modal on a separate page is technically simpler, and progress is visible in the adjacent tab.
  • +
  • I didn’t create new components except in extreme cases. The ready-made Ant Design library sped up development and kept the interface consistent.
  • +
  • I didn’t remove the editor’s side panels entirely; instead, I made them collapsible. Beginners need on-screen hints, while advanced users need as much space as possible for their document.
  • +
  • I didn’t set up a separate design system. The product already had a framework, so I passed links to the existing user flows in Figma to the development team.
  • +
+

Handoff to Development

+

+ Once a week, we discussed the design with the team: the task list, priorities, and deadlines aligned with release plans. I went over the details of the mockups one-on-one with the front-end developer and provided feedback after the layout was completed to ensure the result matched the intended behavior. +

+
+ +
+

Result

+ +
    +
  • Requests to the implementation team during the setup phase decreased by 40%. Clients complete the basic setup on their own.
  • +
  • 7 out of 10 new clients set up their first project without technical support. Before the redesign, it was 1 out of 10.
  • +
  • Setting up the first project: from ~3 days to ~4 hours.
  • +
  • Over 30 screens were redesigned: navigation, dataset editor, model training, and data import.
  • +
  • We established design patterns that allow functionality to be expanded without major rework.
  • +
  • The company hired a second designer. I brought her up to speed and oversaw the results; after she became familiar with the project, it was entirely transferred to her responsibility.
  • +
+
+ +
+

Conclusions

+
    +
  • The rigid framework turned out to be an accelerator rather than a limitation: the ready-made component library allowed us to focus on scenarios rather than pixels.
  • +
  • The most valuable insights came from configuration experts. Next time, I would involve the implementation team even earlier.
  • +
  • Handing the project over to a new designer confirmed that the established patterns work and can be explained and maintained without the original author.
  • +
+
+
+
+ + {{> projects-related}}{{> projects-section}} + {{> contact-section}} + {{> footer}} + {{> cookie-notice}} + {{> nav-more-floating}} + + diff --git a/src/pages/cases/ai-platform-primo-rpa-v2.html b/src/pages/cases/ai-platform-primo-rpa-v2.html new file mode 100644 index 0000000..113665f --- /dev/null +++ b/src/pages/cases/ai-platform-primo-rpa-v2.html @@ -0,0 +1,551 @@ + + + + + + + AI Server в Primo RPA — кейс + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} + {{> fab-stack}} +
+
+

Product Design · AI

+

Упростил настройку Primo RPA AI Server: клиенты настраивают ИИ-сервис без команды внедрения

+

+ Платформа интеллектуальной обработки документов
+ и оркестрации ИИ-агентов. Обращения к команде внедрения упали на 40%, настройка первого проекта ужалась с ~3 дней до ~4 часов. +

+ +
+ Обновлённый редактор разметки датасета — главный экран сервиса после редизайна +
+ +
+
Продукт
+
AI Server. Нейросети во внутреннем контуре компании, настраивается через веб-интерфейс
+
Роль
+
UX/UI-дизайнер (единственный на проекте)
+
Задача
+
Снизить порог входа, чтобы клиент настраивал сервис сам
+
Стек
+
Ant Design + React, стилизация Hope UI
+
Формат
+
Редизайн живого продукта, релизы не останавливались
+
+ +
+

С настройкой справлялись только эксперты. Это тормозило продажи

+

+ AI Server разворачивается на внутренних серверах клиентов. Продукт активно тестировали, и обратная связь показала неприятное: настроить его могли только эксперты и команда внедрения Primo RPA. Каждый новый клиент съедал часы техподдержки. Продукт терял привлекательность и плохо масштабировался. +

+ +

+ Начал со знакомства с командой и экспертами по настройке. Разделил сценарии на главные и вспомогательные, собрал визуализацию того, как ведёт себя пользователь, и вместе с разработкой искал, где интерфейс можно упростить. Я уже работал с ИИ и похожими веб-платформами, поэтому быстро разобрался в домене и говорил с разработкой на одном языке. +

+
+ +
+

Ограничения

+
    +
  • Продуктом уже пользовались клиенты. Остановить разработку и пересобрать интерфейс целиком было нельзя.
  • +
  • Изменения приходилось вписывать в план релизов, отдельного «редизайн-спринта» никто не выделял.
  • +
  • Жёсткий стек: Ant Design под React со стилизацией под Hope UI. Новые компоненты рисовать только в крайнем случае, сначала выжимаем всё из готовой библиотеки.
  • +
+
+ + + +
+

Редактор датасета: экран, где пользователи живут

+

+ Здесь проводят больше всего времени. Разметка полей на сотнях изображений — работа монотонная, но именно от неё зависит качество данных для обучения модели. Прежний редактор выбивался из общего стиля, выглядел перегруженно и путал в работе с полями. +

+

+ Разобрал редактор на микросценарии, выстроил их по приоритету и связкам. Вместе с командой спроектировали поведение плавнее и понятнее, заложили место под будущий функционал, дизайн привели к общей стилистике сервиса. +

+
+ Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам +
Редактор разметки датасета — размечаемый документ в центре, панели полей и инструментов по бокам
+
+

+ Страница стала симметричной. Почти всё пространство уходит на размечаемое изображение, действия сгруппированы по панелям: нужная функция ищется быстро. +

+
+ Схематичный каркас редактора — центральная рабочая область и боковые панели +
Схематичный каркас редактора — центральная рабочая область и боковые панели, режим по умолчанию
+
+

+ Для продвинутых пользователей добавил режим «Во весь экран». Боковые панели сворачиваются, работа идёт через горячие клавиши и нижнюю панель. На небольших мониторах это спасает. +

+
+ Полноэкранный режим редактора с раскрытыми боковыми панелями +
Полноэкранный режим редактора с раскрытыми боковыми панелями
+
+
+ Полноэкранный режим без панелей — только размечаемый документ и нижняя панель +
Полноэкранный режим без панелей — на экране только размечаемый документ и нижняя панель инструментов
+
+
+ Четыре варианта каркаса полноэкранного режима +
Четыре варианта каркаса полноэкранного режима — обе панели, без панелей, только левая, только правая
+
+
+ Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки +
Нижняя панель инструментов — переключение между изображениями и быстрые действия разметки
+
+
+ +
+

Десятки полей на одном скане, и место под документ не страдает

+

+ Смежная задача: настроить уникальные параметры полей датасета. Большая область нужна под скан документа, полей десятки, у каждого свои типы и зависимые параметры. +

+
+
+ +
+
Прототип сопоставления полей — пользователь выбирает поле на скане документа и настраивает его параметры в компактной панели
+
+

+ Интерфейс вышел компактным. Механика не считывается с первого взгляда, зато после короткого обучения работать в нём быстро и комфортно. +

+
+ +
+

Карточки обучения: паттерн, который разошёлся по всему продукту

+

+ После разметки датасета модель дообучают (fine-tuning). Разобрали все действия пользователя, перебрали несколько компоновок страницы. Остановились на ленте из карточек. +

+
+ Раздел обучения моделей — вертикальная лента карточек +
Раздел обучения моделей — вертикальная лента карточек, сверху карточка настройки нового обучения
+
+

+ В начале ленты карточка настройки: результат разметки активного датасета, параметры, запуск fine-tuning. +

+
+ Карточка настройки обучения — матрица качества датасета и блок параметров запуска +
Карточка настройки обучения — матрица качества датасета и блок параметров запуска
+
+ +

+ Под каждый запущенный процесс появляется своя карточка. Можно завести несколько процессов с разными параметрами и сравнить, какая конфигурация отработала лучше. +

+
+ Карточка запущенного процесса обучения — статус, прогресс и метрики +
Карточка запущенного процесса обучения — статус, прогресс и метрики, разнесённые по логическим блокам
+
+
+ Каркас карточки процесса обучения +
Каркас карточки процесса обучения — схема расположения информационных блоков и действий
+
+

+ Карточки сворачиваются, в шапке остаются название и статус. +

+
+ Свёрнутая карточка обучения — одна строка с названием и статусом процесса +
Свёрнутая карточка обучения — одна строка с названием и статусом процесса
+
+

+ Паттерны из этих карточек потом разошлись по другим разделам продукта. Единый стиль стало проще держать. +

+
+ +
+

Долгие процессы — в соседнюю вкладку

+

+ Часть задач идёт параллельно с остальной работой. Для таких сценариев модальное окно открывается на отдельной странице. +

+ +
+ Импорт датасета в модальном окне на отдельной странице +
Импорт датасета в модальном окне на отдельной странице — форма загрузки на весь экран
+
+

+ На странице проекта в это время висит заглушка с кнопкой перехода к процессу. +

+
+ Заглушка активного импорта внутри проекта +
Заглушка активного импорта внутри проекта — индикатор процесса и кнопка перехода
+
+
+ +
+

Что я решил не делать, и почему

+
    +
  • Не стал блокировать кнопки на отдельных страницах во время активных процессов. Модалка на отдельной странице технически проще, а прогресс виден в соседней вкладке.
  • +
  • Не рисовал новые компоненты, кроме крайних случаев. Готовая библиотека Ant Design ускоряла разработку и держала интерфейс консистентным.
  • +
  • Не убрал боковые панели редактора совсем, а сделал сворачиваемыми. Новичкам нужны подсказки на экране, продвинутым — максимум места под документ.
  • +
  • Не заводил отдельную дизайн-систему. В продукте уже был фреймворк, в разработку передавал ссылки на готовые юзерфлоу в Figma.
  • +
+

Передача в разработку

+

+ Раз в неделю обсуждали дизайн с командой: список задач, приоритеты, сроки под планы релизов. С верстальщиком уточнял детали макетов тет-а-тет и давал фидбэк после вёрстки, чтобы результат не разошёлся с задуманным поведением. +

+
+ +
+

Результат

+ +
    +
  • Обращения к команде внедрения на этапе настройки сократились на 40%. Базовую настройку клиенты проходят сами.
  • +
  • 7 из 10 новых клиентов настроили первый проект без техподдержки. До редизайна — 1 из 10.
  • +
  • Настройка первого проекта: с ~3 дней до ~4 часов.
  • +
  • Переработано 30+ экранов: навигация, редактор датасета, обучение моделей, импорт данных.
  • +
  • Заложены паттерны, на которых функционал наращивается без больших переделок.
  • +
  • В компанию наняли второго дизайнера. Я ввёл её в курс дела и курировал результат, после адаптации проект целиком перешёл под её ответственность.
  • +
+
+ +
+

Выводы

+
    +
  • Жёсткий фреймворк оказался не ограничением, а ускорителем: готовая библиотека компонентов позволила думать о сценариях, а не о пикселях.
  • +
  • Самые ценные инсайты дали эксперты по настройке. В следующий раз вовлёк бы команду внедрения ещё раньше.
  • +
  • Передача проекта новому дизайнеру подтвердила: заложенные паттерны работают, их можно объяснить и поддерживать без автора.
  • +
+
+
+
+ + {{> projects-related}}{{> projects-section}} + {{> contact-section}} + {{> footer}} + {{> cookie-notice}} + {{> nav-more-floating}} + + diff --git a/src/pages/cases/ai-platform-primo-rpa.en.html b/src/pages/cases/ai-platform-primo-rpa.en.html new file mode 100644 index 0000000..2d03a9a --- /dev/null +++ b/src/pages/cases/ai-platform-primo-rpa.en.html @@ -0,0 +1,593 @@ + + + + + + + AI Server in Primo RPA — Case Study + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Product Design · AI

+

User Interface Development for the Primo RPA AI Server

+

+ Intelligent document
processing platform + and AI agent orchestration +

+ +
+

Overview

+

+ The AI Server product is deployed on clients’ internal servers. It enables the use of neural networks within the company’s internal network and is configured via a web interface. +

+

+ I was invited to join the project as a UX/UI designer. The product was in the active testing phase; feedback from customers made it clear that the configuration was too complex: only experts and the Primo RPA implementation team could handle it. The complex configuration reduced the product’s appeal and hindered scaling. +

+

+ My task was to simplify the configuration process and lower the barrier to entry, thereby reducing the workload on technical support and the implementation team—so that customers could configure the service themselves to meet their specific needs. +

+ +

+ Below is an in-depth look at the product, examples of completed tasks, and interface patterns. +

+
+ +
+

Overview

+

+ I began by getting to know the team: who was responsible for the product, who was responsible for implementation, and how everything was currently organized. I spoke with configuration experts, categorized key and secondary scenarios, and compiled a visualization of user behavior. I worked closely with the development team to identify areas where the interface could be simplified and the setup process accelerated. +

+ +

+ The product was already in use by customers, so we couldn’t halt development and completely rebuild the interface. I had to decide in what order and which parts to address in the redesign. I incorporated the most significant changes into the current development plan without disrupting the release schedule. +

+

+ I had to plan the interface changes with the framework in mind. At the time, it was + Ant Design on top of React, styled after + Hope UI. +

+

+ I only had to create new components as a last resort; the priority was to reuse the existing library. This is evident in the final screens: the interface is mostly built from ready-made blocks, rather than from scratch. +

+
+ +
+

Completed Tasks

+

+ Interface elements that I redesigned or built almost from scratch. Below are individual tasks from the project that I wanted to showcase in my portfolio—not an overview of the entire product. +

+ +

Main navigation of the service

+

+ The service used a layout common for admin panels: a side menu on the left and content on the right. The screenshot shows that this page structure didn’t make effective use of the available screen space. When you open the project, the navigation for the project sections appears on the left—intermixed with the main navigation—which confuses the user. This situation needed to be fixed. +

+
+ Previous main navigation page for the AI Server service +
Previous Navigation
+
+
+ Updated Main Navigation for the AI Server Service +
Updated navigation
+
+

+ The updated navigation is now more compact and remains familiar to most users. There is significantly more space for content. As you’ll see later, projects will have their own separate navigation that won’t overlap with the main navigation. At the same time, the service’s overall aesthetic has improved. +

+

Navigation Within a Specific Project

+

+ Users retain access to both the navigation within a project and the service’s main navigation. Different project types contain varying numbers of items, and this solution allows for their optimal placement. +

+
+ Navigation within the NLP project and the service's main navigation +
NLP Project
+
+
+ Navigation within an OCR project and the service's main navigation +
OCR project
+
+

+ I’d like to highlight the design of the navigation items. Visually, it subtly suggests that it’s more convenient to go through the setup step by step, but the sections still function as regular tabs: you can switch between them in any order. +

+
+ Secondary navigation for the project with arrow-shaped items +
Shape of navigation items
+
+

Dataset Annotation Editor

+

+ This is the screen where users spend the most time. Annotating fields across hundreds of images is monotonous work, but the quality of the data used to train the model depends on it. The previous editor stood out sharply from the service’s overall style: it looked cluttered and was confusing in places, especially when working with fields. +

+
+ Dataset Editor — Previous Version for Comparison +
Previous version of the dataset editor
+
+

+ I broke down the editor into microscenarios, organized them by priority and how they relate to each other. The team and I discussed how to make the user experience smoother and more intuitive. In the new page structure, I reserved space for new functionality and incorporated the same design patterns used throughout the rest of the service. The editor’s design now aligns with the overall style. +

+
+ Dataset Editor — Current Version of the Interface +
Updated version of the dataset editor
+
+

+ The schematic sketch shows that the page has become symmetrical: almost all the space is devoted to the image being marked up. Actions are grouped by panels—making it easier to find the desired function. +

+
+ Dataset Editor Wireframe — Default Mode +
Frame: Default mode
+
+

+ Added a “Full Screen” mode. Advanced users usually hide unnecessary panels and work with markup using keyboard shortcuts and the bottom panel. The side panels can be collapsed when more space is needed for the image—this is especially important on smaller monitors. +

+ + +

+ The bottom toolbar lets you switch between images and perform quick actions. +

+
+ The Dataset Editor's Main Toolbar +
Bottom toolbar
+
+ +

Model training configuration cards

+

+ After labeling the dataset, the model can be fine-tuned. As usual, our team broke down all user actions for this task. We created several layout options for this page; the one with a card feed turned out to be the most successful. +

+
+ Overview of Training Configuration Cards in the Project +
View of the Model Training section
+
+

+ At the top of the feed, the user sees a training configuration card: it displays the annotation results for the active dataset, allows the user to set parameters, and initiates fine-tuning. +

+
+ Training Configuration Card +
Training Configuration Card
+
+

+ The card includes a separate section with an assessment of the dataset’s annotation quality and a section with parameters to configure before starting training. +

+ +

+ A separate card will appear below for each process that is started. You can run multiple processes with different parameters and then compare which configuration performed better. +

+
+ Model Learning Process Card +
Training Process Card
+
+

+ Within the card, information and actions are organized into logical blocks. We considered various layout options and ultimately settled on a scheme that proved to be more user-friendly than the others. +

+
+ Learning Process Card Wireframe +
Wireframe of the Learning Process Card
+
+

+ The cards can be collapsed. For convenience, the name and status are displayed in the header. +

+
+ Collapsed Training Settings Card +
Collapsed Card
+
+

+ After refining these cards, we identified patterns that were later used in other sections of the product. This makes it easier to maintain a consistent style across the interface. +

+ +

Field Parameter Configuration

+

+ On one of the pages in the project section, we needed to allow users to configure unique field parameters for a dataset. The editor required a large area for an image (a scanned document), and there could be dozens of fields, each with its own dependent parameters and types. +

+

+ The video shows the prototype of the finished solution in action. +

+
+
+ +
+
Field mapping in the interface
+
+

+ The interface turned out to be compact and clean. The mechanics aren’t immediately obvious, but after a brief learning curve, users find it comfortable to use. +

+

+ Developing the editor proved to be an interesting and challenging task for me: there were many drafts and different behavior options. In the end, we settled on a version that met all the requirements my team and I had defined. +

+ +

Modal windows on a separate screen

+

+ In the service, some tasks need to be performed alongside other work. For such scenarios, we implemented the following: a modal window opens on a separate page. +

+
+ A modal window for importing a dataset on a separate screen +
Importing a dataset in a modal window
+
+

+ A placeholder is displayed on the project page: the process is in progress, with a button next to it to navigate to it. +

+
+ Importing a dataset within a project +
Importing a dataset within a project
+
+

+ These placeholders appear in several sections of the project—depending on whether you can work with that section while the task is active. +

+ +

+ This approach turned out to be technically simpler than selectively disabling buttons on individual pages. +

+ +
+ +
+

Handoff to Development

+

+ Once a week, the team met to discuss the design. The calls were long but productive. We created a weekly list of design tasks, adjusting priorities and deadlines to align with release plans. +

+

+ I created the designs in Figma and sent links to the finished user flows to the development team. We didn’t set up a separate design system or UI kit—the product already had a ready-made framework. +

+

+ From time to time, I would have one-on-one calls with the front-end developer to clarify details about the mockups or explain how the interface should behave. After the layout was completed, I provided feedback on revisions to ensure the result matched the intended behavior and appearance of the interface. +

+
+ +
+

Change in Designers

+

+ I managed this project alongside others, so I had to balance my workload. The company hired a new designer—my task was to bring her up to speed and oversee the results of her work on each task. Once she was up to speed, responsibility for the project shifted entirely to her. +

+
+ +
+

Result

+

+ The Primo RPA AI Server interface now has a consistent look and feel. It is user-friendly even for those who are not yet experts in AI. The interface is designed to accommodate future changes, allowing for the addition of new features without major overhauls. +

+
+
+
+ + {{> projects-related}}{{> projects-section}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + + diff --git a/src/pages/cases/ai-platform-primo-rpa.html b/src/pages/cases/ai-platform-primo-rpa.html index 5ac3f8d..46dbe5b 100644 --- a/src/pages/cases/ai-platform-primo-rpa.html +++ b/src/pages/cases/ai-platform-primo-rpa.html @@ -106,7 +106,7 @@ })(); - + {{> header-case}} {{> fab-stack}}
@@ -180,7 +180,7 @@

Предыдущая главная навигация сервиса AI Server
Обновлённая главная навигация сервиса AI Server
Навигация внутри NLP-проекта и главная навигация сервиса
Навигация внутри OCR-проекта и главная навигация сервиса
Вторичная навигация проекта со стрелочной формой пунктов
Редактор датасета — предыдущая версия для сравнения
Редактор датасета — текущая версия интерфейса
Вайрфрейм редактора датасета — режим по умолчанию
- {{> projects-section-mp}}{{> projects-section}} + {{> projects-related}}{{> projects-section}} {{> contact-section}} -{{> footer-spacer}} {{> footer}} {{> cookie-notice}} {{> nav-more-floating}} + diff --git a/src/pages/cases/card-columns-obsidian.en.html b/src/pages/cases/card-columns-obsidian.en.html new file mode 100644 index 0000000..8cb4e43 --- /dev/null +++ b/src/pages/cases/card-columns-obsidian.en.html @@ -0,0 +1,529 @@ + + + + + + + Card Columns, a plugin for Obsidian Bases: a case study + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Obsidian Bases · Plugin

+

+ A Kanban view for Obsidian Bases
+ that leaves your notes untouched +

+

+ Card Columns: Notes as cards in columns,
+ filtered by AND/OR tags with just two clicks +

+ + + +
    +
  • Role: Product Design and Development
  • +
  • Platform: Obsidian Bases
  • +
  • Status: Working plugin, open source (MIT)
  • +
  • Team: Solo, side project
  • +
  • Status: Draft response, to be replaced
  • +
+ +
+ Question for the author +

+ How long did it take from the initial idea to a working version? Weeks? A month of weekends? +

+

+ Draft answer: I put it together in three weeks in the evenings, then spent another month refining it while + working on my own projects. Replace with the actual time. +

+
+ +

+ Obsidian now has Bases, a separate file type that displays notes not as text, but + in various formats: tables, cards, and trees. The standard card view didn’t work for me: it’s + flat, with no grouping, and it’s hard to make sense of when there are hundreds of notes. I wanted to see them + arranged in columns, like a Kanban board, but without the extra overhead. That’s how the Card Columns view plugin came about. +

+ +
+

Context

+

+ A “Bases” is neither a note nor a separate database. It’s a query to the vault: you select + notes using a filter, and the framework displays them in the chosen view. Obsidian itself has + a built-in card view, but it’s one-dimensional: no grouping by properties, no + proper filtering. I decided to create my own view that integrates into the + Bases toggle alongside the default ones. +

+

+ The main limitation was in the framework itself: the plugin doesn’t store data, but rather renders what + the Bases query returns (Sort, Filter, Properties, Search from the toolbar). This means that all the logic + for data retrieval remains with Bases, and my task is solely to display and navigate the results. +

+ +
+ +
+

Why not use a ready-made solution?

+

+ Before writing any code, I checked what was already available. Three options, none of them worked: +

+

+ The default Bases card view. + It displays a continuous stream of cards without grouping. + When there are over a hundred notes, it becomes a feed where you have to scroll and squint at the titles. + I ruled that out first. +

+

+ Kanban plugins from the community. + Most come with their own storage system: + the board exists separately from the vault, and notes are duplicated. This results in a second database + that needs to be synchronized. I didn’t need that. +

+

+ The neighboring bases-kanban. + The closest in spirit: it hooks into Bases to create + a Kanban board. But it writes to the notes: dragging a card changes the frontmatter. For a view plugin, + this is a risk, which I’ll discuss below. +

+ + +
+ Question for the author +

+ Did you actually install and test other people’s plugins, or did you just go by the descriptions? Which + ones did you look at specifically? +

+

+ Preliminary answer: I installed three of them, ran them for a week on my database, then uninstalled them. I looked at the source code for bases-kanban + but didn’t install it. Replace with actual information; include the plugin names. +

+
+
+ +
+

Task

+

+ Design a view for Bases that displays notes as cards arranged in columns and allows you to + quickly select the ones you need. Strict constraint: the plugin does not write anything to the notes and does not store + its own state, and sorting, filtering, properties, and search from the standard Bases toolbar must + work as before. +

+ +
+ +
+

Solutions

+

+ The plugin adds a “Columns” view to Bases. Cards are built from the results of a Bases query; + grouping is defined by a property, and filtering is done via clickable tags. Next, there are five options + that determine the result. Each one includes something I decided against. +

+ + +

Grouping by any property

+

+ Columns are generated using the “Group by” option in the Bases toolbar, by tag, status, category, or any + frontmatter property. Change the property in the query, and the board reloads automatically. This way, a single + database covers different perspectives: projects by status, notes by tags, tasks by + priority. +

+ +

Tag filter by AND and OR

+

+ This is exactly what the plugin was designed for. Below the toolbar, there are tag icons; when clicked, they + filter cards across all columns at once: +

+
    +
  • Left-click on a tag to show only that tag (resets the other filters).
  • +
  • Left-click on the next tag to show notes with ANY of the selected tags (OR mode).
  • +
  • Ctrl+Click or right-click to switch tags without resetting the selection.
  • +
  • The OR/AND button: toggle between “any of” and “all of” for multiple tags.
  • +
  • All: Clear all filters.
  • +
+

+ The combination of “OR—add with a left-click + exact toggle via Ctrl” turned out to be intuitive: + quick selection doesn’t disrupt what’s already selected. I immediately rejected the alternative—moving the filter to a modal window— + because it requires an extra click and loses the context of what’s already been filtered. +

+

+ The choice here isn’t about the feature itself, but where it lives. I chose the pills below the toolbar instead of + a separate filter window because filtering by tags is an action performed at every step of working + with the database. A modal window turns it into an interrupted workflow: open, configure, close, check. + The pill-shaped icons keep the result right in front of you, and the filter status is always visible. +

+ + +

Cards and Covers

+

+ Properties such as chips, text, tags, links, Booleans, and dates are displayed for each note. You can + show a cover: from the first image in the note or from a separate property. You have + full control over the cover, including aspect ratio (Auto, 1:1, 3:2, 4:3, 16:9), border, fit + (cover/contain), orientation, and position on the card (above the title, below it, at the end). +

+ +

Solution: Settings have been moved out of the code

+

+ At first, the parameters were hardcoded for my database. This was a mistake: the view would stop working + for someone else’s scenario. I moved everything to the gear menu, dividing it into four sections so that the view + could adapt to different databases, rather than just my single use case. More than 15 parameters. +

+
+ Show the full list of settings +

General

+
    +
  • Where to open the card: active panel, pop-up window, new tab, split to the right / down.
  • +
  • Card width (px).
  • +
  • Columns per group (1–6), grid within a column.
  • +
  • Zebra columns (alternating background colors for easy scanning).
  • +
  • Masonry layout (cards fill vertical gaps).
  • +
+

Title

+
    +
  • Title wrapping, bold title, font size (px).
  • +
+

Properties

+
    +
  • Chip layout: stack (vertical) or grid (compact in a row).
  • +
  • Wrap multi-line values, font size.
  • +
  • Date and date-time formats + locale (Moment.js).
  • +
+

Cover

+
    +
  • Source: none / first image in the post / cover property.
  • +
  • Style (borderless / with border), aspect ratio, orientation, fit, position on the card.
  • +
+
+ +
+ Question for the author +

+ Is it true about the hard-coded parameters? If not, replace it with the real reason for moving them + to the settings: user requests, multiple databases with different structures, whatever. +

+

+ Preliminary answer: Yes, the first version was tailored to my database; the settings were added when + I needed to make the plugin available externally. I’ll check and fix it. +

+
+ + +

Works within the standard toolbar

+

+ The view hooks into the BasesView framework, so sorting, filtering, properties, and search from + the Bases toolbar remain functional. The plugin only renders the query results; the data is not + duplicated and does not depend on a separate storage. +

+ +
+ +
+

The Result and How I Tested It

+

+ This view solved my problem: a Bases query now reads like a board, rather than a flat stream + of cards. Grouping by property + filtering by AND/OR tags—exactly what the default + view was missing. Everything runs on the Bases query, so moving the vault and syncing don’t + break anything. +

+ +

Metrics: number of steps to filter

+

+ The main task I used to measure success: filter notes by two tags and find the right + card. In the standard card view, this involves scrolling through the feed and scanning the titles with your eyes. In + Columns, two clicks on the pills and the column is right in front of you. +

+ +
+ Question for the author +

+ Can you run this test for real? Time 10 repetitions of the task: the default + view versus Columns. Or count the exact number of steps. +

+

+ Preliminary answer: on a sample of 25 notes, filtering by two tags took about 40 seconds and + required scrolling through the feed; in Columns, two clicks, about 5 seconds. These are rough estimates— + replace them with actual measurements. +

+
+ +

Test: Long-term usability in daily work

+

+ The second criterion is more subjective, but for a tool tailored to my needs, it’s more important: will I still be + using the plugin a month from now? I am—it’s become an integral part of my work with the database and doesn’t require + me to remember how to use it. +

+ +
+ Question for the author +

+ Are there any external indicators: installs, GitHub stars, reviews, or questions in the issue tracker? Any + number confirming that you’re not the only one using the plugin. +

+

+ Preliminary answer: The plugin currently needs to be installed manually; it hasn’t been approved for the community repository yet, so + there are only a handful of installs and no stars so far. If there’s actual data, include it here— + it’s more convincing than any self-report. +

+
+

+ What I’d do differently. I deliberately chose not to implement drag-and-drop of cards between columns, + since that changes a property in the note, and for a view plugin, that poses a separate data integrity risk. + But I see a demand for this among users, and I might add it as an optional mode. For + now, since the plugin is maintained manually (it hasn’t been reviewed by the community directory yet), this is the only thing + preventing users from using it right away. +

+ +
+
+
+ + {{> mini-projects-related}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + + diff --git a/src/pages/cases/card-columns-obsidian.html b/src/pages/cases/card-columns-obsidian.html new file mode 100644 index 0000000..578012b --- /dev/null +++ b/src/pages/cases/card-columns-obsidian.html @@ -0,0 +1,525 @@ + + + + + + + Card Columns, плагин для Obsidian Bases: кейс + {{> head-links}} + {{> head-plyr}} + + + + + {{> header-case}} +{{> fab-stack}} +
+
+

Obsidian Bases · Plugin

+

+ Канбан-вид для Obsidian Bases,
который не трогает ваши заметки +

+

+ Card Columns: заметки как карточки по колонкам,
+ отбор по тегам И/ИЛИ за два клика +

+ + + +
    +
  • РольПродуктовый дизайн и разработка
  • +
  • ПлатформаObsidian Bases
  • +
  • СтатусРабочий плагин, open source (MIT)
  • +
  • КомандаСоло, пет-проект
  • +
  • СрокЧерновой ответ, заменить
  • +
+ +
+ Вопрос к автору +

+ Сколько заняла работа от первой идеи до рабочей версии? Недели? Месяц выходных? +

+

+ Черновой ответ: собрал за три недели вечером, потом ещё месяц доводил по ходу + собственной работы. Заменить на реальное. +

+
+ +

+ В Obsidian появились Bases, отдельный тип файла, который показывает заметки не текстом, а + в разных видах: таблицей, карточками, деревом. Стандартный вид карточек мне не зашёл: он + плоский, без группировки, и в нём не разобраться, когда заметок сотни. Я хотел видеть их + раскладку по колонкам, как канбан, но без лишнего веса. Так появился плагин-вид Card Columns. +

+ +
+

Контекст

+

+ Bases, это не заметка и не отдельная база данных. Это запрос к vault-у: выбираешь + заметки по фильтру, и фреймворк рисует их в выбранном виде. У самого Obsidian есть + встроенный вид карточек, но он одноплановый: нет группировки по свойствам, нет + нормальной фильтрации. Я решил сделать свой вид, который встраивается в переключатель + Bases наравне со штатными. +

+

+ Главное ограничение было в самом фреймворке: плагин не хранит данные, а рисует то, что + выдал Bases-запрос (Sort, Filter, Properties, Search из тулбара). Значит, вся логика + выборки остаётся на Bases, а моя задача, только представление и навигация по нему. +

+ +
+ +
+

Почему не готовое

+

+ Прежде чем писать код, я посмотрел, что уже есть. Три пути, все мимо: +

+

+ Штатный вид карточек Bases. Рисует ровный поток карточек без группировки. + Когда заметок за сотню, это лента, в которой нужно скроллить и вглядываться в заголовки. + Отпал первым. +

+

+ Канбан-плагины из комьюнити. Большинство тянет за собой свою систему + хранения: доска живёт отдельно от vault-а, заметки дублируются. Получается вторая база, + которую надо синхронизировать. Мне это было не нужно. +

+

+ Соседний bases-kanban. Ближайший по духу: цепляется к Bases, делает + канбан. Но он пишет в заметки: перетаскивание карточки меняет frontmatter. Для view-плагина + это риск, о котором ниже. +

+ + +
+ Вопрос к автору +

+ Ты реально ставил и тестировал чужие плагины или ориентировался по описаниям? Какие + именно смотрел? +

+

+ Черновой ответ: поставил три штуки, погонял неделю на своей базе, удалил. bases-kanban + смотрел в исходниках, не ставил. Заменить на реальное, названия плагинов вписать. +

+
+
+ +
+

Задача

+

+ Спроектировать вид для Bases, который показывает заметки карточками по колонкам и позволяет + быстро отбирать нужное. Жёсткое ограничение: плагин ничего не пишет в заметки и не хранит + своего состояния, а сортировка, фильтр, свойства и поиск из штатного тулбара Bases должны + работать как раньше. +

+ +
+ +
+

Решения

+

+ Плагин добавляет в Bases вид «Columns». Карточки строятся из результатов Bases-запроса, + группировка задаётся свойством, а отбор, кликабельными тегами. Дальше, пять выборов, + которые определили результат. В каждом есть то, от чего я отказался. +

+ + +

Группировка по любому свойству

+

+ Колонки формируются из Group by в тулбаре Bases, по тегу, статусу, категории или любому + frontmatter-свойству. Меняешь свойство в запросе, и доска пересобирается сама. Так одна + и та же база закрывает разные разрезы: проекты по статусу, заметки по тегам, задачи по + приоритету. +

+ +

Фильтр тегов по И и ИЛИ

+

+ Это то, ради чего плагин и задумывался. Под тулбаром, пилюли тегов, по клику они + отсеивают карточки по всем колонкам сразу: +

+
    +
  • Левый клик по тегу, показать только его (сбрасывает остальные фильтры).
  • +
  • Левый клик по следующему тегу, показать заметки с ЛЮБЫМ из выбранных (режим ИЛИ).
  • +
  • Ctrl+Клик или правый клик, переключить тег, не сбрасывая выбор.
  • +
  • Кнопка OR/AND, переключить между «любой из» и «все перечисленные» для нескольких тегов.
  • +
  • All, снять все фильтры.
  • +
+

+ Связка «ИЛИ-добавление левым кликом + точный toggle через Ctrl» получилась интуитивной: + быстрый отбор не сбивает то, что уже выбрано. Альтернативу, вынести фильтр в модалку, + отверг сразу: лишний клик и потеря контекста того, что уже отфильтровано. +

+

+ Выбор здесь не в самой фиче, а в том, где она живёт. Я выбрал пилюли под тулбаром вместо + отдельного окна фильтров, потому что отбор по тегам, это действие на каждом шаге работы + с базой. Модалка превращает его в прерванный сценарий: открыл, настроил, закрыл, проверил. + Пилюли оставляют результат перед глазами, а состояние фильтра видно всегда. +

+ + +

Карточки и обложки

+

+ По каждой заметке рисуются свойства как чипы, текст, теги, ссылки, булевы, даты. Можно + показать обложку: из первой картинки в заметке или из отдельного свойства. У обложки + полный контроль, соотношение сторон (Auto, 1:1, 3:2, 4:3, 16:9), рамка, вписывание + (cover/contain), ориентация и место на карточке (над заголовком, под ним, после всего). +

+ +

Решение: настройки вынесены из кода

+

+ Сначала параметры были захардкожены под мою базу. Это ошибка: вид перестал бы работать + для чужого сценария. Вынес всё в gear-меню, разбив на четыре секции, чтобы вид + подстраивался под разные базы, а не под мой единственный случай. Больше 15 параметров. +

+
+ Показать полный список настроек +

General

+
    +
  • Куда открывать карточку: активная панель, всплывающее окно, новая вкладка, сплит вправо / вниз.
  • +
  • Ширина карточки (px).
  • +
  • Колонок в группе (1–6), сетка внутри колонки.
  • +
  • Зебра колонок (чередование фона для сканирования).
  • +
  • Masonry-раскладка (карточки заполняют вертикальные пустоты).
  • +
+

Title

+
    +
  • Перенос заголовка, жирный заголовок, размер шрифта (px).
  • +
+

Properties

+
    +
  • Раскладка чипов: стек (вертикально) или сетка (компактно в строку).
  • +
  • Перенос многострочных значений, размер шрифта.
  • +
  • Формат даты и даты-времени + локаль (Moment.js).
  • +
+

Cover

+
    +
  • Источник: нет / первая картинка в заметке / свойство-обложка.
  • +
  • Стиль (без рамки / с рамкой), соотношение сторон, ориентация, вписывание, позиция на карточке.
  • +
+
+ +
+ Вопрос к автору +

+ Это правда про захардкоженные параметры? Если нет, замени на реальную причину выноса + в настройки: просьбы пользователей, несколько баз с разной структурой, что угодно. +

+

+ Черновой ответ: да, первая версия была под мою базу, настройки появились, когда + понадобилось открыть плагин наружу. Проверить и поправить. +

+
+ + +

Работает внутри штатного тулбара

+

+ Вид цепляется к BasesView-фреймворку, поэтому сортировка, фильтр, свойства и поиск из + тулбара Bases остаются рабочими. Плагин только рисует результат запроса, данные не + дублируются и не зависят от отдельного хранилища. +

+ +
+ +
+

Результат и как я это мерял

+

+ Вид закрыл мою боль: Bases-запрос теперь читается как доска, а не как плоская лента + карточек. Группировка по свойству + отбор по тегам И/ИЛИ, то, чего не хватало штатному + виду. Всё крутится на запросе Bases, так что перенос vault-а и синхронизация ничего не + ломают. +

+ +

Замер: сколько действий на отбор

+

+ Главная задача, по которой я мерял успех: отобрать заметки по двум тегам и найти нужную + карточку. В штатном виде карточек это скролл ленты с поиском по заголовкам глазами. В + Columns, два клика по пилюлям и колонка перед глазами. +

+ +
+ Вопрос к автору +

+ Можешь прогнать этот замер по-настоящему? Засечь время на 10 повторах задачи: штатный + вид против Columns. Или посчитать точное число действий. +

+

+ Черновой ответ: на выборке из 25 заметок отбор по двум тегам занимал около 40 секунд и + требовал скроллить ленту; в Columns, два клика, примерно 5 секунд. Цифры примерные, + заменить на замеренные. +

+
+ +

Замер: живучесть в ежедневной работе

+

+ Второй критерий субъективнее, но для инструмента под себя он важнее: продолжаю ли я + пользоваться плагином через месяц. Пользуюсь, он встроился в работу с базой и не требует + вспоминать, как им управлять. +

+ +
+ Вопрос к автору +

+ Есть ли внешние сигналы: установки, звёзды на GitHub, отзывы, вопросы в issue? Любая + цифра, подтверждающая, что плагином пользуются не только ты. +

+

+ Черновой ответ: плагин пока ставится вручную, через каталог комьюнити не прошёл, так + что установок единицы и звёзд пока нет. Если есть реальные данные, вписать их сюда, + это сильнее любого-self-report. +

+
+

+ Что сделал бы иначе. Drag-and-drop карточек между колонками не стал делать сознательно, + это меняет свойство в заметке, и для view-плагина это отдельный риск целостности данных. + Но вижу запрос на это у пользователей, и, возможно, добавлю как опциональный режим. А + пока плагин живёт вручную (через community-каталог ещё не прошёл), это единственное, что + мешает им пользоваться сходу. +

+ +
+
+
+ + {{> mini-projects-related}} +{{> contact-section}} +{{> footer}} +{{> cookie-notice}} +{{> nav-more-floating}} + + + + diff --git a/src/pages/cases/design-system.en.html b/src/pages/cases/design-system.en.html new file mode 100644 index 0000000..0af26d1 --- /dev/null +++ b/src/pages/cases/design-system.en.html @@ -0,0 +1,912 @@ + + + + + + + Design System — Portfolio + {{> head-links}} + + + + + {{> header-minimal}} +{{> fab-stack-home-contact}} +
+
+

Documentation · UI

+

Design System

+

Tokens, typography, grid, components, and longgrid blocks

+

+ Switch between themes, accents, and fonts using the floating panel on the right. When making global style changes (tokens, + components, longgrid blocks), be sure to update this page as well—it’s the project’s live UI catalog. +

+ + + +

Colors

+

+ The accent and “cool/warm” tones of the entire UI are defined by three numbers in the HSL model (Hue, + Saturation, Lightness) at <html>. From these, + --accent and derived tokens; neutral backgrounds are slightly tinted with the same hue + (--neutral-accent-tint). Light/dark theme — data-theme, mesh — + data-mesh-bg. Can be changed via the FAB on the right. +

+

What is --theme-h, --theme-s, --theme-l

+
    +
  • + --theme-h — hue (Hue), position on the color wheel 0–360°. A hue shift + recolors buttons, links, the mesh gradient, and square accents (--accent-square-2 … + 4), without having to manually adjust each hex value. +
  • +
  • + --theme-s — Saturation: how “pure” the color is, rather than gray. +
  • +
  • + --theme-l — Accent lightness: darker or lighter than the primary brand color + (usually limited by the panel’s range to ensure buttons remain readable). +
  • +
+

+ In CSS, it looks like this: + --accent: hsl(var(--theme-h) calc(var(--theme-s) * 1%) calc(var(--theme-l) * 1%)). + Values are written in localStorage (siteThemeV1) and are inserted by an inline script into + headto prevent a “foreign” color from flashing during loading. +

+
+
+
+ --bg +
+
+
+ --bg-elevated +
+
+
+ --surface +
+
+
+ --text +
+
+
+ --text-muted +
+
+
+ --line +
+
+
+ --accent +
+
+
+ --accent-text +
+
+
+ --accent-soft +
+
+

+ Additionally: quadratic mesh shades — --accent-square-2 … 4; they depend on hue. +

+ +

Fonts

+

+ A pair of fonts — data-font on <html> (FAB “Typography”): sets + --font-accent (headings) and --font (body text). Scale in tables: class, example, purpose. +

+

Font families

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
data-fontStackWhere
merriweatherMerriweather + AliceDefault pair; Alice—body text, Merriweather—emphasized headings
monoSource Code Pro + PT MonoMonospace pair
geologicaGeologica + PT SansSans-serif + neutral body text
+
+ +

Typographic scale

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassExamplePurpose
.hero__eyebrow

Section caption

Hero, case input
.hero__title +

+ Display main heading +

+
H1 for the main page; reduced on DS for the column
.section__title

Section

“Skills,” “Portfolio,” contacts; main font, no caps
.case-page__h1

Page title

H1 for case studies / Design System
.case-page__sub

Lead text under the heading

Subheading below H1
.section__prose

Main paragraph text.

Prose, lists, and links in the article
h2 in .case-article

H2 subsection

Long-form sections (such as “Colors”)
h3 in .case-article

H3 subsection

Subsections within H2; margins in main.css: 3.25rem top, 1.5rem bottom, after figure 4rem, h3+h3 3.5rem
+
+ +

Rhythm and Code

+

Rhythm example:

+
    +
  • bulleted list;
  • +
  • emphasis and link;
  • +
+
    +
  1. numbered item;
  2. +
+

Inline code and block:

+
--font: "Alice", system-ui, serif;
+--font-accent: "Merriweather", var(--font);
+

+ Headings use clamp() — scale relative to the viewport; see the “Grid” section for more details. +

+ +

Grid and Responsive

+

+ There is no separate 12-column grid: a full-bleed layout with side margins + --space and local CSS Grid within sections. The content column for DS and case studies — + .case-article (max-width 48rem). +

+ +

Basic framework

+
    +
  • --space: clamp(1rem, 4vw, 2.75rem) — horizontal margins + safe-area-inset.
  • +
  • --max: none — no fixed 1200px “box”; width = viewport minus padding.
  • +
  • .section — vertical spacing via clamp(); .section__inner centers the block.
  • +
  • .section--tight — More compact vertically (case studies, this page).
  • +
+ +

Breakpoints

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FromWhat Changes
860px.hero__grid → 2 columns 1.15fr 0.85fr
760px / 1180px.projects-carousel: --projects-visible 1 → 2 → 3 cards
720px.skills-category__grid → 2 columns
1024pxSkills: 6-column mosaic (grid-column: span …)
560px / 1024px.contact-grid → 2 → 4 columns
48rem / 28remCase gallery: ≤2 col → 1 col
640pxcase-media-gallery--wide collapses to the width of a column
+
+ +

Hero: one → two columns

+

Layout .hero__grid (compressed to 860px):

+ + +

Contacts: 1 → 2 → 4

+

Demo .contact-grid (placeholders, no transitions):

+ +

+ Skills: mosaic layout on wide screens—see + the homepage, #skills. Portfolio: carousel on + 100vw with --projects-card-width and JS scrolling — on the homepage + #projects. +

+ +

Wide in case studies

+

+ .case-media--wide, .case-media-gallery--wide and .case-callout wider + article columns: min(100vw − 2×padding, content + 8rem), with margins --space. On a narrow screen (up to 640px), "wide" collapses to the width of the column. Images with data-image-viewer — the outline and hover effects + are the same as .case-video-shell (live examples — case study element demo). The caption below the video is + figcaption centered in the column. +

+ +

Typography and Motion

+

+ Fluid type: clamp() on .hero__title, .section__title, + .case-page__h1. Rounded corners (data-radius) do not change the grid. For long screenshots and + animations, the prefers-reduced-motion: reduce. +

+ +

Components

+

Live examples; the shell (header, FAB, footer, cookie) is already integrated on this page.

+ +

Buttons

+ +

.btn .btn--primary | secondary | ghost · .btn--flat | raised · .btn__icon

+ +

FAB buttons

+

+ Floating buttons in the lower-right corner (.fab-stack). Icon-only and with a text label. +

+
+ + + +
+

.fab .fab--primary | secondary · .fab--label (with text) · .fab-primary__ico · .fab__label

+ +

Tags

+
    +
  • hero__tag
  • +
  • UX/UI
  • +
+

.hero__tags / .hero__tag

+
    +
  • mp-card__tag
  • +
  • Web App
  • +
+

.mp-card__tags / .mp-card__tag

+ +

Portfolio Card

+ +

.mp-card .mp-card--stack-bottom — text on top, cover at the bottom

+ + +

Placeholder while images are loading

+

+ While img hasn't loaded yet, initImageLoadPlaceholder it + .is-img-loading on the image and .has-img-loading the wrapper. The background layer is + ::before, a single glow from left to right— ::after (img-ph-shine-sweep). + Icons, SVGs, and small images up to 40px are skipped; + disable selectively: data-no-img-placeholder. Below is a fixed demo (on the website, the classes are removed + after load). +

+ +

.has-img-loading · .is-img-loading · .img-ph-demo (placeholder demo)

+ +

TUI Braille spinner

+

+ Loading large images in the viewer (.case-image-viewer) is indicated by a monospaced Braille spinner—just like in TUI. Pure CSS, no JS: 10 frames cycle through an animation. Reusable class: steps()an animation. Reusable class .tui-spinner + (color — --accent, font—system mono). +

+
+ ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ + ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ +
+

.tui-spinner · frames: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ · prefers-reduced-motion displays ⠋

+ +

Skill card

+
+

skill-task-card__task — the task description provided by the business.

+
+

skill-task-card__solution — the first approach to the solution.

+

The second item in the solutions section.

+
+
+

.skill-task-card__task · __solutions · __solution

+ +

Callout

+

+ Longread accent block: wider than the column (like wide media), text .case-callout__content slightly + larger than the main paragraph. +

+ +

.case-callout · .case-callout__emoji · .case-callout__emoji--pulse · .case-callout__content

+ +

Contact Cards

+

+ On the home page, there are four cards in a grid (1→2→4 columns). Here are two examples—the grid is forced into two columns. +

+ +

.contact-card--link · --action · --brand-*

+ +

Section with a background

+

+ On the homepage, the strip .section--surface is visually presented using mesh; in other cases, the page background is + solid—see the compact demo below with background: var(--surface). +

+
+
+

section__title

+

Snippet: .section.section--surface + .section__inner

+
+
+

.section · .section--surface · .section--tight · .section__inner

+ +

Separators

+

+ Horizontal separators: a line or simply white space (indentation). Modifiers --line, --air, --air-lg. +

+
+
+
+
+
+

.sep · .sep--line · .sep--air · .sep--air-lg

+ +

Blocks

+
+

+ Longgrid case layout: media and galleries, link cards, videos, callouts, wide blocks, and other partials. + A live example with all variations is available on a separate page; use it to copy sections when building a new case. +

+

+ Case element demo +

+
+ +

Activity

+
+

+ On the homepage in the section #mini-projects card mp-card section on the homepage in a masonry grid. Types, cells, and the live + layout are on a separate page. +

+

+ Activity Card Catalog +

+
+
+
+ + {{> footer}} + {{> cookie-notice}} + {{> nav-more-floating}} + + + diff --git a/src/pages/cases/design-system.html b/src/pages/cases/design-system.html index 0af2800..8116e0d 100644 --- a/src/pages/cases/design-system.html +++ b/src/pages/cases/design-system.html @@ -37,7 +37,7 @@ margin: 0 0 1.15rem; padding-top: clamp(2.75rem, 6vw, 4rem); font-family: var(--font); - font-size: clamp(1.15rem, 2vw, 1.35rem); + font-size: clamp(1.7rem, 3vw, 2rem); font-weight: 700; color: var(--text); scroll-margin-top: 1.5rem; @@ -52,7 +52,7 @@ .case-article.ds-page > h3 { margin: clamp(2rem, 4vw, 2.75rem) 0 0.85rem; font-family: var(--font); - font-size: 1.05rem; + font-size: 1.55rem; font-weight: 600; color: var(--text); } @@ -69,7 +69,7 @@ .case-article.ds-page #components h3:first-of-type { margin-top: 1rem; } - .case-article.ds-page a:not(.btn) { + .case-article.ds-page a:not(.btn):not(.fab) { color: var(--accent); } .sg-toc { @@ -368,8 +368,9 @@
  • Шрифты
  • Сетка и адаптив
  • Компоненты
  • +
  • FAB-кнопки
  • Блоки
  • -
  • Активность
  • +
  • Активность
  • @@ -666,9 +667,9 @@

    Навыки: мозаика на широком экране — см. - главную, #skills. Портфолио: карусель на + главную, #skills. Портфолио: карусель на 100vw с --projects-card-width и JS-прокруткой — на главной - #projects. + #projects.

    Wide в кейсах

    @@ -700,42 +701,74 @@

    .btn .btn--primary | secondary | ghost · .btn--flat | raised · .btn__icon

    +

    FAB-кнопки

    +

    + Плавающие кнопки в правом нижнем углу (.fab-stack). Иконка-only и с текстовой подписью. +

    +
    + + + +
    +

    .fab .fab--primary | secondary · .fab--label (с текстом) · .fab-primary__ico · .fab__label

    +

    Теги

    • hero__tag
    • UX/UI

    .hero__tags / .hero__tag

    -
    +

    Песочница _lab

    +

    + Стенды для настройки видa элементов без сборки сайта. Правишь + public/css/main.css или файл стенда — страница перезагружается сама (live-reload + сервера npm run serve). Когда вид готов, переносишь компонент в + src/pages/** и собираешь npm run build:pages. +

    + +
    + +
    + Как добавить стенд: скопируй /_lab/template.html в + /_lab/<имя>.html, поправь заголовок и секции. Стили компонента — в + public/css/main.css, обвязка стенда — в /_lab/lab.css. + Переключатель темы подключается автоматически через lab.js. +
    +