From c3453723825876f7440469a4b1e3cbe6937bf57f Mon Sep 17 00:00:00 2001 From: jedi Date: Thu, 20 Aug 2026 03:41:09 +0200 Subject: [PATCH] stash --- frontend/src/assets/fonts/pixel/LICENSE.md | 9 ++ frontend/src/assets/fonts/pixel/PICO-8.ttf | Bin 0 -> 11248 bytes .../fonts/pixel/Silkscreen-Regular.woff2 | Bin 0 -> 8404 bytes frontend/src/assets/fonts/pixel/TomThumb.ttf | Bin 0 -> 10390 bytes frontend/src/label-layouts.js | 35 +++-- frontend/src/label.js | 107 +++++++++---- frontend/src/scss/_pixel-fonts.scss | 21 +++ frontend/src/scss/toolshed.scss | 1 + frontend/src/views/Print.vue | 142 +++++++++++++----- 9 files changed, 233 insertions(+), 82 deletions(-) create mode 100644 frontend/src/assets/fonts/pixel/LICENSE.md create mode 100644 frontend/src/assets/fonts/pixel/PICO-8.ttf create mode 100644 frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 create mode 100644 frontend/src/assets/fonts/pixel/TomThumb.ttf create mode 100644 frontend/src/scss/_pixel-fonts.scss diff --git a/frontend/src/assets/fonts/pixel/LICENSE.md b/frontend/src/assets/fonts/pixel/LICENSE.md new file mode 100644 index 0000000..c8e62bf --- /dev/null +++ b/frontend/src/assets/fonts/pixel/LICENSE.md @@ -0,0 +1,9 @@ +# Pixel fonts used for small label text (see ../../../scss/_pixel-fonts.scss) + +- **Tom Thumb** (`TomThumb.ttf`) - by Brian Swetland, TTF conversion by gheja + (https://github.com/gheja/tom-thumb-ttf). Licensed CC0 or CC-BY 3.0 (original: + https://robey.lag.net/2010/01/23/tiny-monospace-font.html). +- **PICO-8** (`PICO-8.ttf`) - reproduction by Jacob Pierce + (https://github.com/jacobpierce/pico-8-font). MIT License, Copyright (c) 2016 Jacob Pierce. +- **Silkscreen** (`Silkscreen-Regular.woff2`) - by Jason Kottke, served via Google Fonts + (https://fonts.google.com/specimen/Silkscreen). SIL Open Font License 1.1. diff --git a/frontend/src/assets/fonts/pixel/PICO-8.ttf b/frontend/src/assets/fonts/pixel/PICO-8.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7085f45f5216b25270db6627d6d74d2248828524 GIT binary patch literal 11248 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..285a17af999975dd4bcef9af0027429b2f0b5999 GIT binary patch literal 8404 zcmV;_AS>T@Pew8T0RR9103g%=5dZ)H0CQXb03dMy0RR9100000000000000000000 z0000QQX7N-9Bc+)0E9jWQVE<45eN!_rxbybBMXBz00A}vBm;(M1Rw>3MhApU41qEm z3T8zIV#YWCFno5%$Uv}hz&6uM#s9|xO@<`odnBrBSCdqo6r;p?X=a+SJ!g*Cnr=Tr zET40);t|5*I1cuwR%)I(cNZ^FoJL)IKk(dJ-KBrSsJ8B&X`zUsqgYT*ERG?Hw>Y0| zNAGFlHVa3TcYkd>6h8yoM*buxum6Cjy`NOr$r+6f?%? zdb{drjj{ihetuQm*gYd@pMx9{+C|7*W&_8$A6&n~edM1Y{-)W|`vD?LmiNKaR2gj* zrbG2fQwBmN%LDQd@E4`Fby=w_3$}E44gu$E;u_%^-}~ErUrS9@m&#cqV?h^XfmOLAulCF_ zwrT_PqW{}i1%rc~8ED;)GlDP__Q&4?Lx}JZAsM3Jh4_d@64fDRoyWT95*8tV2;n>H zob%b+P5=px8=3`0BtV1*fL6-^2y@P@=z3IExjjemKOj$~)fJ)<&3do2Pa=Us{S5g46zNp>B;IHpQ`2{0<^jaI? zsoqP3#xF%n07gaJmxjx+Qi{sHyOF7Fq@;@EswB$lJiT@^y+PT|mZiOn+B7niul^+w zAJKqoiuMkv=&}gTx z)ca@Nqu}KGvtZo(KRe7o1hhTya=MBIMt72_r6aLsX~k$&V0b&#Y(#Uz38P61ULE9Q z@PWB!QUHXHzC9hl&Tr)TgQ3Ww64xVfEj$Vb$yfDw0<8dGf29~*F8#T^05~jwf#yYB zv8c)`Gl(8f4v&bOHCF>^=>>CKca2ycE8@Cxy}ACJ9%sb4aqEM6+7aY9Hry;u9RDEL z_M$FaPp)s;_0soQ+M@_LA5tEQn=%TA`y6dB_e)=0gmnm}=ELpMU zz>yPYu9li@nNt?H%8eg?fr3Sd6eU`ML`jmRNRy|4N}*yUDpjebR%fxDmN;RI$#&as zj{|nu;)<>6t##5|+uXFnaYGGr&1oH`xn+n3>x?kmNK^4KSVBAkB2t|x>FA`l9(w9U zj$=rVK4S(BF=Ee#EjupE6!GHDgAY&Md<6&>B2<`oF=E9Dk}6BO3^_6#maRgmGUaOI zt7RhBYHKXF(kd%-z6PHEo0p->(umAb14PHY0LmzOw)vtp_biG*D*r`a`F;>tyM}wW zhW7B&vpAyr7RS=OdWCxW&t+cPkscds!8>q6do74Cx)K7a)wqO0t=bX=QlBZ?_X^h~ zqQvMj-R`>Fnz{ky{;6Oal-*RrZcke}7%)0&{Symiw{>?Wu0Ihd)wNNIy3r|k>Q6!| z5;Y5KkrE{I-rWQt*lw+nTDW=&%Rk$0a@ZgB(TcYy9(I5EmD8E#t@6~DEs`QOhvIXRHm7szi&r>mlZ{ z-Z)_)uLI)+4JV;h+J-u@GxrUpss3L~M4rsFV0p*KZIgC`*ITGCe%2sxTOONBlxrq!)2Im;rTMDt z&VgQHh_!v6S=S3Mae6(~A)49oYkTLSu_+Y|jgb*{4~9dz0#ZAI2%gPRciFq3zYMag zg5s=m6dPggn*YT3Hh*GuSyBSS1vD@CZD+vaFuE>3ib+{JiM-qlMB0N8oLZ97^*&2b zWN6vAv`TM?AQP3mU z8AyF`rrHV;YgolPDmGBDiHa>$Y@=ca6}za|L&ZL7eFT{;TNp2nWGm@v*K`v*Oo|>M zci9AtgYhIhe+rRd8#&#sX;QcM_W87d0A}84dv<3F?#_?tB&%ts?>~l6%{)qL;H)no z-`t;CqNq10mZu@%Q2gX&LYxBtZqDmuUZf=8@5>4@E5jfr!43KiDp{j?Fk7+_*bEux zmzHmlsqDUzDpWvY&Foxh{u^4IOX82s-n-mfyGdl{?t5Ik0}y1MMdlxz`I56O=UD8V zG$Rw4Z8nc1*^f%4bqAU`nTqgrXIU=q{29~RRU_x}?R;?i?Yv288gi35h}4yfR>wWh z`_XhRjImZ!raDhDCaS26k66iaQrWrutb;UZ6q5s}W75`sqjXwpU&lJ8q^x=pv8)wX zo(pqdkzzk>vbZCZ902iUG^XZyQfDV8HXTctT*Ct()0E4R69@OngWJ_#=cTV?R;^KI zEzX6Et*E)SZsivkFO@AZFLI`>@JG&1%7MJ;_;XII=Q7cA5-JsxuDIk3{kGW+US>m; zCn@e&j(F`2cg^<3*EO^)_jTU@yD)qDn_wSJ^(3SnV6zcegvN@=9^5KHpft$vPbTc_ zMS$+Zx;QJ9PFiWajciW4t2PhbTjE#Ti7V{W zz#$DBC**a-ljDVF#|tlx7hVOyEe+h$z+*z*gipsCUye7v9dG;yf^Qo5rK$hZj==ld zo4#4lr^CsxfhqdBeRNY({meIeRhwCZn5LKPd*AZy3I3l#0B!@&D|GOXJRn;k%$SHC zF<+y&jWUCz%RKS?HsG+gep>*TC27F2J)j&V>cWE(Xf}%YW5;X-Zk;0qX-P?zsLhL> z4bK&rL1);qp73^MEuw%c8!Ay<63i%89k>m*Qq~2Iij5JeO~93xY9A^vm?1A9l&c7h zAHq@#CP13?%dd)sx_q(>L4YpnN+Enx9ibj>^NFQMk^GSW`{qRP7P@_>gCgEa%eD&= zkQ#NSD`$1cxwLFm>>0tyR0U!9Jkv%Wg&Lf)ST2LwQ%0cSV1`Wh&z?`%BqQBE51mxk z&DHIOO_{dTYAgl2UMo!^2E(FWSxHM+h^n&0GTmkZxDJpdHoNq9pewHKc3Q|}K9bO6 z#Bxy*g+-bK-(h$}Ozt>=)65AWFO@_IW-FvX3f{!{Q>)$ujx{S7XgP}iMNAP}OKK%9 zp{g|_YKwBEcBvA~x#)#aN>^=)=zSlec2#nZ_cyl?x`q6rN_Pzb`vIgopn^sI@HIEE zb*gYuQ|(hDUK?$Y#U@ge2+n6nM4_r!if|a_j46qgffGFLO=9F%P6dOWUHl~r~Ap^k$d z%8-i&+{77)EuKfH%19jyf;-|Rg~;n1B)rlQGD6tltquIRkw}ZZge^S~&KD5gCfRW{ zLR#}O2DO@DiKU@MDT#HYRHX$i97g@`s&J(=48$Qco{hRkzVtP$O*kG3QH&~4>!t^h zUVhHG>YRG%qhyBD(sPtLzY+6g`Wk&PoI@mYbWO!*q*)j}q*gI6zV zrL7R-Z?)9_E(Cu*GsB7DYpFMGcyYSZ;@|8`#5?as7 z-F{Y7dT2+vC0?pVO-2}mbMSydo}a3c#+uEa!ErPx_MQVCWQKaoQ7#pe*E>r_(d#EIp~&P3{3+BQ8a zu8cXWbDOGVsIw5uxbZTLL7j;-KZ}cTa5dio>roh6x!aOv$GN zjWdM`1WU1clW+9PX$!O^31W&%PuvfBB(oj(NzfQ^zGK*Osn`Aw zON3=OT!pzA8O?x84(*W9+pS3EXbuBEe0$nj(%ka9c`S;22GvXijO)Pjv}J)B6HiKF zTMAdk^1~L0AS^Jz)oN=Cg})7C-m7|mZeQIX zBh>~J0-|=>Sr-ioxMYR7@N}w|MS(ioNvbP=?CP_hK8-wx&y;CVS8jHpR;yI;XS{S` zE-_8xqAbn(i=xn~LOM><)hgl3_+efSQh^`pO+vvm)UQU08xquV-e^b$St?&<>AFwK z+Kh#&*mxvd(R`Fpf?R1AFe?##wI1KA8LFLS3)n1>DsKdXE_6!f-=x~-EapOfi|o`c z7c#-~SgN(@52fWXH1*=ap<#jGvtwe^osY9gL5vz9PZgdbEmkU%JCMr4trL=IyQ&pngzk+*+T`QtLd3q#>7wr?+Q&@I`jz$4Y~}=BZ`(Y4 zL5I}vp^@E$F0<9UuZFpd$1~(Le$O;MS~sS5KCx$r*o|Rfc|8}OyX=&d01yP{H35@* zuJ0mJ>-sK#FKkjtI`jg7o^%$&JQzc;YhiG9v0=_GHq6<@v%z6=rb!+(bdEAF=P2`X zj>@14Po@HLQ*%>uQ!#g57@Wu6*i)dGo^#43S?Nt~ZEkIDZEkI{uU2P324$E#kenCh z&WEP-)bGGpuF~8MRtIzbpNzp-jR%qC5uB%~OK|yjf?@FJ?LJ?pKKhEd!6zmN01g2F z47dt32pGe@iy7^#OT_k(x0m%-@(GRz7LMUJlqIdrx`bhEVik6G@iuJjhfT=^huT81 zmk40P;D&vmMP|KFA}{0IPx(WwV<7zl>l@$%TZq(w#8XgwfRN!Z*vpt-{N;>d^mrI>gecoK=IQwXf z`?1RfW+r{8*R1x8e}VZh7NDLUrpksC+i8TTIF z>;ltIIVm`@GjH%TB!Wtk1Md)L5^*0abF47KtcDc9+=o3#yAV=X38B91lJ$S>zgeC~ z(QZ(=-$n3A5o1!*ujgOqF#>Mki?!u6s}rK^uK-nj7TtH-?JLU7?WJZf&n|HV$#O{( z!fo}Cnu4~_Y&K&4O5a>#2{QgFzk{18F``@};(c9emjT}|^#>B`umioiC?Zs8`bE(a>acBEz zKKU1AWIcIp%RI5gYd&*c?|>uVQ3gEEMrOT59B5NX0H;}D%+fQUrs7>;9=6ETq7fYi zq`wjhjIUZjlvql{B)5UVJGZ%_w@B^<3KHTXCoAhW#;_AJsG@uP{of$5P{WE5k(Ti$ zJ?kNjj7~Dy0xX?sbB)`5Fzm@cjxFN_D?f)Lcpjdi4E@-R)mbaiz+m^_mLoHUq{*kU z^7TyuTgny`JHp6O5F=B!55rKiNboI?Nc7w326SueMXt2=M$7A*?L%~$RBo8ma`U~1{ERs_v!>|&b z=CB2>i6E>sW`CGm0$6O8n=^L<11X5C;%ExZ3zjnrl%>(Z!FqVfy^aCg$RTC~`=oU| zhWax_Xc?b0W3P;AGPlYaI@7%s`@Z>aqHVjc6Xwbwnm)o$keSS_m~%Mpr*z`{mk8l6 zyM2Xjz!n4TqZf{h%=nyM(;2GrEMsqH0QY!EJ^hUB57TYHy z!Q2nL;#mLBnO2j!eNiIo$z2zg4RSv(Q89bfQ)tpcLcaNJ8hvNe&RH}fg;%GS zaUB`eDlP^(!1deu5yRaS-jmr&Dx0n)q`UdoKnD*TqkSwQi3%+8IUhf|nNY~k7B0%? zi2#L>1(sRh&so&wYBxRzKKV!A(wp%ec&M_ZXj)8M_IX(Wn;3a4@LqQpRH!iqpuIlk z%!{s}$ICrvFq`YZkgx)cd^i}8$r>rDIeOnE$J`HK-uet}!vGBU@#ya2d1-*~v#R<4 zblv&=FC>U3|MD$;AkVmAsz^4Mcv}2evG-m%8a{mFT&>?e0Q}8AVoNs;0c*I-!+-e= zJOgRRZHB`2n#D_8F3HGM5zQk&H@>=N(4SAOtkFNcUP6Q+@G8}8u^@8UY$ij8TOShIW7PRpMiNi zcT(nD-9|x^{q}o~&?wE~kwN&i$JcL_A zuSp4KrfoN!CWO@;3&Gp{dF2jAJV?F;c{||5N<6}k#68YByM@Q2Y9r5rF2CH)B2Ao) zY-V7S9^ln*c)&44eT*Pi6{0dQ0bP2r9%MH@eicJyU{%2RNU=ND^fDg8hU{Q$^cZt} zS0PHV5&*{Rdv8w%nDLZ(yYrPP!zKq0mmB%9xd8Pq95%cRkewQHRKVb<8`eBc9^5wY z&B2{>?a1%I|GNIz{2wnVwZ>rP=|eAAR}xhaURX)DcRXdiii4y|n}TCzU*RVtDzX-h zoApgcE>l<2;tFn8E-<7wzo|Jy$RE^xvhVkB5S&`|^L=l|D{G9|V=*t>Ft?p#OuoqG zNb;b#@?9Qd03+j@B7HXYQH5Y?2wGIux{t?czw25!a8y_Z`iXMaf#hdCo$m4n;EHJ< zYEQPr(peHDr7bt^4>(z(j)p!LGjbq2=&$J+$4-I6t*9TX^X~@4&Z(HIhYwfr zOR%{BcS{=QsH+_EVRM~Z?CG!%!|nEiiR-oFM>KaCijpG{Ejl^&lff|X1pr^?2m$l4 z_qxAAqefRY=g141*kW$0F(ZxqIT+z$KfPfO!T_k2qO{6Kr5Pbw^ix53{{<}Ro~_z z%RiNIKEPJ+*uhvP@wniTnyT2M{7?J1wtVEwb7)ig9Eqsv`%>^OFEGm~wY6;#=2b40 zz0yOoP`@j@J1g14o6^Yn2B-m{Av%QAprueO{nX8GmDhIEkHEU3jDrCC7X-|K+zkeg zxx}ycssFK$*wVTsiVKnK=bdNFaNKl>es7p%ml(Hm9S~8Nj67QX>b{|Y<1c3YB3sr64$yC{S0S0sdW<+o1 z{a7+4(Pr=8&)cPjrrsw9(#kR$t4Rqb(nvi}Af{H-zPS?hs8wi79iRNMt+eyi#uu(2 zYe@FU*D%(p$UM##K*5bz9LP|)5a&wkg6g0REWYs4*fN)_%BZXjwuEZ2oV2R2AjHHg zT056ilidi94h)jSsA?$KE>}jOk*hPnPaX`0g)|cYBNk*}5J035i#joYj#x1-Azjn^ zB@9JmFJZa4{1P4m8(+egAk7yH(@wsEcLBxf>du_s8q5PN2N$T`MKaHp{arRRhG-+*s@~7hAnrTwO?rV>zlA@hWgSv z91{(4-$kIq%R95=sNiGgF*QFD&NkbXC`!0{-`&f$xz;-4Kj+;v`(%n7oZ{G7mN^MJ zsM;yjk5S51O3CD_jjscy*k4_{#y6x|q(oM(m&bGYs#N6ih*fjtR0po)^$^i z{f%np7j_=(+W-TnoRDXOd>c(>$VdTWub5Cd>9jLUnK5Vas}aHrbMvYCY9YH{m2mMz zu9#wz&FsCKsDJ)0uDYh!b*|iI(&L-k7obF$a-}L&SS(PGD#2b0QSFLRPpdD7izo{X z8r4tW=hN%`Cc5%wt=hC$LZe;0_p|xUn0S5z4K~CesnTZLm*3h=(+e#8MF3)o{{Q;k z`sf>mSmNQUZ@z~QHUe~_)K7ow@GPTesUt>_6X?vFPK4;Ng&!%IEIGXRIO>?Kw%Kln zHP+hi06qzQVxx^T&RF9OlkKJ9_7O&e5oS{OXeW-Y0j9~doNvSsMM6@Nk(?ByBppoT zZ-Sd{x$XX-zOux|#?SSTwwtkM{Np~2`Pxtv!27FTgvdD3XWHAii2tR{^{W4`a$cO9 zUtbr6;5*of>q^QC_`o4tp`A0rKXG!)ZYP?UdO<(i_PkEpA)IbO|AN_e#%=n%P7+2E zpJmMS5uBTNx9gSD;AQR-3jt3CG&=U}W}kmjA`~!_+D;Mf8W6DEr#%!; qw_7wA)S1WgUw)nTOWtP4bQ3TNvz=&1z$UcQ-1x#T5FlU%0000EmOG^Y literal 0 HcmV?d00001 diff --git a/frontend/src/assets/fonts/pixel/TomThumb.ttf b/frontend/src/assets/fonts/pixel/TomThumb.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1e85fd900dea711efcd248b59c9c7d5f8f8c4783 GIT binary patch literal 10390 zcmeHNPl#Pt8UOCQnaNB#lcY&(^RIBLK`f*(7?Gk~G?}7Z2$m)bQwruKnPdX*%^M~! zX#x!d?4n{p1viDR#6<)bf-VF%T@{Z~oeB`nzUN++A91UwHXvfBh5I{u1=HB?zX9jW+lXK!=yQ{p%gu2DW$u zbgR=l-?pi%@0(5D#rXAZ`}&Gqw8y{$-^%57ck$hgYYR|4Vm9%UmEM(p@wVMD`{FyC zH`)sN;Pu;kzqj+*f0*rp!0feez4uYxw}RAWY$9j`ox(nWbP`nXlSp|x8zg6KuT2H< zL_X<~)*p)>V|H=|z-DXsWDmO2I?ffuj%@(_%!zLwwdbs~n{Gp4(_9wTY`8+{LyAZ( zd=jlrE-x^j=EUbZlGXL|G1rf?F4y^Q@Z;E&`}%2Z5*9+roOAoVApvF|BLRO2p`HwB zxw*k~l^Yuye+y#_{)gLV*|*`eJOj}R2zONhzW9x&d*U$qKImPf{yET_Ao3sLgw26o z0o?}OGn;x5gu7@O@@ZgAzh}0s1cBcMeg^O}STln)vnb2iTOizZ+n)#h-0YD1<6b}WHlVRUQ~ED?fZCq&7$tM{~9`>W^kC+@?z(U(1q`$2&^8`#(Tjan}`76l*1 z=mAv4FlCI+1@E|zY?g;EVFo!%;tU#;)`J$45|WV3hKwP&tPx(h_@J)?d&++KRb={c z4?Kmv#WA3=nycZ-I^hVH2qW;2i?!Uo|5z@vI8}SohD;9j0rrNp=7-y_mDF-gaq78( zgci=m1f$h>78MDdL#hyk>hvGkia87nWXxY!fi-^4A5?1;Ur4kjqIyj zr?Wma939>W9s@^W3>=3R1uM{3zG6DoOyey@B#$C*1#0_M8n{Z5g^DxP1@8!qF4 zF@^w$b%RMg)rp#_gw_Si(%LP=T`?Lz?(&Jf`=yBFSenJ6`a9i2@0=7VomQZ@kLa0M zW|Tc!@IBRZ!&}PWX?2^GZz0GN)}8JaunCvT;K@&CKxd);-U#f2ymZ>>&h2pu*;U@`TJM9_PmIfs_5RQvQ3--SqS$ zCdZtqPHM8h;a!_6g_qt9AgpX94y%IwR=y)w6oMWeJ@gUHYpLNTQygWFvR^+-#3iPz zCMacuukNwNNV<;na&IN`n)SL!?$j!GLZ(ToVfJjC%WApdjC;*< zo#LHxAY{dwW4^CMbod|7NCR#QJBUL|7ApzubQ`Jf9*WEC39*d+;}6O9PX#0V z^NmjwI03t&d;)jz{>bxWl#!prcZE+yej0x)|9a$SZHJwV{C4|-y%_mK})@Z{>{ z_HudZ)y00Ny?o(Bd2Mm^M%ll-eB(&j?Jf7NthCQB9x2aV?&JTh%8R|_etpXq7CW8Z zleU1LV{pG`tN7&TX@u?!_~UjIGp8}r#ptvx*;R1o>?K>YOW-@!hR*Q(u%v9%Nr)$b za~WvM*yohJ3jBURU$zT&0;GZzsb{=aeJ%<&2%(Wpc j@sD6!(|@R!FF?D4pPoI*Bg8|DR=mak6Z?PrY((JSz*Q#I literal 0 HcmV?d00001 diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index f258cae..ae413b9 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -8,33 +8,33 @@ const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"}; export const LABEL_TEMPLATES = [ { id: "qr", name: "QR code only", description: "Just the code - smallest label, prints fastest.", - required_vars: ["value"], - layout: [{type: "qrcode", content: c => c.value}] + required_vars: ["text"], + layout: [{type: "qrcode", content: c => c.text}] }, { id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", - required_vars: ["value"], - layout: [{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}] + required_vars: ["text"], + layout: [{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}] }, { id: "qr-text-below", name: "QR code + text below", description: "The code with the encoded text printed below it.", - required_vars: ["value"], - layout: [[{type: "qrcode", content: c => c.value}, GAP, {type: "text", content: c => c.value?.split("\n")}]] + required_vars: ["text"], + layout: [[{type: "qrcode", content: c => c.text}, GAP, {type: "text", content: c => c.text?.split("\n")}]] }, { id: "id-qr-text-vertical", name: "ID + QR code + text below", description: "The code with the encoded text printed below it.", - required_vars: ["itemId", "value", "userHandle"], + required_vars: ["itemId", "text", "userHandle"], layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { type: "qrcode", - content: c => c.value + content: c => c.text }, GAP, {type: "text", content: c => c.userHandle}]] }, { id: "text", name: "Text only", description: "No code, just the text itself, as large as it fits.", - required_vars: ["value"], - layout: [{type: "text", content: c => c.value?.split("\n")}] + required_vars: ["text"], + layout: [{type: "text", content: c => c.text?.split("\n")}] }, { id: "item-handle", name: "Item handle", @@ -105,10 +105,19 @@ export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_va // A derived var is a format string calculated from other vars rather than typed directly - it // doesn't get its own input, just a read-only, live-recalculated display next to the ones that // do (see Print.vue and withDerivedVars below). `inputs` names every var (base or, in principle, -// derived) `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS -// below can include a var like "webdomain" that only feeds a calculation and that no template -// ever references directly. +// another derived one - see itemUrl/itemHandle below, which both read the derived userHandle) +// `calc` reads - declared up front rather than inferred from calc's body so BASE_VARS below can +// include a var like "webdomain" that only feeds a calculation and that no template ever +// references directly. Declaration order matters here: withDerivedVars runs these in a single +// pass, so a derived var must be declared after every other derived var it depends on. export const DERIVED_VARS = { + // The full owner handle (see federation.md's Unique Handles section / ToolshedUser's + // separate username/domain columns) - kept as two base vars (user, domain) rather than one, + // since that's how the account itself is actually shaped, with this just the display/URL form. + userHandle: { + inputs: ["user", "domain"], + calc: (f) => `${f.user}@${f.domain}`, + }, // The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a printed // label actually encodes, since scanning it has to resolve the right frontend/backend/item // with no other context, not just this browser's history. `webdomain` defaults to this diff --git a/frontend/src/label.js b/frontend/src/label.js index ad51fba..39bd9cb 100644 --- a/frontend/src/label.js +++ b/frontend/src/label.js @@ -1,4 +1,5 @@ import QRCode from "qrcode"; +import {encodeHandleForUrl} from "@/handle-url"; const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */ @@ -40,7 +41,26 @@ export function tapeFromStatus(status) { */ const QR_ASPECT = 1; /* a QR code is always square */ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */ -const MIN_READABLE_TEXT_PX = 8; /* below this, a layout is rejected rather than rendered unreadably small */ + +// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to +// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to - +// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor +// below picks the first tier whose belowPx clears the requested size, sans-serif once none do. +const PIXEL_FONT_TIERS = [ + {belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */ + {belowPx: 7, family: "PICO-8"}, /* designed for ~6px */ + {belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */ +]; + +function fontFamilyFor(fontPx) { + return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif"; +} + +// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a +// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them. +// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see +// there for why that's a quieter failure than rejecting the whole label over it. +const MIN_READABLE_TEXT_PX = 5; function isSplit(node) { return Array.isArray(node); @@ -134,26 +154,6 @@ function positionTree(node, ownAxis, x, y) { } } -/* Walks the tree once it's been sized (layoutTree) and given a hard minimum to check against - - this is the text-too-small-to-read validation, kept as one pass over the already-final boxes - rather than scattered through the drawing code, so nothing gets mutated on the canvas before - every leaf is confirmed to fit. The equivalent QR-doesn't-fit check already happened earlier, - in snapQrToCrispSize. */ -function assertLeavesFit(node) { - if (isSplit(node)) { - node.forEach(child => assertLeavesFit(child)); - return; - } - if (node.type === "text") { - const fontPx = TEXT_REFERENCE_PX * (node.box.height / node.naturalHeight); - if (fontPx < MIN_READABLE_TEXT_PX) { - throw new Error("This text doesn't fit this layout even at the smallest readable size — " - + "try a shorter value, a wider tape, or a different layout."); - } - } - // "qrcode"/"empty" leaves have nothing left to check by this point. -} - /* A QR code needs an integer number of pixels per module to render crisply rather than blurring at a fractional scale, so its true size is whatever that rounds down to - almost never the scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a @@ -180,6 +180,12 @@ function snapQrToCrispSize(node) { } } +// Always measures in sans-serif at the fixed reference size, even though drawTextLeaf may end up +// actually drawing in one of PIXEL_FONT_TIERS' fonts - the final effective size (and so which +// font applies) isn't known until layoutTree has already sized the box this aspect ratio feeds +// into. The pixel fonts are close enough in proportion for basic Latin/digits that the tiny +// resulting mismatch is invisible in practice at these sizes, and the MIN_READABLE_TEXT_PX check +// still catches anything that genuinely doesn't fit. function measureTextBlock(ctx, lines, referencePx) { ctx.font = `${referencePx}px sans-serif`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); @@ -224,10 +230,28 @@ function drawQrLeaf(ctx, node) { } } +// Returns the effective font size drawn at (or that would have been, if it's too small to draw - +// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx. function drawTextLeaf(ctx, node, referencePx) { const fontPx = referencePx * (node.box.height / node.naturalHeight); + // Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than + // reject the whole label over one field that's too small (the old behavior), just leave this + // leaf blank; its box was already accounted for, so nothing else in the layout shifts. + if (fontPx < MIN_READABLE_TEXT_PX) { + return fontPx; + } const lineHeight = node.box.height / node.lines.length; - ctx.font = `${fontPx}px sans-serif`; + const family = fontFamilyFor(fontPx); + ctx.font = `${fontPx}px "${family}"`; + // A @font-face family already in use elsewhere on the page loads in time for this, but canvas + // text silently falls back to the next font in the stack (there isn't one here, so the + // browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas + // fillText never waits or repaints on its own once the real font arrives. Kicking off the load + // here means only that very first draw at a given size risks the fallback; every redraw after + // it (Print.vue's live preview redraws on every keystroke) picks up the real font. + if (family !== "sans-serif") { + document.fonts.load(ctx.font); + } ctx.textBaseline = "middle"; ctx.textAlign = "center"; const centerX = node.box.x + node.box.width / 2; @@ -238,6 +262,7 @@ function drawTextLeaf(ctx, node, referencePx) { ctx.fillText(line, centerX, y); y += lineHeight; } + return fontPx; } // Flip this to true (in a debugger or a local edit) to outline every leaf's box - including @@ -256,15 +281,19 @@ function drawDebugBorder(ctx, node) { ctx.restore(); } -function drawTree(ctx, node, referencePx) { +// `textSizesPx` collects each "text" leaf's effective font size as drawTree walks the tree - see +// drawLabel/drawFallbackLabel, which hand it back to the caller (Print.vue shows it alongside the +// tape width) so a field rendering blank (see drawTextLeaf's MIN_READABLE_TEXT_PX check) shows up +// as a suspiciously small size here rather than just silently not being there. +function drawTree(ctx, node, referencePx, textSizesPx) { if (isSplit(node)) { - node.forEach(child => drawTree(ctx, child, referencePx)); + node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx)); return; } if (node.type === "qrcode") { drawQrLeaf(ctx, node); } else if (node.type === "text") { - drawTextLeaf(ctx, node, referencePx); + textSizesPx.push(drawTextLeaf(ctx, node, referencePx)); } // "empty" leaves carry no ink - their box just reserves the space. if (DEBUG_LEAF_BORDERS) { @@ -298,12 +327,13 @@ function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) { + "try a shorter value, a different layout, or a bigger label."); } layoutTree(tree, false, width, height, pxPerMm); - assertLeavesFit(tree); return {tree, width}; } /* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's - real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. */ + real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns + {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right, + top-to-bottom order. */ export function drawLabel(canvas, tape, content) { const maxLength = tape.printLengthPx ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX @@ -324,7 +354,9 @@ export function drawLabel(canvas, tape, content) { const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2); positionTree(tree, false, originX, 0); - drawTree(ctx, tree, TEXT_REFERENCE_PX); + const textSizesPx = []; + drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); + return {textSizesPx}; } const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */ @@ -333,7 +365,7 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m /* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a fixed reference height instead of a real tape's, and with no maxLength (there's no physical tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's - no real print head here to keep clear of. */ + no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */ export function drawFallbackLabel(canvas, content) { const measureCtx = canvas.getContext("2d"); const pxPerMm = FALLBACK_DPI / 25.4; @@ -349,7 +381,9 @@ export function drawFallbackLabel(canvas, content) { ctx.fillStyle = "#000"; positionTree(tree, false, 0, 0); - drawTree(ctx, tree, TEXT_REFERENCE_PX); + const textSizesPx = []; + drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); + return {textSizesPx}; } // Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a @@ -360,7 +394,7 @@ export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md) - what a // printed label actually encodes, since scanning it has to resolve the right // frontend/backend/item with no other context, not just this browser's history. - "item-url": ({user, id}) => `${window.location.origin}/i/${user}/${id}`, + "item-url": ({user, id}) => `${window.location.origin}/i/${encodeHandleForUrl(user)}/${id}`, }; export function buildLabelContent(prefill) { @@ -378,11 +412,20 @@ export function buildLabelContent(prefill) { // (rather than present-but-empty) is what label-layouts.js's templateIsAvailable treats as "not // available", so builders should only include a field once its inputs actually check out. const LABEL_FIELD_BUILDERS = { + // `user` here is already a full "user@domain" handle (that's the form login usernames take - + // see Login.vue/store.js), so it's split into label-layouts.js's separate `user`/`domain` + // base vars the same way store.js's own lookupServer does, rather than stuffing the whole + // handle into one field the way userHandle (now derived from these two) used to be. "item-url": ({user, id}) => { if (!user || !id) { return {}; } - return {userHandle: user, itemId: String(id)}; + const at = user.indexOf("@"); + return { + user: at === -1 ? user : user.slice(0, at), + domain: at === -1 ? "" : user.slice(at + 1), + itemId: String(id), + }; }, }; diff --git a/frontend/src/scss/_pixel-fonts.scss b/frontend/src/scss/_pixel-fonts.scss new file mode 100644 index 0000000..dadf862 --- /dev/null +++ b/frontend/src/scss/_pixel-fonts.scss @@ -0,0 +1,21 @@ +// Specialized bitmap-style fonts label.js's drawTextLeaf switches to below an effective text +// size of 10px, where a general-purpose sans-serif gets blurry/illegible - each is designed for +// (and named after) roughly the pixel size it's used at. See +// ../assets/fonts/pixel/LICENSE.md for sources/licenses. +@font-face { + font-family: "Tom Thumb"; + src: url("../assets/fonts/pixel/TomThumb.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "PICO-8"; + src: url("../assets/fonts/pixel/PICO-8.ttf") format("truetype"); + font-display: block; +} + +@font-face { + font-family: "Silkscreen"; + src: url("../assets/fonts/pixel/Silkscreen-Regular.woff2") format("woff2"); + font-display: block; +} diff --git a/frontend/src/scss/toolshed.scss b/frontend/src/scss/toolshed.scss index b06de08..a95db2a 100644 --- a/frontend/src/scss/toolshed.scss +++ b/frontend/src/scss/toolshed.scss @@ -92,6 +92,7 @@ $body-color: $gray-700; @import "forms"; @import "tags"; @import "dropdown"; +@import "pixel-fonts"; #root, body, html { height: 100%; diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index dfdd84b..c286d57 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -20,9 +20,9 @@
-
+
- @@ -88,7 +88,7 @@
Label
- {{ tape.mediaWidthMm }} mm tape + {{ tape.mediaWidthMm }} mm tape{{ textSizesSummary }}

