diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..0843cde --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "vuoom": { + "command": "D:\\Vuoom\\target\\release\\vuoom-mcp.exe", + "args": [] + } + } +} diff --git a/assets/fonts/Anton-OFL.txt b/assets/fonts/Anton-OFL.txt new file mode 100644 index 0000000..efd0dd8 --- /dev/null +++ b/assets/fonts/Anton-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Anton-Regular.ttf b/assets/fonts/Anton-Regular.ttf new file mode 100644 index 0000000..4d65707 Binary files /dev/null and b/assets/fonts/Anton-Regular.ttf differ diff --git a/assets/fonts/BebasNeue-OFL.txt b/assets/fonts/BebasNeue-OFL.txt new file mode 100644 index 0000000..da95714 --- /dev/null +++ b/assets/fonts/BebasNeue-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2010 by Dharma Type. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/BebasNeue-Regular.ttf b/assets/fonts/BebasNeue-Regular.ttf new file mode 100644 index 0000000..c328c6e Binary files /dev/null and b/assets/fonts/BebasNeue-Regular.ttf differ diff --git a/assets/fonts/PermanentMarker-LICENSE.txt b/assets/fonts/PermanentMarker-LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/assets/fonts/PermanentMarker-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/fonts/PermanentMarker-Regular.ttf b/assets/fonts/PermanentMarker-Regular.ttf new file mode 100644 index 0000000..3218fc5 Binary files /dev/null and b/assets/fonts/PermanentMarker-Regular.ttf differ diff --git a/assets/fonts/Poppins-Bold.ttf b/assets/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/assets/fonts/Poppins-Bold.ttf differ diff --git a/assets/fonts/Poppins-OFL.txt b/assets/fonts/Poppins-OFL.txt new file mode 100644 index 0000000..76df3b5 --- /dev/null +++ b/assets/fonts/Poppins-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Shrikhand-OFL.txt b/assets/fonts/Shrikhand-OFL.txt new file mode 100644 index 0000000..eda5d82 --- /dev/null +++ b/assets/fonts/Shrikhand-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2015 Jonny Pinhorn (jonpinhorn.typedesign@gmail.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Shrikhand-Regular.ttf b/assets/fonts/Shrikhand-Regular.ttf new file mode 100644 index 0000000..e52b55e Binary files /dev/null and b/assets/fonts/Shrikhand-Regular.ttf differ diff --git a/crates/vuoom-project/src/annotation.rs b/crates/vuoom-project/src/annotation.rs index 36a1b0c..891504d 100644 --- a/crates/vuoom-project/src/annotation.rs +++ b/crates/vuoom-project/src/annotation.rs @@ -25,6 +25,14 @@ pub struct TextAnnotation { /// Render italic. #[serde(default)] pub italic: bool, + /// Draw a translucent plate behind the glyphs so captions stay legible over busy + /// footage. Defaults off so older projects keep their look. + #[serde(default)] + pub background: bool, + /// Font family name (e.g. "Anton"); empty = the default sans-serif. Defaults empty so + /// older projects keep the sans look. + #[serde(default)] + pub font: String, pub range: TimeRange, } diff --git a/crates/vuoom-project/src/lib.rs b/crates/vuoom-project/src/lib.rs index a5dc463..009adc4 100644 --- a/crates/vuoom-project/src/lib.rs +++ b/crates/vuoom-project/src/lib.rs @@ -186,6 +186,8 @@ mod tests { color: Color::WHITE, bold: true, italic: false, + background: false, + font: String::new(), range: TimeRange::with_fade(1.0, 4.0, 0.3), }); p.aspect = AspectRatio::Widescreen; diff --git a/crates/vuoom-render/src/compositor.rs b/crates/vuoom-render/src/compositor.rs index a906227..7a979ef 100644 --- a/crates/vuoom-render/src/compositor.rs +++ b/crates/vuoom-render/src/compositor.rs @@ -16,6 +16,17 @@ use glyphon::{ }; use std::sync::Mutex; +/// Load the bundled display fonts into the glyphon font DB so text annotations can be +/// rendered by family name (matching the `@font-face` set the web UI previews with). +fn load_bundled_fonts(font_system: &mut FontSystem) { + let db = font_system.db_mut(); + db.load_font_data(include_bytes!("../../../assets/fonts/Anton-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/BebasNeue-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/Poppins-Bold.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/PermanentMarker-Regular.ttf").to_vec()); + db.load_font_data(include_bytes!("../../../assets/fonts/Shrikhand-Regular.ttf").to_vec()); +} + struct TextState { font_system: FontSystem, swash_cache: SwashCache, @@ -230,8 +241,10 @@ impl Compositor { wgpu::MultisampleState::default(), None, ); + let mut font_system = FontSystem::new(); + load_bundled_fonts(&mut font_system); let text = Mutex::new(TextState { - font_system: FontSystem::new(), + font_system, swash_cache: SwashCache::new(), atlas: text_atlas, viewport, @@ -615,7 +628,12 @@ impl Compositor { for label in &labels { let metrics = Metrics::new(label.font_px as f32, label.font_px as f32 * 1.25); let mut buf = TextBuffer::new(font_system, metrics); - let mut attrs = Attrs::new().family(Family::SansSerif); + let family = if label.font.is_empty() { + Family::SansSerif + } else { + Family::Name(label.font.as_str()) + }; + let mut attrs = Attrs::new().family(family); if label.bold { attrs = attrs.weight(Weight::BOLD); } diff --git a/crates/vuoom-render/src/scene.rs b/crates/vuoom-render/src/scene.rs index 59eabd5..2187d48 100644 --- a/crates/vuoom-render/src/scene.rs +++ b/crates/vuoom-render/src/scene.rs @@ -19,6 +19,8 @@ pub struct ResolvedText { pub color: Color, pub bold: bool, pub italic: bool, + /// Font family name; empty = the default sans-serif. + pub font: String, } /// An arrow resolved to output pixels. @@ -85,19 +87,39 @@ pub fn build_scene( let oh = f64::from(out_h); let mut texts = Vec::new(); + let mut highlights = Vec::new(); for ta in &project.texts { let o = ta.range.opacity_at(t); if o <= 0.0 { continue; } + let font_px = f64::from(ta.font_size) * oh; + if ta.background { + // A translucent plate behind the glyphs (estimated text box), drawn in the + // shape pass so it sits under the text. Mirrors the keystroke-chip backing. + let tw = ta.text.chars().count() as f64 * font_px * 0.6; + let pad_x = font_px * 0.3; + let pad_y = font_px * 0.16; + highlights.push(ResolvedHighlight { + x: ta.pos.x * ow - pad_x, + y: ta.pos.y * oh - pad_y, + w: tw + pad_x * 2.0, + h: font_px * 1.25 + pad_y * 2.0, + thickness_px: 0.0, + filled: true, + ellipse: false, + color: fade(Color::rgb(0.05, 0.05, 0.06).with_alpha(0.7), o), + }); + } texts.push(ResolvedText { text: ta.text.clone(), x: ta.pos.x * ow, y: ta.pos.y * oh, - font_px: f64::from(ta.font_size) * oh, + font_px, color: fade(ta.color, o), bold: ta.bold, italic: ta.italic, + font: ta.font.clone(), }); } @@ -124,7 +146,6 @@ pub fn build_scene( }); } - let mut highlights = Vec::new(); for h in &project.highlights { let o = h.range.opacity_at(t); if o <= 0.0 { @@ -218,6 +239,7 @@ pub fn build_scene( color: fade(Color::WHITE, o), bold: true, italic: false, + font: String::new(), }); } } @@ -255,6 +277,8 @@ mod tests { color: Color::WHITE, bold: false, italic: false, + background: false, + font: String::new(), range: TimeRange::new(1.0, 3.0), }); p diff --git a/docs/UI-Upgrade-Plan.md b/docs/UI-Upgrade-Plan.md new file mode 100644 index 0000000..389f9d7 --- /dev/null +++ b/docs/UI-Upgrade-Plan.md @@ -0,0 +1,189 @@ +# UI Upgrade Plan — learning from palmier-pro + +Status: **proposal** (no code changed yet). Author pass: 2026-06-19. + +A plan to raise Vuoom's editor UI from "clean and functional" to "feels like a paid +pro tool", informed by a deep read of the open-source **palmier-pro** editor +(`D:\palmier-pro`, a native macOS Swift/AppKit NLE) used purely as a UX benchmark. + +## Framing & guardrails + +palmier-pro is a **multi-track NLE with AI generation** on a different stack (Swift/AppKit +vs Vuoom's Tauri + SolidJS). We copy **patterns and ergonomics, not code or scope.** + +Respect Vuoom's settled identity (`vuoom-design-language`): mono-first themes, **no purple**, +record-red `#e5484d` as the only accent, the V+record-dot logo. Everything below stays inside +that language — we add *structure and motion*, not new brand colors. + +**Explicitly out of scope** (would bloat the record→GIF focus): multi-track timeline, audio +tracks/waveforms, linked clips, razor/ripple tools, AI generation catalog, in-app agent chat +(the MCP **AI Demo Director** is the better moat). Keep the record-overlay flow — it already +beats palmier's import-first flow. + +## Workflow + +Per `vuoom-workflow-rules`: builds/tests run on **CI only**; **commit + push after every +change**. Each numbered item below lands as its own commit. Items are ordered by dependency — +the token layer (Phase 0) underpins the rest. + +--- + +## Phase 0 — Design-token layer (foundation) + +**Why:** Today `src/App.css` defines only `--bg/--panel/--line/--text/--muted/--accent`, one +radius pair, and a font scale (`App.css:6–82`). palmier's polish comes from one disciplined +token system (`AppTheme.swift`): a spacing scale, a 4-step elevation ramp, 3 shadow tiers, and +exactly 2 motion durations. Consistency *is* the premium feel. + +**Add to `:root` (per theme where color-bearing):** + +| Token group | Tokens | Notes | +|---|---|---| +| Spacing | `--sp-2 --sp-4 --sp-6 --sp-8 --sp-10 --sp-12 --sp-16 --sp-20 --sp-24` | even-only; screen edges always `--sp-24` | +| Elevation | extend `--panel` → `--surface --raised --prominent` | neutral, theme-aware; layers panels/cards/popovers | +| Shadow | `--shadow-sm` (r1) · `--shadow-md` (r4/y2) · `--shadow-lg` (r24/y8) | only three, like palmier | +| Motion | `--dur-hover: .15s` · `--dur-transition: .2s` | only two; reuse everywhere | +| Radius | keep `--radius`/`--radius-lg`; add `--radius-sm` (6) `--radius-xs` (3) | continuous feel | + +**Acceptance:** no view hardcodes a px spacing/shadow/duration that a token covers; all 5 +themes still pass a visual sanity check; zero behavior change. + +--- + +## Phase 1 — Inspector ergonomics (highest impact) + +The inspector is the surface that most reads as "settings form" today (`App.tsx:2156–2472`). +Three changes turn it pro. + +### 1a. `ScrubField` component — drag-or-type number control +palmier's single best idea: every numeric value is **drag horizontally to scrub OR click to +type**, with **Shift = ×10 coarse, Ctrl = ×0.1 fine**, live preview during drag, one coalesced +undo on release, and `—` for mixed/empty. + +- New `src/ScrubField.tsx`: a `
` capturing `pointermove` deltas → value; swaps to an + `` on click (drag < 3px). Props: `value, min, max, step, sensitivity, suffix, + onInput (live), onCommit (undo boundary)`. +- Replace in the inspector: zoom **Strength** (`App.tsx:2376`), box **Opacity** (`2242`), + **Thickness** (`2286`), text **Size** (`2196`), speed **factor** (`2432`), and the + **Timing** number inputs (`2324–2346`). +- Reuse the existing throttled `pushEdit`/`refresh` path for live vs commit (`App.tsx:611`). + +**Acceptance:** dragging any field scrubs with modifier precision and previews live; one undo +step per gesture; keyboard typing still works; touch/pointer captured cleanly. + +### 1b. Section/row grammar +palmier groups properties into `InspectorSection` (tiny **UPPERCASE letter-spaced muted** +header) + `InspectorRow` (label left, value right-aligned, **fixed row height** so everything +aligns). Vuoom's `.field` rows vary in height and mix full-width sliders with label rows. + +- Add `` and `` wrappers in `App.tsx` sub-components + (near `InspectorPanel`, `App.tsx:2840`). +- Re-group: Text → `STYLE / COLOR / TIMING`; Box → `SHAPE / FILL / COLOR / TIMING`; + Zoom → `STRENGTH / FOCUS`, etc. + +**Acceptance:** every inspector row shares one baseline grid; section headers consistent +across all five selection types. + +### 1c. Uniform hover/focus recipe +palmier applies one hover everywhere (scale `1.03`, shadow grows, border brightens, spring) and +an animated focus ring on the active panel. Define one `.hoverable` + `.panel-focus` in +`App.css` (using Phase-0 motion/shadow tokens) and apply to tool buttons, swatches, timeline +segments, and dialog buttons. + +**Acceptance:** all interactive chrome shares one hover/active treatment; the properties panel +shows a subtle focus ring when it holds the selection. + +--- + +## Phase 2 — Text annotations + +The text surface is the weakest vs palmier and the one called out directly. Today: Inter-only +(`App.tsx:2027`), bold/italic + size slider, **move-only** on canvas (no resize handles), +no background plate. + +### 2a. Bundled font set + in-typeface picker +palmier ships Anton, Bebas Neue, Space Grotesk, Playfair, Permanent Marker, etc. — that variety +is *why* their text looks designed. + +- Bundle ~6 display fonts via `@font-face` (woff2 in `src/assets/fonts/`), OFL-licensed. +- Font picker where **each item renders in its own typeface**; ideally hover-preview on canvas + with revert-on-cancel (palmier's `FontPickerField`). A styled static dropdown is an + acceptable first cut. +- Backend: thread a `font` field through the text annotation (`add_text`/`update_text` in + `src-tauri`) and the renderer (`vuoom-render`) + SVG preview `font-family` (`App.tsx:2027`). + +### 2b. Background / outline / shadow plate (legibility) +Captions over busy screen-recording content are unreadable without a backing. palmier's TEXT +tab offers Background / Border / Shadow as color+switch rows. Add at least a **background plate** +(color + opacity + padding + corner radius) to the text annotation model, renderer, and +inspector. + +### 2c. On-canvas text resize handles → font size +palmier resizes text by `fontScale` (never stretches). Vuoom's selected text draws only an +outline (`App.tsx:2034–2042`). Add corner handles that map drag → `font_size`, plus the same +move behavior. Reuse the box/arrow handle machinery (`handleAt`, `App.tsx:658`). + +### 2d. Canvas snap guides +When dragging text (or any annotation), snap center/edges to the canvas center/edges and flash +a 1px guide-line (palmier draws magenta center guides). Lightweight in normalized space. + +**Acceptance:** can pick from several fonts and see them on canvas; text stays legible over +any recording via a background plate; corner-drag scales the glyphs without distortion; drag +snaps to canvas center with a visible guide. + +--- + +## Phase 3 — Timeline feel + +The timeline drags are free-floating today (zoom/speed/cut/note bars, `App.tsx:2651–2785`), +so alignment is fiddly. palmier's timeline *snaps*. + +### 3a. Snapping with a visual guide +- Snap any dragged band's edges to: the **playhead**, **ruler ticks**, and **other bands' + edges**. Threshold **pixel-constant** (convert px → time via `tlEl` width) so it's + zoom-independent; **sticky** break-away (must move ~2.5× to unstick), like palmier's + `SnapEngine`. +- Flash a 1px vertical snap-line at the catch point; small scale-pop on the band. (No trackpad + haptics on web — the visual sells precision instead.) +- Hook into the existing `onZoomMove/onSpeedMove/onCutMove/onAnnMove/onTrimMove` handlers. + +### 3b. Adaptive ruler ticks +Replace fixed `ticks()` with palmier's approach: target ~80px between majors, snap to nice +values `[1,2,5,10,15,30,60…]`s; draw a taller mid minor tick. Cleaner ruler at any duration. + +**Acceptance:** dragging a band catches the playhead/ticks/neighbors with a visible guide and a +sticky feel; the ruler shows sensible, evenly-spaced labels at short and long durations. + +--- + +## Phase 4 — Starting UI / onboarding + +The empty editor is a missed opportunity (`App.tsx:1887`). Keep it single-window — do **not** +build palmier's full sidebar home. + +### 4a. Recents strip on the empty canvas +palmier's project cards: ~5:4 thumbnail, bottom gradient for legibility, **relative date** +("3 days ago"), hover-scale, right-click → Open / Reveal / Delete. Vuoom already persists +sessions (`check_recovery`, `App.tsx:347`) but only surfaces one "Recover last session" button +(`App.tsx:1902`). Replace with a small **recents grid** of cards (needs a backend list + +thumbnail; a first frame of each recording works). + +### 4b. One-time welcome + coachmark +palmier gates a welcome card on `hasSeenWelcome` then offers a spotlight tour. For Vuoom's small +surface, a single centered glass card (value prop + "Record your screen" CTA) plus an optional +**4-step sequential coachmark** (Record → auto-zoom → annotate → export) anchored to the real +controls is enough. Persist a `vuoom-seen-welcome` flag in `localStorage` (like `themes.ts`). + +**Acceptance:** first launch shows the welcome once; the empty state lists recent recordings as +clickable cards with relative dates and a context menu; the tour can be skipped and never +re-nags. + +--- + +## What we deliberately skip +Multi-track, audio waveforms, linked clips, razor/ripple, keyframe animation lanes, AI +generation, in-app chat. These belong to palmier's NLE scope, not Vuoom's record→GIF job. + +## Rough sequence +Phase 0 → 1 (1a is the big win) → 2 → 3 → 4. Phases 0–1 alone make the app *feel* two tiers +higher with no change to what it does. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 399dce9..578e2ca 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -559,10 +559,12 @@ pub fn update_text( font_size: Option, bold: Option, italic: Option, + background: Option, + font: Option, ) -> Result<(), String> { engine .session()? - .update_text(id, x, y, text, font_size, bold, italic) + .update_text(id, x, y, text, font_size, bold, italic, background, font) } /// Move an arrow's endpoints. diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 5234173..a9a2892 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -712,6 +712,8 @@ impl Session { color: Color::WHITE, bold: true, // labels over video read best bold; toggleable in the inspector italic: false, + background: false, + font: String::new(), range, }); Ok(id) @@ -1128,6 +1130,7 @@ impl Session { /// Move/edit a text label. `None` fields keep their current value. #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] pub fn update_text( &self, id: u32, @@ -1137,6 +1140,8 @@ impl Session { font_size: Option, bold: Option, italic: Option, + background: Option, + font: Option, ) -> Result<(), String> { // Typing and the size slider fire per keystroke / per tick — coalesce each run // into one undo step. Geometry / style commits stay discrete. @@ -1171,6 +1176,12 @@ impl Session { if let Some(i) = italic { a.italic = i; } + if let Some(bg) = background { + a.background = bg; + } + if let Some(f) = font { + a.font = f; + } Ok(()) }) } diff --git a/src/App.css b/src/App.css index 22cdd09..dda5891 100644 --- a/src/App.css +++ b/src/App.css @@ -6,6 +6,7 @@ --bg: #0e0e0f; --panel: #161617; --panel-2: #1e1e20; + --prominent: #26262a; --line: #2a2a2d; --text: #f2f2f3; --muted: #8d8d93; @@ -19,6 +20,7 @@ --bg: #ffffff; --panel: #f7f7f8; --panel-2: #efeff1; + --prominent: #ffffff; --line: #e3e3e6; --text: #161617; --muted: #6b6b71; @@ -32,6 +34,7 @@ --bg: #1a1c1e; --panel: #212427; --panel-2: #2a2e31; + --prominent: #33383c; --line: #34383c; --text: #e8eaec; --muted: #9aa0a6; @@ -45,6 +48,7 @@ --bg: #f6f4ef; --panel: #efece4; --panel-2: #e6e2d8; + --prominent: #f1eee7; --line: #ddd8cc; --text: #2b2a26; --muted: #7a766c; @@ -58,6 +62,7 @@ --bg: #0b0f17; --panel: #111722; --panel-2: #18202c; + --prominent: #1f2a39; --line: #232c3a; --text: #e6ebf2; --muted: #8a93a3; @@ -72,6 +77,9 @@ --record: #e5484d; --note-box: #ffd23f; --note-text: #6ea8ff; + /* Radius ramp (continuous-feel corners), small → large. */ + --radius-xs: 3px; + --radius-sm: 6px; --radius: 9px; --radius-lg: 14px; /* One deliberate type scale, used everywhere instead of ad-hoc px sizes. */ @@ -80,10 +88,57 @@ --fs-md: 13px; /* body: buttons, inputs, primary controls */ --fs-sm: 12px; /* secondary labels, hints, chips */ --fs-xs: 11px; /* micro: timeline ticks, badges, track labels */ + /* Even-only spacing scale. Screen edges use --sp-24; controls use --sp-6/8/10. */ + --sp-2: 2px; + --sp-4: 4px; + --sp-6: 6px; + --sp-8: 8px; + --sp-10: 10px; + --sp-12: 12px; + --sp-16: 16px; + --sp-20: 20px; + --sp-24: 24px; + /* Three elevation shadow tiers (resting → floating → modal). */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.18); + --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.22); + --shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.42); + /* Exactly two motion durations + one easing — consistency is the polish. */ + --dur-hover: 0.15s; + --dur-transition: 0.2s; + --ease: cubic-bezier(0.2, 0.6, 0.2, 1); font-family: "Inter Variable", Inter, system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--text); } +/* Bundled display fonts for text annotations (OFL / Apache; see assets/fonts/). The same + files are loaded into the glyphon renderer so the export matches this preview. */ +@font-face { + font-family: "Anton"; + src: url("../assets/fonts/Anton-Regular.ttf") format("truetype"); + font-display: swap; +} +@font-face { + font-family: "Bebas Neue"; + src: url("../assets/fonts/BebasNeue-Regular.ttf") format("truetype"); + font-display: swap; +} +@font-face { + font-family: "Poppins"; + src: url("../assets/fonts/Poppins-Bold.ttf") format("truetype"); + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: "Permanent Marker"; + src: url("../assets/fonts/PermanentMarker-Regular.ttf") format("truetype"); + font-display: swap; +} +@font-face { + font-family: "Shrikhand"; + src: url("../assets/fonts/Shrikhand-Regular.ttf") format("truetype"); + font-display: swap; +} + * { box-sizing: border-box; } @@ -428,7 +483,8 @@ kbd { font-size: var(--fs-xs); font-weight: 500; cursor: pointer; - transition: background 0.1s ease, color 0.1s ease; + transition: background var(--dur-hover) var(--ease), color var(--dur-hover) var(--ease), + transform var(--dur-hover) var(--ease), box-shadow var(--dur-hover) var(--ease); } .tool svg { pointer-events: none; @@ -436,6 +492,8 @@ kbd { .tool:hover:not(:disabled) { background: var(--hover); color: var(--text); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } .tool.active { background: var(--hover); @@ -537,9 +595,58 @@ kbd { font-size: var(--fs-sm); color: var(--muted); } -.btn.recover { - margin-top: 14px; - border-style: dashed; +/* Recents — pick up where you left off (the recoverable session as a card). */ +.recents { + margin-top: 22px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-10); +} +.recents-label { + font-size: var(--fs-sm); + color: var(--muted); +} +.recent-card { + display: flex; + align-items: center; + gap: var(--sp-12); + width: 220px; + padding: var(--sp-10); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel-2); + color: var(--text); + text-align: left; + cursor: pointer; + transition: border-color var(--dur-hover) var(--ease), transform var(--dur-hover) var(--ease), + box-shadow var(--dur-hover) var(--ease); +} +.recent-card:hover { + border-color: var(--muted); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} +.recent-thumb { + flex: none; + width: 56px; + height: 46px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + background: linear-gradient(150deg, var(--canvas-a), var(--canvas-b)); + color: var(--muted); +} +.recent-meta strong { + display: block; + font-size: var(--fs-md); +} +.recent-meta small { + display: block; + margin-top: 2px; + font-size: var(--fs-sm); + color: var(--muted); + font-variant-numeric: tabular-nums; } /* ---- Properties ---- */ @@ -550,6 +657,18 @@ kbd { padding: 16px; overflow-y: auto; } +/* Accent spine: marks the inspector as the active editing surface. */ +.properties::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: color-mix(in srgb, var(--accent) 38%, transparent); + pointer-events: none; + z-index: 6; +} .panel-resizer { position: absolute; left: -3px; @@ -621,6 +740,10 @@ kbd { stroke: rgba(0, 0, 0, 0.45); stroke-width: 3px; } +/* Translucent plate behind a text label (legibility over busy footage). */ +.text-plate { + fill: rgba(13, 13, 15, 0.7); +} .handle { fill: var(--accent); stroke: var(--accent-text); @@ -634,6 +757,14 @@ kbd { stroke-width: 1.5; stroke-dasharray: 4 3; } +/* Canvas alignment guides (center/edge snap while dragging an annotation). */ +.canvas-snap { + stroke: var(--accent); + stroke-width: 1; + stroke-dasharray: 5 4; + opacity: 0.75; + pointer-events: none; +} /* Zoom focus crosshair */ .focus-reticle { cursor: move; @@ -698,6 +829,156 @@ kbd { background: var(--panel-2); cursor: pointer; } + +/* ---- Inspector grammar: tiny-caps sections + aligned rows ---- */ +.insp-section + .insp-section { + margin-top: var(--sp-12); + padding-top: var(--sp-12); + border-top: 1px solid var(--line); +} +.insp-section-title { + margin-bottom: var(--sp-8); + font-size: var(--fs-xs); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} +.insp-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-10); + min-height: 30px; +} +.insp-row + .insp-row { + margin-top: var(--sp-6); +} +/* A row whose control needs the full width (text input, swatches) stacks instead. */ +.insp-row.stack { + flex-direction: column; + align-items: stretch; + gap: var(--sp-6); +} +.insp-row-label { + flex: none; + font-size: var(--fs-sm); + color: var(--muted); +} +.insp-row.stack .insp-row-label { + align-self: flex-start; +} +.insp-row-value { + display: flex; + align-items: center; + gap: var(--sp-6); + min-width: 0; +} +.insp-row.stack .insp-row-value { + align-self: stretch; +} + +/* ---- Scrubbable number field (drag to scrub · click to type) ---- */ +.scrub { + display: inline-flex; + align-items: baseline; + justify-content: flex-end; + gap: 1px; + min-width: 56px; + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel-2); + color: var(--text); + font-size: var(--fs-md); + font-weight: 600; + font-variant-numeric: tabular-nums; + cursor: ew-resize; + touch-action: none; + user-select: none; + transition: background var(--dur-hover) var(--ease), border-color var(--dur-hover) var(--ease); +} +.scrub:hover { + background: var(--prominent); + border-color: var(--muted); +} +.scrub.disabled { + opacity: 0.45; + cursor: default; + pointer-events: none; +} +.scrub.mixed .scrub-val { + color: var(--muted); +} +.scrub-suffix { + font-size: var(--fs-sm); + font-weight: 500; + color: var(--muted); +} +.scrub-input { + width: 72px; + padding: 5px 9px; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--panel-2); + color: var(--text); + font-family: inherit; + font-size: var(--fs-md); + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + outline: none; +} +/* Full-width controls that live inside a stacked inspector row. */ +.insp-text-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel-2); + color: var(--text); + font-family: inherit; + font-size: var(--fs-md); +} +.insp-row .swatch-row { + margin-bottom: 0; +} +.insp-row input[type="color"] { + width: 100%; + height: 30px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel-2); + cursor: pointer; +} +/* In-typeface font picker — each button renders its own font. */ +.font-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--sp-6); +} +.fontbtn { + padding: 8px 4px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--panel-2); + color: var(--text); + font-size: var(--fs-sm); + line-height: 1.1; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + transition: border-color var(--dur-hover) var(--ease), background var(--dur-hover) var(--ease); +} +.fontbtn:hover { + background: var(--hover); +} +.fontbtn.on { + border-color: var(--accent); + outline: 1px solid var(--accent); +} /* Bold / italic toggles */ .style-row { display: flex; @@ -745,6 +1026,11 @@ kbd { border-radius: 50%; cursor: pointer; padding: 0; + transition: transform var(--dur-hover) var(--ease), box-shadow var(--dur-hover) var(--ease); +} +.swatchbtn:hover { + transform: scale(1.12); + box-shadow: var(--shadow-sm); } .swatchbtn.active { outline: 2px solid var(--accent); @@ -967,9 +1253,16 @@ kbd { .tl-tick i { display: block; width: 1px; - height: 6px; + height: 4px; background: var(--line); } +.tl-tick.mid i { + height: 7px; +} +.tl-tick.major i { + height: 9px; + background: color-mix(in srgb, var(--muted) 70%, var(--line)); +} .tl-track { position: relative; height: 32px; @@ -1254,6 +1547,17 @@ kbd { background: var(--accent); } +/* Snap guide — a dashed accent line that flashes where a dragged band catches. */ +.tl-snapguide { + position: absolute; + top: 0; + bottom: 0; + width: 0; + border-left: 1px dashed var(--accent); + opacity: 0.85; + pointer-events: none; + z-index: 5; +} .tl-playhead { position: absolute; top: 0; @@ -1320,7 +1624,8 @@ kbd { font-weight: 600; cursor: pointer; text-align: left; - transition: border-color 0.1s ease; + transition: border-color var(--dur-hover) var(--ease), transform var(--dur-hover) var(--ease), + box-shadow var(--dur-hover) var(--ease); } .chip small { font-weight: 400; @@ -1329,6 +1634,8 @@ kbd { } .chip:hover { border-color: var(--muted); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } .chip.active { border-color: var(--accent); @@ -1393,6 +1700,111 @@ kbd { margin-bottom: 10px; } +/* ============================================================ + Onboarding — first-run welcome card + Record coachmark + ============================================================ */ +.welcome-backdrop { + z-index: 60; +} +.welcome-card { + width: min(520px, 92vw); + padding: 28px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + text-align: center; +} +.welcome-title { + margin: 18px 0 8px; + font-size: var(--fs-xl); + font-weight: 650; + letter-spacing: -0.01em; +} +.welcome-sub { + margin: 0 auto 22px; + max-width: 400px; + font-size: var(--fs-md); + line-height: 1.55; + color: var(--muted); +} +.welcome-steps { + display: flex; + flex-direction: column; + gap: var(--sp-12); + margin-bottom: var(--sp-24); + text-align: left; +} +.welcome-step { + display: flex; + align-items: flex-start; + gap: var(--sp-12); +} +.welcome-num { + flex: none; + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--panel-2); + border: 1px solid var(--line); + font-size: var(--fs-sm); + font-weight: 700; + color: var(--text); +} +.welcome-step strong { + display: block; + font-size: var(--fs-md); +} +.welcome-step small { + display: block; + margin-top: 2px; + font-size: var(--fs-sm); + color: var(--muted); + line-height: 1.4; +} +.welcome-actions { + display: flex; + justify-content: center; + gap: var(--sp-10); +} +.welcome-cta { + margin-top: 0; +} +/* Coachmark bubble pointing up at the Record button. */ +.coachmark { + position: fixed; + z-index: 55; + max-width: 250px; + padding: 12px 14px; + background: var(--prominent); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-md); +} +.coach-arrow { + position: absolute; + top: -6px; + left: 22px; + width: 11px; + height: 11px; + background: var(--prominent); + border-left: 1px solid var(--line); + border-top: 1px solid var(--line); + transform: rotate(45deg); +} +.coachmark p { + margin: 0 0 10px; + font-size: var(--fs-sm); + line-height: 1.45; + color: var(--text); +} +.coach-dismiss { + padding: 5px 12px; + font-size: var(--fs-sm); +} + /* ============================================================ Responsive — small laptops through large / 4K displays ============================================================ */ diff --git a/src/App.tsx b/src/App.tsx index 9539556..76d1fe8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import ThemeMenu from "./ThemeMenu"; import { applyTheme, initialTheme } from "./themes"; import { PreviewClient } from "./preview"; import { LogoWordmark } from "./Logo"; +import ScrubField from "./ScrubField"; import "./App.css"; type Tool = "select" | "text" | "arrow" | "line" | "shape" | "highlight"; @@ -45,6 +46,8 @@ interface TextAnn { color: Color; bold: boolean; italic: boolean; + background: boolean; + font: string; range: TimeRange; } interface ArrowAnn { @@ -140,6 +143,19 @@ interface Selection { /// Quick-pick annotation colors (white, ink, record red, box yellow, green, text blue). const PRESET_COLORS = ["#ffffff", "#0e0e0f", "#e5484d", "#ffd23f", "#30a46c", "#6ea8ff"]; +/// Bundled text fonts. `id` is the family name sent to the renderer (empty = default sans); +/// `css` styles the on-canvas preview + the in-typeface picker. Mirrors the @font-face set +/// in App.css and the fonts loaded into glyphon for export. +const TEXT_FONTS: { id: string; label: string; css: string }[] = [ + { id: "", label: "Default", css: "Inter, sans-serif" }, + { id: "Anton", label: "Anton", css: "Anton, sans-serif" }, + { id: "Bebas Neue", label: "Bebas", css: "'Bebas Neue', sans-serif" }, + { id: "Poppins", label: "Poppins", css: "Poppins, sans-serif" }, + { id: "Permanent Marker", label: "Marker", css: "'Permanent Marker', cursive" }, + { id: "Shrikhand", label: "Shrikhand", css: "Shrikhand, serif" }, +]; +const fontCss = (name: string) => TEXT_FONTS.find((f) => f.id === name)?.css ?? "Inter, sans-serif"; + const TOOLS: { id: Tool; label: string; key: string; code: string; hint: string }[] = [ { id: "select", label: "Select", key: "V", code: "KeyV", hint: "Click an element to select, drag to move, drag a handle to resize. (V)" }, { id: "text", label: "Text", key: "T", code: "KeyT", hint: "Click on the video to drop a text label. (T)" }, @@ -194,6 +210,9 @@ type Drag = | { mode: "create-highlight"; start: Vec2; cur: Vec2 } | { mode: "move"; kind: Kind; id: number; grab: Vec2; orig: number[]; geom: number[] } | { mode: "resize"; kind: Kind; id: number; handle: string; orig: number[]; geom: number[] } + // Corner-dragging a text label scales its font size (anchored to the opposite corner), + // so text scales typographically instead of stretching. + | { mode: "scale-text"; id: number; anchor: Vec2; startFont: number; startDist: number; cur: number } | null; function App() { @@ -228,6 +247,15 @@ function App() { const [drag, setDrag] = createSignal(null); const [stage, setStage] = createSignal({ w: 1, h: 1 }); const [frameAspect, setFrameAspect] = createSignal(16 / 9); + // Pixel width of the timeline track surface — drives adaptive ruler ticks and the + // pixel-constant snap threshold (kept in sync via a ResizeObserver in onMount). + const [tlWidth, setTlWidth] = createSignal(800); + // While a timeline band is dragged, the time it snapped to (for the guide line), or null. + const [snapGuide, setSnapGuide] = createSignal(null); + // While an annotation is dragged on the canvas, the normalized x/y of an active + // center/edge snap guide (or null). Drawn as crosshair lines on the overlay. + const [snapX, setSnapX] = createSignal(null); + const [snapY, setSnapY] = createSignal(null); const [showExport, setShowExport] = createSignal(false); const [recordPhase, setRecordPhase] = createSignal<"idle" | "active">("idle"); const [backdrop, setBackdrop] = createSignal(null); @@ -235,6 +263,11 @@ function App() { // Auto-update: a pending update (if any) and whether we're mid-download. const [update, setUpdate] = createSignal(null); const [updating, setUpdating] = createSignal(false); + // First-run onboarding: a one-time welcome card, then a coachmark pointing at Record. + const [showWelcome, setShowWelcome] = createSignal(false); + const [coachRecord, setCoachRecord] = createSignal(false); + const [coachPos, setCoachPos] = createSignal({ x: 0, y: 0 }); + let recordBtnEl: HTMLButtonElement | undefined; const preview = new PreviewClient(); let canvasEl: HTMLCanvasElement | undefined; @@ -323,6 +356,14 @@ function App() { ro.observe(stageEl); onCleanup(() => ro.disconnect()); } + if (tlEl) { + const tro = new ResizeObserver(() => { + if (tlEl) setTlWidth(tlEl.clientWidth || 800); + }); + tro.observe(tlEl); + setTlWidth(tlEl.clientWidth || 800); + onCleanup(() => tro.disconnect()); + } await connectEngine(); void checkForUpdate(); }); @@ -343,6 +384,7 @@ function App() { preview.connect(port); setStatus("Ready — press Record to capture your screen"); hideSplash(); + maybeShowWelcome(); // A previous session's frames are still on disk (crash or accidental close)? invoke("check_recovery") .then((d) => setRecoverable(d ?? null)) @@ -401,6 +443,29 @@ function App() { } }; + // ── first-run onboarding ─────────────────────────────────────────────────────── + const maybeShowWelcome = () => { + try { + if (!localStorage.getItem("vuoom-seen-welcome")) setShowWelcome(true); + } catch { + /* storage unavailable — skip onboarding */ + } + }; + // Dismiss the welcome card; `hint` pops a coachmark pointing at Record for skippers. + const dismissWelcome = (hint: boolean) => { + try { + localStorage.setItem("vuoom-seen-welcome", "1"); + } catch { + /* ignore */ + } + setShowWelcome(false); + if (hint && recordBtnEl) { + const r = recordBtnEl.getBoundingClientRect(); + setCoachPos({ x: r.left, y: r.bottom }); + setCoachRecord(true); + } + }; + onCleanup(() => { document.removeEventListener("contextmenu", onContextMenu); window.removeEventListener("keydown", onGlobalKey, true); @@ -653,6 +718,15 @@ function App() { await invoke("update_arrow", { id, fx: g[0], fy: g[1], tx: g[2], ty: g[3] }); else await invoke("update_text", { id, x: g[0], y: g[1] }); }; + // Approximate width of a text label in normalized-X space (glyph width is in height- + // fraction units; convert to width fraction). Shared by hit-testing and resize handles. + const textWNorm = (t: TextAnn) => + Math.max(t.text.length * t.font_size * 0.6 * (stage().h / Math.max(stage().w, 1)), 0.05); + // The live font size for a text label (the scale-text drag override, else the stored size). + const liveFont = (id: number, fallback: number) => { + const d = drag(); + return d && d.mode === "scale-text" && d.id === id ? d.cur : fallback; + }; // ── hit testing (normalized) ───────────────────────────────────────────────────── const TOL = () => 11 / Math.max(stage().w, stage().h); // ~11px grab radius in normalized space const handleAt = (p: Vec2): string | null => { @@ -669,6 +743,17 @@ function App() { } else if (s.kind === "arrow") { if (near(g[0], g[1])) return "from"; if (near(g[2], g[3])) return "to"; + } else if (s.kind === "text") { + const t = anns().texts.find((x) => x.id === s.id); + if (t) { + const pos = v2(t.pos); + const w = textWNorm(t); + const h = t.font_size; + if (near(pos.x, pos.y)) return "nw"; + if (near(pos.x + w, pos.y)) return "ne"; + if (near(pos.x, pos.y + h)) return "sw"; + if (near(pos.x + w, pos.y + h)) return "se"; + } } return null; }; @@ -686,12 +771,7 @@ function App() { for (const t of anns().texts) { if (!inView(t.range, false)) continue; const pos = v2(t.pos); - // font_size is a fraction of stage HEIGHT; convert the glyph width into - // width-normalized space or wide text on a wide stage can't be clicked. - const wApprox = Math.max( - t.text.length * t.font_size * 0.6 * (stage().h / Math.max(stage().w, 1)), - 0.05, - ); + const wApprox = textWNorm(t); // The glyphs sit between pos.y (top) and pos.y + font_size (baseline); pad by TOL. if ( p.x >= pos.x - TOL() && @@ -704,6 +784,60 @@ function App() { return null; }; + // Snap a moved annotation's geometry to the canvas edges/center (0, 0.5, 1) within a + // pixel-constant threshold, shifting the whole element and flashing crosshair guides. + const CANVAS_SNAPS = [0, 0.5, 1]; + const snapMoveGeom = (kind: Kind, g: number[]): number[] => { + const tx = 8 / Math.max(stage().w, 1); + const ty = 8 / Math.max(stage().h, 1); + let xs: number[]; + let ys: number[]; + if (kind === "box") { + xs = [g[0], g[0] + g[2] / 2, g[0] + g[2]]; + ys = [g[1], g[1] + g[3] / 2, g[1] + g[3]]; + } else if (kind === "arrow") { + xs = [g[0], g[2], (g[0] + g[2]) / 2]; + ys = [g[1], g[3], (g[1] + g[3]) / 2]; + } else { + xs = [g[0]]; + ys = [g[1]]; + } + let offX = 0; + let gx: number | null = null; + let bestX = tx; + for (const x of xs) + for (const s of CANVAS_SNAPS) { + const dd = Math.abs(x - s); + if (dd < bestX) { + bestX = dd; + offX = s - x; + gx = s; + } + } + let offY = 0; + let gy: number | null = null; + let bestY = ty; + for (const y of ys) + for (const s of CANVAS_SNAPS) { + const dd = Math.abs(y - s); + if (dd < bestY) { + bestY = dd; + offY = s - y; + gy = s; + } + } + const ng = g.slice(); + ng[0] += offX; + ng[1] += offY; + if (kind === "arrow") { + ng[2] += offX; + ng[3] += offY; + } + setSnapX(gx); + setSnapY(gy); + return ng; + }; + // ── pointer interaction on the overlay ─────────────────────────────────────────── const onPointerDown = async (e: PointerEvent) => { if (!hasClip()) return; @@ -756,6 +890,23 @@ function App() { const h = handleAt(p); if (h && selected()) { const s = selected()!; + if (s.kind === "text") { + // Corner-resize a text label = scale its font, anchored to the opposite corner. + const tx = anns().texts.find((x) => x.id === s.id)!; + const pos = v2(tx.pos); + const w = textWNorm(tx); + const ht = tx.font_size; + const opp: Record = { + nw: { x: pos.x + w, y: pos.y + ht }, + ne: { x: pos.x, y: pos.y + ht }, + sw: { x: pos.x + w, y: pos.y }, + se: { x: pos.x, y: pos.y }, + }; + const anchor = opp[h]; + const startDist = Math.hypot(p.x - anchor.x, p.y - anchor.y) || 1e-4; + setDrag({ mode: "scale-text", id: s.id, anchor, startFont: tx.font_size, startDist, cur: tx.font_size }); + return; + } const g = geomOf(s.kind, s.id); setDrag({ mode: "resize", kind: s.kind, id: s.id, handle: h, orig: g, geom: g.slice() }); return; @@ -787,6 +938,13 @@ function App() { setDrag({ ...d, cur: p }); return; } + if (d.mode === "scale-text") { + // Font scales with the cursor's distance from the anchored opposite corner. + const dist = Math.hypot(p.x - d.anchor.x, p.y - d.anchor.y); + const f = Math.min(0.2, Math.max(0.02, (d.startFont * dist) / d.startDist)); + setDrag({ ...d, cur: f }); + return; + } if (d.mode === "move") { const og = d.orig; const dx = p.x - d.grab.x; @@ -796,6 +954,7 @@ function App() { else if (d.kind === "arrow") g = [clamp01(og[0] + dx), clamp01(og[1] + dy), clamp01(og[2] + dx), clamp01(og[3] + dy)]; else g = [clamp01(og[0] + dx), clamp01(og[1] + dy)]; + g = snapMoveGeom(d.kind, g); setDrag({ ...d, geom: g }); } else if (d.mode === "resize") { const og = d.orig; @@ -819,6 +978,8 @@ function App() { const onPointerUp = async (e: PointerEvent) => { const d = drag(); if (!d) return; + setSnapX(null); + setSnapY(null); const p = norm(e); if (d.mode === "create-arrow" || d.mode === "create-line") { const isLine = d.mode === "create-line"; @@ -864,6 +1025,12 @@ function App() { setSelected({ kind: "box", id }); if (!toolLock()) setTool("select"); } + } else if (d.mode === "scale-text") { + const f = d.cur; + setDrag(null); + await invoke("update_text", { id: d.id, fontSize: f }); + await refresh(); + await pushSeek(playhead()); } else { // Commit the moved/resized geometry and refresh the source of truth BEFORE clearing // the drag, so the overlay never flashes back to the pre-drag position for a frame. @@ -954,7 +1121,12 @@ function App() { await refresh(); await pushSeek(playhead()); }; - const editTextStyle = async (patch: { bold?: boolean; italic?: boolean }) => { + const editTextStyle = async (patch: { + bold?: boolean; + italic?: boolean; + background?: boolean; + font?: string; + }) => { const s = selected(); if (s?.kind !== "text") return; await invoke("update_text", { id: s.id, ...patch }); @@ -1065,6 +1237,7 @@ function App() { // this window — the window is excluded from the capture and grown/shrunk by the backend, // so the overlay never lands in the recording and we avoid fragile extra webviews. const startRecord = async () => { + setCoachRecord(false); try { setStatus("Choose the area to record…"); await invoke("enter_overlay"); // hide editor from capture + go fullscreen @@ -1369,7 +1542,10 @@ function App() { }; const onTrimMove = (e: PointerEvent) => { if (!trimDrag || !tlEl) return; - const t = tlTime(e); + const raw = tlTime(e); + const hit = nearestTarget(raw, snapTargets({ kind: "trim" })); + setSnapGuide(hit); + const t = hit ?? raw; const cur = trim() ?? { start: 0, end: duration() }; const next = trimDrag === "start" @@ -1380,6 +1556,7 @@ function App() { setTrimState(next); }; const onTrimUp = async () => { + setSnapGuide(null); if (!trimDrag) return; trimDrag = null; const t = trim(); @@ -1435,10 +1612,12 @@ function App() { } else { end = Math.max(Math.min(duration(), end + dt), start + 0.2); } + ({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "zoom", idx: d.idx }), 0.2)); setZoomDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 }); }; const onZoomUp = async () => { const d = zoomDrag(); + setSnapGuide(null); if (!d) return; setZoomDrag(null); setSelected(null); @@ -1491,10 +1670,12 @@ function App() { } else { end = Math.max(Math.min(duration(), end + dt), start + 0.2); } + ({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "speed", idx: d.idx }), 0.2)); setSpeedDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 }); }; const onSpeedUp = async () => { const d = speedDrag(); + setSnapGuide(null); if (!d) return; setSpeedDrag(null); setSelected(null); @@ -1548,10 +1729,12 @@ function App() { } else { end = Math.max(Math.min(duration(), end + dt), start + 0.1); } + ({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "cut", idx: d.idx }), 0.1)); setCutDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 }); }; const onCutUp = async () => { const d = cutDrag(); + setSnapGuide(null); if (!d) return; setCutDrag(null); setSelected(null); @@ -1567,18 +1750,116 @@ function App() { }; const pct = (t: number) => (duration() > 0 ? (t / duration()) * 100 : 0); + // Adaptive ruler: pick a "nice" major interval targeting ~90px between labels, then a + // minor subdivision that keeps minor ticks ≥11px apart. The midpoint minor is drawn + // taller. Mirrors the spacing logic in pro editors instead of a fixed tick count. + const NICE_STEPS = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600]; + const pxPerSec = () => (duration() > 0 ? tlWidth() / duration() : 0); const tickStep = () => { - for (const s of [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]) { - if (duration() / s <= 12) return s; + const target = pxPerSec() > 0 ? 90 / pxPerSec() : duration(); + return NICE_STEPS.find((s) => s >= target) ?? NICE_STEPS[NICE_STEPS.length - 1]; + }; + const minorStep = (major: number) => { + for (const div of [5, 4, 2]) { + const s = major / div; + if (s * pxPerSec() >= 11) return s; + } + return major; + }; + const tickMarks = () => { + const d = duration(); + if (d <= 0) return []; + const major = tickStep(); + const minor = minorStep(major); + const out: { t: number; major: boolean; mid: boolean }[] = []; + const count = Math.floor(d / minor + 1e-9); + for (let i = 0; i <= count; i++) { + const t = i * minor; + const ratio = t / major; + const isMajor = Math.abs(ratio - Math.round(ratio)) < 1e-6; + const frac = ((t % major) + major) % major; + const isMid = !isMajor && Math.abs(frac - major / 2) < minor / 8; + out.push({ t, major: isMajor, mid: isMid }); } - return 120; - }; - const ticks = () => { - const s = tickStep(); - const out: number[] = []; - for (let t = 0; t <= duration() + 1e-9; t += s) out.push(t); return out; }; + + // ── timeline snapping (pixel-constant, zoom-independent) ──────────────────────── + // A dragged band's edges snap to the playhead, the clip bounds, major ruler ticks, and + // every other band's edges — within ~8px regardless of clip length — and a guide line + // flashes at the catch point. + const snapThresholdT = () => 8 / Math.max(pxPerSec(), 1e-6); + const snapTargets = (exclude?: { + kind: "zoom" | "speed" | "cut" | "ann" | "trim"; + idx?: number; + id?: number; + }): number[] => { + const ts: number[] = [0, duration(), playhead()]; + for (const m of tickMarks()) if (m.major) ts.push(m.t); + zooms().forEach((z, i) => { + if (!(exclude?.kind === "zoom" && exclude.idx === i)) ts.push(z.start, z.end); + }); + speed().forEach((r, i) => { + if (!(exclude?.kind === "speed" && exclude.idx === i)) ts.push(r.start, r.end); + }); + cuts().forEach((c, i) => { + if (!(exclude?.kind === "cut" && exclude.idx === i)) ts.push(c.start, c.end); + }); + annBars().forEach((b) => { + if (!(exclude?.kind === "ann" && exclude.id === b.id)) ts.push(b.start, b.end); + }); + return ts; + }; + const nearestTarget = (t: number, targets: number[]): number | null => { + let hit: number | null = null; + let bestD = snapThresholdT(); + for (const tg of targets) { + const dd = Math.abs(t - tg); + if (dd <= bestD) { + bestD = dd; + hit = tg; + } + } + return hit; + }; + // Snap a dragged band for the given mode; updates the guide and returns the new band. + const snapBand = ( + start: number, + end: number, + mode: "move" | "l" | "r", + targets: number[], + minGap: number, + ): { start: number; end: number } => { + const d = duration(); + if (mode === "l") { + const hit = nearestTarget(start, targets); + const v = hit === null ? start : Math.min(Math.max(0, hit), end - minGap); + setSnapGuide(hit !== null && v === hit ? hit : null); + return { start: v, end }; + } + if (mode === "r") { + const hit = nearestTarget(end, targets); + const v = hit === null ? end : Math.max(Math.min(d, hit), start + minGap); + setSnapGuide(hit !== null && v === hit ? hit : null); + return { start, end: v }; + } + // move: snap whichever edge lands closest to a target, shifting the whole band. + const hs = nearestTarget(start, targets); + const he = nearestTarget(end, targets); + const ds = hs !== null ? Math.abs(start - hs) : Infinity; + const de = he !== null ? Math.abs(end - he) : Infinity; + const len = end - start; + if (ds <= de && hs !== null && hs >= 0 && hs + len <= d) { + setSnapGuide(hs); + return { start: hs, end: hs + len }; + } + if (he !== null && he - len >= 0 && he <= d) { + setSnapGuide(he); + return { start: he - len, end: he }; + } + setSnapGuide(null); + return { start, end }; + }; // Annotation bar dragging: grab the middle to move it in time, the edges to resize // how long it stays on screen. const [annDrag, setAnnDrag] = createSignal<{ @@ -1623,10 +1904,12 @@ function App() { } else { end = Math.max(Math.min(duration(), end + dt), start + 0.2); } + ({ start, end } = snapBand(start, end, d.mode, snapTargets({ kind: "ann", id: d.id }), 0.2)); setAnnDrag({ ...d, cur: { start, end }, moved: d.moved || Math.abs(dt) > 0.02 }); }; const onAnnUp = async () => { const d = annDrag(); + setSnapGuide(null); if (!d) return; setAnnDrag(null); setSelZoom(null); @@ -1749,6 +2032,7 @@ function App() { +
+ Pick up where you left off + +
@@ -2015,16 +2315,27 @@ function App() { {(() => { const g = () => liveGeom("text", tx.id); const p = () => px({ x: g()[0], y: g()[1] }); - const fs = () => tx.font_size * stage().h; + const fs = () => liveFont(tx.id, tx.font_size) * stage().h; + const wbox = () => Math.max(40, tx.text.length * fs() * 0.6); return ( + + + + ); @@ -2117,6 +2436,26 @@ function App() { ); })()} + + {/* Canvas alignment guides — flash when a dragged element snaps. */} + + + + + + @@ -2131,6 +2470,7 @@ function App() { left: `${p.x}px`, top: `${p.y}px`, "font-size": `${fs}px`, + "font-family": fontCss(ta.font), "font-weight": ta.bold ? "700" : "400", "font-style": ta.italic ? "italic" : "normal", }} @@ -2162,190 +2502,226 @@ function App() { onResizeUp={onInspUp} > - -
- Style -
- - -
-
- + + + void editText(e.currentTarget.value)} + /> + + +
+ + + +
+
+ +
+ + {(f) => ( + + )} + +
+
+ + void editFontSize(v)} + onCommit={(v) => void editFontSize(v)} + /> + +
-
- Shape -
- - -
-
-
- Fill -
- - -
-
- + + +
+ + +
+
+ +
+ + +
+
+ + void setOpacity(v)} + onCommit={(v) => void setOpacity(v)} + /> + + + + void editStyle({ thickness: v })} + onCommit={(v) => void editStyle({ thickness: v })} + /> + + +
-
- Ends -
- - - -
-
-
- - + + +
+ + + +
+
+ + void editStyle({ thickness: v })} + onCommit={(v) => void editStyle({ thickness: v })} + /> + +
-
- Color -
- - {(c) => ( -
- void setColor(e.currentTarget.value)} - /> -
+ + +
+ + {(c) => ( +
+ void setColor(e.currentTarget.value)} + /> +
+
-
- Timing (seconds) -
- + + - void editRange(Number(e.currentTarget.value), selectedRange()!.end) - } - /> - - void editRange(v, selectedRange()!.end)} + /> + + + - void editRange(selectedRange()!.start, Number(e.currentTarget.value)) - } + min={0} + max={duration()} + step={0.1} + suffix="s" + title="When this disappears — drag to scrub, click to type" + onCommit={(v) => void editRange(selectedRange()!.start, v)} /> -
-
+ +
@@ -2372,41 +2748,42 @@ function App() { onResizeMove={onInspMove} onResizeUp={onInspUp} > - -
- Focus -
- - -
-
+ /> + + +
+ + +
+
+

Drag the crosshair on the video to aim this zoom.

@@ -2428,20 +2805,22 @@ function App() { onResizeMove={onInspMove} onResizeUp={onInspUp} > - + + + { + const r = selectedSpeed()!; + void applySpeedEdit(selSpeed()!, r.start, r.end, v); + }} + /> + +

{fmt(selectedSpeed()!.start)} – {fmt(selectedSpeed()!.end)} · drag the band on the timeline to retime, drag its edges to resize. @@ -2639,11 +3018,15 @@ function App() { fallback={

Your recording's timeline appears here
} >
- - {(t) => ( - + + {(m) => ( + - {fmt(t)} + {fmt(m.t)} )} @@ -2787,6 +3170,10 @@ function App() {
+ + +
+
@@ -2812,6 +3199,70 @@ function App() { onCancel={onRecordCancel} />
+ + {/* First-run welcome card. */} + + + + + {/* Coachmark pointing at the Record button for users who skipped the welcome. */} + +
+ +

+ Start here — or press Ctrl+Shift+R any time. +

+ +
+
); } @@ -2865,6 +3316,26 @@ function InspectorPanel(props: { ); } +/** A titled inspector group — a tiny uppercase header over its rows. */ +function InspSection(props: { title: string; children: JSX.Element }): JSX.Element { + return ( +
+
{props.title}
+ {props.children} +
+ ); +} + +/** One inspector row: a label on the left, the control right-aligned (or stacked). */ +function InspRow(props: { label: string; stack?: boolean; children: JSX.Element }): JSX.Element { + return ( +
+ {props.label} +
{props.children}
+
+ ); +} + function LockIcon(props: { locked: boolean }): JSX.Element { return ( diff --git a/src/ScrubField.tsx b/src/ScrubField.tsx new file mode 100644 index 0000000..971faef --- /dev/null +++ b/src/ScrubField.tsx @@ -0,0 +1,145 @@ +import { createSignal, Show, type JSX } from "solid-js"; + +/** + * A numeric field you can **drag to scrub** or **click to type** — the core inspector + * ergonomic borrowed from pro editors. Horizontal drag changes the value; hold Shift for + * a coarse (×8) sweep or Ctrl/Cmd for fine (×0.15) control. `onInput` fires live during + * the gesture (for preview); `onCommit` fires once at the end (the undo boundary). A + * `null` value renders an em-dash for mixed/unknown selections. + */ +export interface ScrubFieldProps { + value: number | null; + min: number; + max: number; + /** Snap grid in stored units. Also sets the displayed precision. */ + step: number; + /** Value change per pixel of drag. Default spreads the range over ~320px. */ + sensitivity?: number; + /** Multiplies the stored value for display (e.g. 100 shows a 0–1 value as a percent). */ + displayScale?: number; + suffix?: string; + disabled?: boolean; + title?: string; + /** Continuous, during scrub/type — use for a live preview. */ + onInput?: (v: number) => void; + /** Once, when the gesture ends (release / Enter / blur) — the undo boundary. */ + onCommit: (v: number) => void; +} + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); +const decimalsOf = (step: number) => { + if (!Number.isFinite(step) || step <= 0) return 0; + return clamp(Math.ceil(-Math.log10(step)), 0, 4); +}; + +export default function ScrubField(props: ScrubFieldProps): JSX.Element { + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(""); + // Overrides props.value while a drag is in flight, so the readout tracks the cursor + // even before the parent round-trips the committed value back through props. + const [live, setLive] = createSignal(null); + + const scale = () => props.displayScale ?? 1; + const sens = () => props.sensitivity ?? (props.max - props.min) / 320; + const snap = (v: number) => { + const n = Math.round(v / props.step) * props.step; + return Number(n.toFixed(decimalsOf(props.step) + 2)); + }; + const fmt = (storeVal: number) => + (storeVal * scale()).toFixed(decimalsOf(props.step * scale())); + const shown = () => live() ?? props.value; + + let startX = 0; + let startVal = 0; + let moved = false; + let activePointer = -1; + + const onPointerDown = (e: PointerEvent) => { + if (props.disabled || editing()) return; + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + activePointer = e.pointerId; + startX = e.clientX; + startVal = props.value ?? (props.min + props.max) / 2; + moved = false; + }; + const onPointerMove = (e: PointerEvent) => { + if (activePointer !== e.pointerId) return; + const dx = e.clientX - startX; + if (!moved && Math.abs(dx) < 3) return; + moved = true; + const mod = e.shiftKey ? 8 : e.ctrlKey || e.metaKey ? 0.15 : 1; + const v = clamp(snap(startVal + dx * sens() * mod), props.min, props.max); + setLive(v); + props.onInput?.(v); + }; + const onPointerUp = (e: PointerEvent) => { + if (activePointer !== e.pointerId) return; + activePointer = -1; + if (moved) { + const v = live(); + if (v !== null) props.onCommit(v); + setLive(null); + } else { + // A plain click (no drag) → switch to type mode. + setDraft(props.value === null ? "" : fmt(props.value)); + setEditing(true); + } + }; + + const commitEdit = (raw: string) => { + setEditing(false); + const cleaned = raw.replace(/[^0-9.+-]/g, "").trim(); + if (cleaned === "") return; + const parsed = Number(cleaned); + if (!Number.isFinite(parsed)) return; + props.onCommit(clamp(snap(parsed / scale()), props.min, props.max)); + }; + + return ( + queueMicrotask(() => { + el.focus(); + el.select(); + })} + onInput={(e) => setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitEdit(e.currentTarget.value); + } else if (e.key === "Escape") { + e.preventDefault(); + setEditing(false); + } + }} + onBlur={(e) => commitEdit(e.currentTarget.value)} + /> + } + > +
+ + + {fmt(shown()!)} + + + + {props.suffix} + +
+
+ ); +}