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() {
(recordBtnEl = el)}
title="Record your screen (Ctrl+Shift+R) — captures the monitor Vuoom is on"
onClick={() => void startRecord()}
>
@@ -1899,9 +2183,25 @@ function App() {
Ctrl+Shift+X stop
- void onRecover()}>
- Recover last session · {recoverable()!.toFixed(1)}s
-
+
+
Pick up where you left off
+
void onRecover()}
+ >
+
+
+ Last session
+ {recoverable()!.toFixed(1)}s · recover
+
+
+
@@ -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}
>
-
- Text
- void editText(e.currentTarget.value)}
- />
-
-
-
Style
-
- void editTextStyle({ bold: !selectedText()!.bold })}
- >
- B
-
- void editTextStyle({ italic: !selectedText()!.italic })}
- >
- I
-
-
-
-
- Size · {Math.round(selectedText()!.font_size * 100)}% of height
- void editFontSize(Number(e.currentTarget.value))}
- />
-
+
+
+ void editText(e.currentTarget.value)}
+ />
+
+
+
+ void editTextStyle({ bold: !selectedText()!.bold })}
+ >
+ B
+
+ void editTextStyle({ italic: !selectedText()!.italic })}
+ >
+ I
+
+ void editTextStyle({ background: !selectedText()!.background })}
+ >
+ BG
+
+
+
+
+
+
+ {(f) => (
+ void editTextStyle({ font: f.id })}
+ >
+ {f.label}
+
+ )}
+
+
+
+
+ void editFontSize(v)}
+ onCommit={(v) => void editFontSize(v)}
+ />
+
+
-
-
Shape
-
- void setShape(false)}
- >
- Rectangle
-
- void setShape(true)}
- >
- Ellipse
-
-
-
-
-
Fill
-
- void editStyle({ filled: false })}
- >
- Outline
-
- void editStyle({ filled: true })}
- >
- Filled
-
-
-
-
- Opacity · {Math.round((selectedBox()!.color.a ?? 1) * 100)}%
- void setOpacity(Number(e.currentTarget.value))}
- />
-
+
+
+
+ void setShape(false)}
+ >
+ Rectangle
+
+ void setShape(true)}
+ >
+ Ellipse
+
+
+
+
+
+ void editStyle({ filled: false })}
+ >
+ Outline
+
+ void editStyle({ filled: true })}
+ >
+ Filled
+
+
+
+
+ void setOpacity(v)}
+ onCommit={(v) => void setOpacity(v)}
+ />
+
+
+
+ void editStyle({ thickness: v })}
+ onCommit={(v) => void editStyle({ thickness: v })}
+ />
+
+
+
-
-
Ends
-
- void setArrowStyle("arrow")}
- >
- Arrow
-
- void setArrowStyle("line")}
- >
- Line
-
- void setArrowStyle("double")}
- >
- Double
-
-
-
-
-
-
-
- Thickness ·{" "}
- {(
- (selectedArrow()?.thickness ?? selectedBox()?.thickness ?? 0.006) * 100
- ).toFixed(1)}
- % of height
-
- void editStyle({ thickness: Number(e.currentTarget.value) })}
- />
-
+
+
+
+ void setArrowStyle("arrow")}
+ >
+ Arrow
+
+ void setArrowStyle("line")}
+ >
+ Line
+
+ void setArrowStyle("double")}
+ >
+ Double
+
+
+
+
+ void editStyle({ thickness: v })}
+ onCommit={(v) => void editStyle({ thickness: v })}
+ />
+
+
-
-
Color
-
-
- {(c) => (
- void setColor(c)}
- />
- )}
-
-
-
void setColor(e.currentTarget.value)}
- />
-
+
+
+
+
+ {(c) => (
+ void setColor(c)}
+ />
+ )}
+
+
+ void setColor(e.currentTarget.value)}
+ />
+
+
-
+
+
@@ -2372,41 +2748,42 @@ function App() {
onResizeMove={onInspMove}
onResizeUp={onInspUp}
>
-
- Strength · {selectedZoom()!.amount.toFixed(1)}×
- {
- const z = selectedZoom()!;
- void applyZoomEdit(selZoom()!, z.start, z.end, Number(e.currentTarget.value));
- }}
- />
-
-
-
Focus
-
- void applyZoomFocus(null)}
- >
- Follow cursor
-
- {
- if (!selZoomFocus()) void applyZoomFocus({ x: 0.5, y: 0.5 });
+
+
+ {
+ const z = selectedZoom()!;
+ void applyZoomEdit(selZoom()!, z.start, z.end, v);
}}
- >
- Fixed point
-
-
-
+ />
+
+
+
+ void applyZoomFocus(null)}
+ >
+ Follow cursor
+
+ {
+ if (!selZoomFocus()) void applyZoomFocus({ x: 0.5, y: 0.5 });
+ }}
+ >
+ Fixed point
+
+
+
+
Drag the crosshair on the video to aim this zoom.
@@ -2428,20 +2805,22 @@ function App() {
onResizeMove={onInspMove}
onResizeUp={onInspUp}
>
-
- Speed · {selectedSpeed()!.factor}×
- {
- const r = selectedSpeed()!;
- void applySpeedEdit(selSpeed()!, r.start, r.end, Number(e.currentTarget.value));
- }}
- />
-
+
+
+ {
+ 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. */}
+
+
+
+
+
Record. Auto-zoom. Ship.
+
+ Vuoom records your screen, zooms in where you click, and exports a crisp GIF or
+ MP4 — ready for your README, Slack, or socials.
+
+
+
+
1
+
+ Record
+ Pick an area and hit record — your clicks drive cinematic zooms.
+
+
+
+
2
+
+ Polish
+ Trim, speed up idle time, and add text, arrows, and highlights.
+
+
+
+
3
+
+ Export
+ One click to a GIF or MP4 you can paste anywhere.
+
+
+
+
+ dismissWelcome(true)}>
+ Maybe later
+
+ {
+ dismissWelcome(false);
+ void startRecord();
+ }}
+ >
+ Start recording
+
+
+
+
+
+
+ {/* Coachmark pointing at the Record button for users who skipped the welcome. */}
+
+
+
+
+ Start here — or press Ctrl+Shift+R any time.
+
+
setCoachRecord(false)}>
+ Got it
+
+
+
);
}
@@ -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}
+
+
+
+ );
+}