@@ -99,7 +99,7 @@

+ :style="{height: (tape.mediaWidthMm * tapePxPerMm) + 'px'}"> @@ -108,7 +108,7 @@ :style="{top: t.pos + 'px'}">{{ t.mm }}
-
+
{{ t.mm }}
-
- + +
+
+ +
@@ -207,8 +214,17 @@ const BLOB_URL = "/vendor/libweblabel.js"; const MAX_ZOOM = 4; /* never magnify the preview more than this */ const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */ -const RULER_TICK_MM = 1; /* finest tick spacing the mm ruler draws */ -const RULER_MAJOR_EVERY_MM = 5; /* every Nth tick is taller and labeled with its mm value */ +// How far apart plain and labeled/major ticks sit, both coarser the longer the ruler itself runs +// - tightly spaced ticks (and their labels) get too cramped to read/render once there are enough +// of them. Ordered smallest threshold first; rulerTicks below uses the last entry whose `aboveMm` +// the ruler's own length clears, so add a finer/coarser tier here rather than growing a pile of +// separate constants. Every tier's majorEveryMm is a multiple of its own tickMm, so major ticks +// always land on a tick that's actually drawn. +const RULER_TIERS = [ + {aboveMm: 0, tickMm: 1, majorEveryMm: 5}, + {aboveMm: 100, tickMm: 1, majorEveryMm: 10}, + {aboveMm: 500, tickMm: 5, majorEveryMm: 25}, +]; export default { name: "Print", @@ -244,17 +260,21 @@ export default { // either one changes. zoom: 1, printedWidthPx: 0, + // Each "text" leaf's effective font size in the current render (see label.js's + // drawLabel) - shown alongside the tape width so a field rendering blank (too small + // even for the smallest pixel font) shows up as a suspiciously tiny number here rather + // than just silently not being there. + textSizesPx: [], // One input per *base* template variable (see label-layouts.js's BASE_VARS) - the - // derived ones (itemUrl, itemHandle) are format strings calculated from these, not - // typed directly, so they're only ever shown (see the `fields` computed below), never - // stored here. Prefilled from the ?kind=…&… query params where + // derived ones (userHandle, itemUrl, itemHandle) are format strings calculated from + // these, not typed directly, so they're only ever shown (see the `fields` computed + // below), never stored here. Prefilled from the ?kind=…&… query params where // buildLabelContent/buildLabelFields have a value for them, editable from there so a - // template needing e.g. userHandle isn't stuck depending on a prefill that never - // arrives. + // template needing e.g. domain isn't stuck depending on a prefill that never arrives. varValues: { ...Object.fromEntries(BASE_VARS.map(v => [v, ""])), - value: buildLabelContent(this.prefill), + text: buildLabelContent(this.prefill), // Defaults to wherever this page itself is being served from - editable since any // frontend can resolve any handle (see label-layouts.js's DERIVED_VARS.itemUrl), // so a label doesn't have to point back at this particular one. @@ -327,17 +347,46 @@ export default { tapePxPerMm() { return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0; }, + // The physical length, in mm, each ruler axis actually needs to cover - see + // horizontalRulerTicks/verticalRulerTicks below for what each one measures and why. + horizontalTotalMm() { + return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0; + }, + verticalTotalMm() { + return this.tape ? this.tape.mediaWidthMm : 0; + }, + // The single RULER_TIERS entry both rulers draw from, keyed off whichever axis is + // physically longer - so a long label's ruler doesn't end up coarser (or finer) than the + // tape-width ruler right next to it just because the other axis happens to be shorter. + rulerTier() { + return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm) + .at(-1); + }, // Ticks along the tape's length (the printed bitmap's actual width, lead/trailing feed // margin included, since that's real physical tape too). horizontalRulerTicks() { if (!this.tape || !this.printedWidthPx) { return []; } - return this.rulerTicks(this.printedWidthPx / (this.tape.dpi / 25.4)); + return this.rulerTicks(this.horizontalTotalMm); }, - // Ticks across the tape's fixed physical width. + // Ticks across the tape's full physical width, mediaWidthMm - not printAreaPx/dpi: a + // print head can't reach the tape's outer edges, so the printable area (see .tape-full in + // the template) is genuinely narrower than the tape itself, by an amount that isn't a + // fixed/predictable fraction of it. The ruler still has to show the *whole* tape - its + // container is sized from mediaWidthMm too (see the template's inline height) precisely so + // these ticks can't run past it, the way they did when both were sized from printAreaPx. verticalRulerTicks() { - return this.tape ? this.rulerTicks(this.tape.mediaWidthMm) : []; + return this.tape ? this.rulerTicks(this.verticalTotalMm) : []; + }, + // "(5px, 23px)" for the current render's text leaves (see data's textSizesPx), or "" once + // there's nothing to show - appended straight onto the tape-width , so the blank + // string here just means that text is left with no trailing space. + textSizesSummary() { + if (!this.textSizesPx.length) { + return ""; + } + return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`; }, }, watch: { @@ -377,12 +426,15 @@ export default { return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase()); }, - // One tick per RULER_TICK_MM from 0 up to totalMm, each positioned in on-screen pixels - // via tapePxPerMm - shared by the horizontal/vertical ruler computeds above. + // Ticks from 0 up to totalMm, each positioned in on-screen pixels via tapePxPerMm - shared + // by the horizontal/vertical ruler computeds above. Both the plain tick spacing and the + // labeled/major one come from the shared rulerTier (see above), not from this totalMm, so + // both rulers always coarsen together once *either* axis is long enough to need it. rulerTicks(totalMm) { + const {tickMm, majorEveryMm} = this.rulerTier; const ticks = []; - for (let mm = 0; mm <= totalMm; mm += RULER_TICK_MM) { - ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % RULER_MAJOR_EVERY_MM === 0}); + for (let mm = 0; mm <= totalMm; mm += tickMm) { + ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0}); } return ticks; }, @@ -463,13 +515,15 @@ export default { return; } this.resizeObserver.observe(canvas.parentElement); + let textSizesPx; try { - drawLabel(canvas, this.tape, content); + ({textSizesPx} = drawLabel(canvas, this.tape, content)); } catch (e) { this.error = e.message; return; } this.error = null; + this.textSizesPx = textSizesPx; const bitmap = canvasToBitmap(canvas); bitmapToCanvas(canvas, bitmap); this.labelBitmap = bitmap; @@ -593,7 +647,7 @@ export default { .label-preview { overflow-x: auto; padding: .75rem; - background: rgba(127, 127, 127, .08); + //background: rgba(127, 127, 127, .08); border-radius: .35rem; text-align: center; max-height: 300px; @@ -602,7 +656,7 @@ export default { .label-preview canvas { display: inline-block; background: #fff; - box-shadow: 0 0 0 1px rgba(127, 127, 127, .5); + //box-shadow: 0 0 0 1px rgba(127, 127, 127, .5); } /* The tape-fed preview's mm ruler (see Print.vue's template/script) - a horizontal track above @@ -613,22 +667,38 @@ export default { align-items: flex-start; } -/* The single horizontal scroll region for a label wider than its card - the horizontal ruler and - the canvas are both direct children of this, so they scroll in lockstep with no JS needed to - keep them in sync. */ -.preview-scroll { +/* Holds the horizontal ruler and the canvas - deliberately never scrollable (no overflow-x:auto): + fitZoom's zoom always satisfies `canvas.width * zoom <= available`, so the canvas can never + actually be wider than this has room for, and a scrollbar here would let the ruler and canvas + drift apart (or just look broken) for no reason. min-width:0 only lets this flex item shrink + to the card's real available width - it doesn't enable scrolling. */ +.preview-track { flex: 1 1 auto; min-width: 0; - overflow-x: auto; } -/* Overrides the standalone rule above: nested here, .label-preview must neither clip nor center - its canvas - overflow-x:visible lets the canvas's true width "bubble up" to .preview-scroll's - own scrollbar instead of scrolling twice, and text-align:left keeps the canvas flush with the - ruler's zero tick instead of drifting to the middle of whatever spare width this card has. */ -.preview-scroll .label-preview { +/* Overrides the standalone rule above: nested here, .label-preview must neither scroll nor center + its canvas - overflow-x:visible (never auto) rules out a second, inner scrollbar, and + text-align:left keeps the canvas flush with the ruler's zero tick instead of drifting to the + middle of whatever spare width this card has. padding:0 so the canvas's own edges are exactly + this box's edges too - the ruler's ticks (see .ruler-h/.ruler-v-ticks below) line up with those + same edges, so any padding here would leave the ticks and the actual canvas misaligned. */ +.preview-track .label-preview { overflow-x: visible; text-align: left; + padding: 0; +} + +/* The tape's full physical width (see the template) - a print head can't mark all the way to a + tape's outer edges, so .label-preview/the canvas is narrower than this and centered within it + (the print area sits centered on the tape, with equal margin on both sides); a faint tint + distinguishes the margin as real (if blank, unprintable) tape rather than empty space. */ +.tape-full { + display: flex; + flex-direction: column; + justify-content: center; + background: rgba(127, 127, 127, .08); + border-radius: .35rem; } .ruler-v { @@ -645,13 +715,11 @@ export default { .ruler-v-ticks { position: relative; - margin-top: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */ } .ruler-h { position: relative; height: 1.6rem; - margin-left: .75rem; /* .label-preview's own padding, so tick 0 meets the canvas, not its box */ color: rgba(127, 127, 127, .9); }