Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec7f1f8a14 | |||
| 24f6c797f2 | |||
| a2885a3283 | |||
| 0143f1e465 | |||
| dc57f9af20 | |||
| 984ede7d55 | |||
| fc398bd1f9 | |||
| 75faf3efaa | |||
| 4f5dd1f399 | |||
| 033ae23b7c | |||
| a0be70d5a6 | |||
| ef29427877 | |||
| 2af0155e6a | |||
| 8130c5cc68 | |||
| 67d23a229f | |||
| 6c9515c385 | |||
| d4f12f6773 | |||
| 65a70996c4 | |||
| 2b8443a1b1 | |||
| c169058915 | |||
| 4c2003d05e | |||
| 42bb758383 | |||
| 26dab9b26b | |||
| d1a4129323 | |||
| a283ddf457 | |||
| b172762288 | |||
| b3da7e84d8 | |||
| 96989be616 | |||
| 9514f15073 | |||
| 1a57eccd38 | |||
| fdfd82207d | |||
| 6e32acfeff | |||
| 999e9cbc36 | |||
| c71f9f3dc2 | |||
| d3e0351d05 | |||
| 4108ecdce9 | |||
| 4bf3e2a1df | |||
| e9da5eeba5 |
@@ -0,0 +1,2 @@
|
||||
[ 3482ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 3531ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,9 @@
|
||||
[ 210ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1039ms] [LOG] [Fast Refresh] done in 930ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1076ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1086ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 37298ms] [ERROR] Pattern attribute value [0-9() -]{10,16} is not a valid regular expression: Uncaught SyntaxError: Invalid regular expression: /[0-9() -]{10,16}/v: Invalid character in character class @ http://localhost:3000/motorista/entrar:0
|
||||
[ 60872ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 60976ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 61021ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 66490ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,13 @@
|
||||
[ 93ms] [LOG] [Fast Refresh] done in 99ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 953ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 963ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4345ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4433ms] [LOG] [Fast Refresh] done in 189ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 4548ms] [VERBOSE] [DOM] Password forms should have (optionally hidden) username fields for accessibility: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/entrar/senha:0
|
||||
[ 9731ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9777ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 11070ms] [LOG] [Fast Refresh] done in 5486ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 30758ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 31764ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 32766ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=fBVPvwqt_6CU4TU3WWYmu' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 33776ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
@@ -0,0 +1,79 @@
|
||||
[ 805ms] [INFO] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 813ms] [LOG] [HMR] connected @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 1121ms] [VERBOSE] [DOM] Password forms should have (optionally hidden) username fields for accessibility: (More info: https://goo.gl/9p2vKq) %o @ http://localhost:3000/entrar/senha:0
|
||||
[ 2267ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 2310ms] [LOG] [Fast Refresh] done in 144ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9621ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 9763ms] [LOG] [Fast Refresh] done in 243ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48693ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48749ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 48826ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51025ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51539ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 51907ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 107213ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 118297ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 123464ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 125669ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 125771ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 126799ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 126941ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 127929ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 127981ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 154930ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 156620ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 157845ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158106ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158454ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158504ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158748ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158848ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 158955ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159307ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159399ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159488ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159901ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 159993ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 187894ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 188000ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 205603ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207208ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207296ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 207951ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208126ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208244ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208353ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208415ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208590ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208695ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208794ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208878ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 208986ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 209132ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 209234ms] [LOG] [Fast Refresh] done in 39171ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 228278ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 228308ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 238991ms] [LOG] [Fast Refresh] done in 10713ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239037ms] [LOG] [Fast Refresh] done in 10713ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239175ms] [LOG] [Fast Refresh] rebuilding @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239206ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239602ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 239697ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 264111ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 265916ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 321743ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 333352ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 369410ms] [LOG] [Fast Refresh] done in 3ms @ http://localhost:3000/_next/static/chunks/1bba_next_dist_041c6vio7na_y._.js:719
|
||||
[ 413886ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 414902ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 415904ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 416906ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 417908ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 418911ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 423918ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 428928ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 433950ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 438984ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 444022ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 449184ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
[ 454479ms] [ERROR] WebSocket connection to 'ws://localhost:3000/_next/hmr?id=ruCdDO_vey1tElDs8WYR0' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/_next/static/chunks/1bba_next_dist_client_0ru2jkh0js1s8._.js:16225
|
||||
@@ -0,0 +1,28 @@
|
||||
- generic [ref=e2]:
|
||||
- complementary [ref=e3]:
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9]: Rota Fresca
|
||||
- generic [ref=e10]: Distribuição de alimentos
|
||||
- generic [ref=e11]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=e12]
|
||||
- paragraph [ref=e13]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=e14]:
|
||||
- link "Precisa de ajuda?" [ref=e16] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=e17]:
|
||||
- generic [ref=e18]:
|
||||
- heading "Entrar" [level=1] [ref=e19]
|
||||
- paragraph [ref=e20]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- generic [ref=e23]: E-mail
|
||||
- textbox "E-mail" [ref=e24]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=e25]
|
||||
- generic [ref=e26]: ou
|
||||
- generic [ref=e33]:
|
||||
- paragraph [ref=e34]: É motorista?
|
||||
- paragraph [ref=e35]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=e36]:
|
||||
- paragraph [ref=e37]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=e38]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,20 @@
|
||||
- generic [active] [ref=f1e1]:
|
||||
- generic [ref=f1e2]:
|
||||
- generic [ref=f1e3]:
|
||||
- generic [ref=f1e8]:
|
||||
- generic [ref=f1e9]: Rota Fresca
|
||||
- generic [ref=f1e10]: App do motorista
|
||||
- banner [ref=f1e11]:
|
||||
- heading "Entrar" [level=1] [ref=f1e12]
|
||||
- paragraph [ref=f1e13]: Use o número de celular que a central cadastrou. Mandamos um código, sem senha.
|
||||
- generic [ref=f1e14]:
|
||||
- generic [ref=f1e15]: Celular
|
||||
- generic [ref=f1e16]:
|
||||
- generic [ref=f1e17]: "+55"
|
||||
- textbox "(21) 98877-4410" [ref=f1e18]
|
||||
- generic [ref=f1e19]:
|
||||
- button "Receber código no WhatsApp" [ref=f1e20]
|
||||
- button "Receber por SMS" [ref=f1e21]
|
||||
- paragraph [ref=f1e22]: Trocou de número? Fale com a central.
|
||||
- button "Open Next.js Dev Tools" [ref=f1e28] [cursor=pointer]
|
||||
- alert [ref=f1e32]
|
||||
@@ -0,0 +1,32 @@
|
||||
- generic [active] [ref=f2e1]:
|
||||
- generic [ref=f2e2]:
|
||||
- complementary [ref=f2e3]:
|
||||
- generic [ref=f2e9]:
|
||||
- generic [ref=f2e10]: Rota Fresca
|
||||
- generic [ref=f2e11]: Distribuição de alimentos
|
||||
- generic [ref=f2e12]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e13]
|
||||
- paragraph [ref=f2e14]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e15]:
|
||||
- link "Precisa de ajuda?" [ref=f2e17] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e18]:
|
||||
- generic [ref=f2e19]:
|
||||
- heading "Entrar" [level=1] [ref=f2e20]
|
||||
- paragraph [ref=f2e21]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=f2e22]:
|
||||
- generic [ref=f2e23]:
|
||||
- generic [ref=f2e24]: E-mail
|
||||
- textbox "E-mail" [ref=f2e25]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=f2e26]
|
||||
- generic [ref=f2e27]: ou
|
||||
- button "Entrar com chave de acesso (passkey)" [ref=f2e31]
|
||||
- generic [ref=f2e35]:
|
||||
- paragraph [ref=f2e36]: É motorista?
|
||||
- paragraph [ref=f2e37]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=f2e38]:
|
||||
- paragraph [ref=f2e39]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e40]: Termos de uso · Privacidade
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]
|
||||
@@ -0,0 +1,38 @@
|
||||
- generic [active] [ref=f2e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]: Digite sua senha
|
||||
- generic [ref=f2e51]:
|
||||
- complementary [ref=f2e52]:
|
||||
- generic [ref=f2e58]:
|
||||
- generic [ref=f2e59]: Rota Fresca
|
||||
- generic [ref=f2e60]: Distribuição de alimentos
|
||||
- generic [ref=f2e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e62]
|
||||
- paragraph [ref=f2e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e64]:
|
||||
- link "Precisa de ajuda?" [ref=f2e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e67]:
|
||||
- generic [ref=f2e68]:
|
||||
- generic [ref=f2e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f2e74]
|
||||
- generic [ref=f2e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f2e76]
|
||||
- paragraph [ref=f2e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f2e78]:
|
||||
- generic [ref=f2e79]:
|
||||
- generic [ref=f2e80]:
|
||||
- generic [ref=f2e81]: Senha
|
||||
- textbox "Senha" [ref=f2e82]
|
||||
- generic [ref=f2e83]:
|
||||
- generic [ref=f2e84]:
|
||||
- checkbox "Mostrar senha" [ref=f2e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f2e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- button "Entrar" [ref=f2e87]
|
||||
- paragraph [ref=f2e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f2e89]:
|
||||
- paragraph [ref=f2e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,40 @@
|
||||
- generic [ref=f2e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f2e46] [cursor=pointer]
|
||||
- alert [ref=f2e50]: Rota Fresca
|
||||
- generic [ref=f2e51]:
|
||||
- complementary [ref=f2e52]:
|
||||
- generic [ref=f2e58]:
|
||||
- generic [ref=f2e59]: Rota Fresca
|
||||
- generic [ref=f2e60]: Distribuição de alimentos
|
||||
- generic [ref=f2e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f2e62]
|
||||
- paragraph [ref=f2e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f2e64]:
|
||||
- link "Precisa de ajuda?" [ref=f2e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f2e67]:
|
||||
- generic [ref=f2e68]:
|
||||
- generic [ref=f2e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f2e74]
|
||||
- generic [ref=f2e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f2e76]
|
||||
- paragraph [ref=f2e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f2e78]:
|
||||
- generic [ref=f2e79]:
|
||||
- generic [ref=f2e80]:
|
||||
- generic [ref=f2e81]: Senha
|
||||
- textbox "Senha" [active] [invalid] [ref=f2e82]
|
||||
- generic [ref=f2e83]:
|
||||
- generic [ref=f2e84]:
|
||||
- checkbox "Mostrar senha" [ref=f2e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f2e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- alert [ref=f2e92]:
|
||||
- generic [ref=f2e93]: Senha incorreta. Confira e tente de novo.
|
||||
- button "Entrar" [ref=f2e87]
|
||||
- paragraph [ref=f2e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f2e89]:
|
||||
- paragraph [ref=f2e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f2e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,32 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- generic [ref=f3e2]:
|
||||
- complementary [ref=f3e3]:
|
||||
- generic [ref=f3e9]:
|
||||
- generic [ref=f3e10]: Rota Fresca
|
||||
- generic [ref=f3e11]: Distribuição de alimentos
|
||||
- generic [ref=f3e12]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e13]
|
||||
- paragraph [ref=f3e14]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e15]:
|
||||
- link "Precisa de ajuda?" [ref=f3e17] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e18]:
|
||||
- generic [ref=f3e19]:
|
||||
- heading "Entrar" [level=1] [ref=f3e20]
|
||||
- paragraph [ref=f3e21]: Use o e-mail da sua conta. Conforme o tipo de conta, pedimos a senha ou mandamos um link de acesso.
|
||||
- generic [ref=f3e22]:
|
||||
- generic [ref=f3e23]:
|
||||
- generic [ref=f3e24]: E-mail
|
||||
- textbox "E-mail" [ref=f3e25]:
|
||||
- /placeholder: nome@empresa.com.br
|
||||
- button "Continuar" [ref=f3e26]
|
||||
- generic [ref=f3e27]: ou
|
||||
- button "Entrar com chave de acesso (passkey)" [ref=f3e31]
|
||||
- generic [ref=f3e35]:
|
||||
- paragraph [ref=f3e36]: É motorista?
|
||||
- paragraph [ref=f3e37]: Entre pelo app Rota Fresca no celular, com o seu número. Não precisa de e-mail.
|
||||
- generic [ref=f3e38]:
|
||||
- paragraph [ref=f3e39]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e40]: Termos de uso · Privacidade
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
@@ -0,0 +1,38 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
- generic [ref=f3e51]:
|
||||
- complementary [ref=f3e52]:
|
||||
- generic [ref=f3e58]:
|
||||
- generic [ref=f3e59]: Rota Fresca
|
||||
- generic [ref=f3e60]: Distribuição de alimentos
|
||||
- generic [ref=f3e61]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e62]
|
||||
- paragraph [ref=f3e63]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e64]:
|
||||
- link "Precisa de ajuda?" [ref=f3e66] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e67]:
|
||||
- generic [ref=f3e68]:
|
||||
- generic [ref=f3e69]: ana.souza@rotafresca.com.br
|
||||
- button "Trocar" [ref=f3e74]
|
||||
- generic [ref=f3e75]:
|
||||
- heading "Digite sua senha" [level=1] [ref=f3e76]
|
||||
- paragraph [ref=f3e77]: Conta de consultor. Depois da senha pedimos o código do autenticador.
|
||||
- generic [ref=f3e78]:
|
||||
- generic [ref=f3e79]:
|
||||
- generic [ref=f3e80]:
|
||||
- generic [ref=f3e81]: Senha
|
||||
- textbox "Senha" [ref=f3e82]
|
||||
- generic [ref=f3e83]:
|
||||
- generic [ref=f3e84]:
|
||||
- checkbox "Mostrar senha" [ref=f3e85]
|
||||
- checkbox [aria-hidden]
|
||||
- text: Mostrar senha
|
||||
- link "Esqueci minha senha" [ref=f3e86] [cursor=pointer]:
|
||||
- /url: /entrar/redefinir
|
||||
- button "Entrar" [ref=f3e87]
|
||||
- paragraph [ref=f3e88]: Pode colar a senha ou usar o gerenciador de senhas do navegador.
|
||||
- generic [ref=f3e89]:
|
||||
- paragraph [ref=f3e90]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e91]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,33 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]
|
||||
- generic [ref=f3e92]:
|
||||
- complementary [ref=f3e93]:
|
||||
- generic [ref=f3e99]:
|
||||
- generic [ref=f3e100]: Rota Fresca
|
||||
- generic [ref=f3e101]: Distribuição de alimentos
|
||||
- generic [ref=f3e102]:
|
||||
- heading "Do depósito à prateleira, na hora certa." [level=2] [ref=f3e103]
|
||||
- paragraph [ref=f3e104]: Pedidos, entregas e notas da sua rede no mesmo lugar.
|
||||
- main [ref=f3e105]:
|
||||
- link "Precisa de ajuda?" [ref=f3e107] [cursor=pointer]:
|
||||
- /url: mailto:suporte@rotafresca.com.br
|
||||
- generic [ref=f3e108]:
|
||||
- heading "Confirme que é você" [level=1] [ref=f3e113]
|
||||
- generic [ref=f3e114]:
|
||||
- generic [ref=f3e115]:
|
||||
- generic [ref=f3e116]:
|
||||
- generic [ref=f3e117]: Código
|
||||
- textbox "Código" [ref=f3e118]
|
||||
- generic [ref=f3e119]:
|
||||
- checkbox "Não pedir de novo neste computador por 30 dias" [checked] [ref=f3e120]
|
||||
- checkbox [checked] [aria-hidden]
|
||||
- text: Não pedir de novo neste computador por 30 dias
|
||||
- button "Confirmar" [ref=f3e121]
|
||||
- generic [ref=f3e122]:
|
||||
- link "Usar um código de recuperação" [ref=f3e123] [cursor=pointer]:
|
||||
- /url: /entrar/codigo?recuperacao=1
|
||||
- paragraph [ref=f3e124]: Perdeu o celular? Peça ao administrador da conta para refazer o autenticador.
|
||||
- generic [ref=f3e125]:
|
||||
- paragraph [ref=f3e126]: Recebeu um convite? Abra o link do e-mail para criar seu acesso.
|
||||
- paragraph [ref=f3e127]: Termos de uso · Privacidade
|
||||
@@ -0,0 +1,47 @@
|
||||
- generic [active] [ref=f3e1]:
|
||||
- button "Open Next.js Dev Tools" [ref=f3e46] [cursor=pointer]
|
||||
- alert [ref=f3e50]: Bom dia, Ana
|
||||
- generic [ref=f3e128]:
|
||||
- complementary [ref=f3e129]:
|
||||
- generic [ref=f3e130]:
|
||||
- generic [ref=f3e136]:
|
||||
- generic [ref=f3e137]: Rota Fresca
|
||||
- generic [ref=f3e138]: Distribuição de alimentos
|
||||
- navigation [ref=f3e139]:
|
||||
- link "Painel" [ref=f3e140] [cursor=pointer]:
|
||||
- /url: /painel
|
||||
- link "Roteiro do dia" [ref=f3e143] [cursor=pointer]:
|
||||
- /url: /roteiro
|
||||
- link "Lojas (PDVs)" [ref=f3e146] [cursor=pointer]:
|
||||
- /url: /lojas
|
||||
- link "Pedidos" [ref=f3e149] [cursor=pointer]:
|
||||
- /url: /pedidos
|
||||
- link "Entregas" [ref=f3e152] [cursor=pointer]:
|
||||
- /url: /entregas
|
||||
- link "Validade e lotes" [ref=f3e155] [cursor=pointer]:
|
||||
- /url: /validade
|
||||
- link "Relatórios" [ref=f3e158] [cursor=pointer]:
|
||||
- /url: /relatorios
|
||||
- link "Integrações e notas" [ref=f3e161] [cursor=pointer]:
|
||||
- /url: /integracoes
|
||||
- link "Leitura de documentos" [ref=f3e164] [cursor=pointer]:
|
||||
- /url: /documentos
|
||||
- link "Contatos e mensagens" [ref=f3e167] [cursor=pointer]:
|
||||
- /url: /contatos
|
||||
- link "Configurações" [ref=f3e170] [cursor=pointer]:
|
||||
- /url: /configuracoes
|
||||
- generic [ref=f3e173]:
|
||||
- generic [aria-hidden] [ref=f3e174]: A
|
||||
- generic [ref=f3e175]:
|
||||
- generic [ref=f3e176]: Ana Souza
|
||||
- generic [ref=f3e177]: Consultora · Zona Oeste
|
||||
- main [ref=f3e178]:
|
||||
- generic [ref=f3e179]:
|
||||
- generic [ref=f3e180]:
|
||||
- heading "Bom dia, Ana" [level=1] [ref=f3e181]
|
||||
- paragraph [ref=f3e182]: domingo, 25 de outubro
|
||||
- generic [ref=f3e183]:
|
||||
- textbox "Buscar loja, pedido ou produto" [ref=f3e187]
|
||||
- button "Notificações" [ref=f3e188]
|
||||
- button "Importar / Exportar" [ref=f3e189]
|
||||
- button "Novo pedido" [ref=f3e190]
|
||||
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 344 KiB |
@@ -784,7 +784,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
|
||||
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb#9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
@@ -3128,7 +3128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.7.2"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3161,6 +3161,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"rfd",
|
||||
"rmcp",
|
||||
"ruzstd",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,8 +3175,10 @@ dependencies = [
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3853,6 +3856,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -4754,6 +4766,12 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -4777,7 +4795,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6018,6 +6036,34 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.7.2"
|
||||
version = "0.8.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -44,7 +44,7 @@ env_logger = "0.11"
|
||||
rfd = "0.17"
|
||||
fastrand = "2"
|
||||
dirs = "6"
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065" }
|
||||
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "9c470aa935c4ce48f2ba4f0a17f9410c0c8660fb" }
|
||||
i_overlay = "9.0.1"
|
||||
miniz_oxide = "0.9.1"
|
||||
usvg = "0.48"
|
||||
@@ -97,8 +97,14 @@ reqwest = { version = "0.13", default-features = false, features = ["native-tls"
|
||||
# libc: SIGINT to the spawned chat CLIs.
|
||||
keyring = { version = "3", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
|
||||
libc = "0.2"
|
||||
# Pages and versions on disk are zstd-compressed JSON (`project::packed`).
|
||||
zstd = "0.13"
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", features = ["webgl"] }
|
||||
wasm-bindgen = "0.2"
|
||||
@@ -106,4 +112,6 @@ wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
# zstd without C, for the browser: reads the packed files native builds write.
|
||||
ruzstd = "0.8"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob", "Request", "RequestInit", "Response"] }
|
||||
|
||||
@@ -76,6 +76,101 @@ are credited in `THIRD_PARTY.md`.
|
||||
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
|
||||
moving layers between pages; remote image URLs (see README Known gaps).
|
||||
|
||||
### 2026-10-28: 0.8.2 (refresh-rate cap, accurate RAM/CPU, toolbar)
|
||||
|
||||
* Settings > Performance > Refresh rate is now a real frame cap applied live (30 to 360 Hz, 60 by default); **Auto** draws as fast as the display allows and is the default on macOS.
|
||||
* Performance Monitor: RAM is the private working set and CPU is divided by logical cores on Windows, so both match Task Manager.
|
||||
* Windows and Linux toolbar: a hamburger button shows or hides the File / Edit strip (remembered); the close button uses the theme's danger colour on the acrylic surface, with hover animation and a pointing-hand cursor.
|
||||
|
||||
### 2026-10-28: 0.8.1 (Performance Monitor on Windows, Design tab speed)
|
||||
|
||||
* Performance Monitor: RAM (working set) and CPU now show on Windows; before they were `n/a` there. The overlay and slow-frame badge judge frames against a refresh rate you pick in Settings > Performance: 30 to 360 Hz (144, 165 and 240 included), 60 by default.
|
||||
* Design tab: the lint and the open-feedback scan are kept until the document changes instead of walking every node each frame; a 12.6k-node page no longer drops to a few FPS while the tab is open.
|
||||
* Windows and Linux: window buttons in the toolbar; controls and acrylic come from craft-ui.
|
||||
|
||||
### 2026-10-27: 0.8.0 (screens, packed pages, canvas performance)
|
||||
|
||||
* Screens (`specs/screens`): a frame can be declared a screen or an overlay (drawer, modal, sheet) with `set_screens`, inspector toggles, or Declare N screens on the page. Agent edits to layers hidden behind an overlay or opaque layer in their screen are refused unless `allowHidden`; partly covered ones are reported with their visible share. `get_basic_info` lists screens, `get_node_info` tells a layer's visibility, `lint_design` skips hidden layers, On click lists screens, nested screens get canvas labels.
|
||||
* Pages and saved versions are stored as zstd-compressed compact JSON (`.json.zst`, project format 3): the 68 MB Interview project is 1.3 MB on disk and loads faster. A legacy `.json` still reads and is replaced on the next save; older builds refuse format 3.
|
||||
* Canvas performance: the editor logs which layers each change touched, so the scene cache re-tessellates only those and keeps no copy of the document (443 → 346 MB on Interview). Zoom holds its geometry until it settles, tiny layers are skipped, vertices are 24 bytes, redraws follow vsync, the idle pointer no longer redraws, freed memory goes back to the system when idle, off-screen frame labels are skipped, and the layers panel keeps its row heights between frames (panning a large page: ~9 → ~0.7 ms a frame).
|
||||
* Frame labels: none on frames too narrow on screen, overlapping labels are dropped (selected and larger frames first), long names end in an ellipsis.
|
||||
* Dropdowns size to their longest option (capped), keep the name of `A / B / Name` options, scroll when long and show the full name on hover.
|
||||
* Settings: a Performance page (FPS, frame time, RAM, CPU, GPU, optional overlay and slow-frame badge), agent logos in the agent rows; the Chat page no longer collapses.
|
||||
|
||||
### 2026-10-25: 0.8.0 (properties modal, row merges)
|
||||
|
||||
* Properties modal (inspector, on a main: Edit properties…): a tab per property (name, kind, layer, default, delete); an options property as a Table (a row per option, a column per layer · style; colours picked from the project's variables with a swatch, visibility as a toggle, icon, image and text fields; add or remove options and columns) or as Source (each option's changes as JSON with syntax highlighting); templates from the + Property menu; Save replaces them all. The inline list is name, kind and a remove button. The `status` template writes colours the project has as variables as those variables.
|
||||
* Canvas overlays (agent glow, agent outlines, review outlines, the agent-elsewhere chip) sit below modals instead of showing through them. Two segmented controls in one row no longer share widget ids (the second did not respond).
|
||||
* `merge_variants` keeps what each moved instance shows (texts that came from its old variant), carries nested instances' property values (a badge's Tone) in the options, and takes `textLayers` for state texts only.
|
||||
|
||||
### 2026-10-25: 0.8.0 (prototype links tool, merge icon colours)
|
||||
|
||||
* `set_prototype_links`: agents can link a layer to a screen for Present mode (back arrows, rows, tabs); before, only the inspector could.
|
||||
* `merge_variants` compares an icon's colour (keeping its variable), now that icons are compared as one unit.
|
||||
|
||||
### 2026-10-25: 0.8.0 (data safety fixes)
|
||||
|
||||
* Sync no longer throws away an instance written over a nested one the way older versions stored it (its own root, no swap): it becomes a swap of the main's child at that place. Projects built before #11 lost such layers (a checked checkbox, a status badge) on the next edit of the outer main.
|
||||
* Icons: the HTML parser keeps attribute names in lowercase, so the icon and plugin code never removed the SVG's original `viewBox`; since #14 that made `<x-paper-icon>` (and icon swaps) scale twice and come out tiny. Unreleased builds only; 0.7.3 is not affected.
|
||||
* `merge_variants` refuses merges that would leave one option and changes nothing then.
|
||||
|
||||
### 2026-10-25: 0.8.0 (merge variants, pinned bars)
|
||||
|
||||
* `merge_variants`: per-state components (`Tab bar / Hoje`, `Status / Sucesso`...) become one component with an enum property. Each variant's differences from the base, matched by layer path (fills, colours, borders, weight, radius, visibility, icons; texts on request), become its option; every instance on every page moves to the base with its value; the base takes the family name; the emptied variants are deleted. A variant with layers the base lacks is left out and reported.
|
||||
* Absolute children of a scrolling screen stay pinned while its content scrolls (a tab bar, a floating button), and do not add to the scroll range.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fix #22)
|
||||
|
||||
* #22: a hug column whose width comes from its parent (stretched or filled) re-measures its height once its text wraps at that width, and the parent measures again, instead of keeping a height measured while the text was narrow.
|
||||
|
||||
### 2026-10-25: 0.8.0 (component properties)
|
||||
|
||||
* Main components carry properties (`Node.props`): `text`, `bool`, `icon` and `enum`, whose options are changes in the overrides format (a layer name, `$root` or a path `Item > Label`, to a text or styles that may hold `text`, `icon` and `visibility`), so they restyle, retext, show, hide or re-icon any layer. Instances keep their values (`prop_values`) as overrides, so main edits keep them; changing the definitions re-applies every instance on every page.
|
||||
* Templates: `text` (a property per text layer), `active` (which item of a tab bar, nav or segmented row is active) and `status` (options with a colour tint the root and colour the label and icon).
|
||||
* `set_component_props`, `set_instance_props`, `props='{...}'` on `<x-paper-instance>`, properties in `get_components`, `{Prop}` in code link templates, and an inspector section (a control per property on an instance; Texts and Active item templates on a main). Icon swaps keep the layer's name and link (a `children` override); recoloured icon shapes inside instances now keep their colour after a sync.
|
||||
* Spec: `specs/component-props/spec.md`.
|
||||
|
||||
### 2026-10-25: 0.8.0 (scrolling screens)
|
||||
|
||||
* Screens keep their size and scroll what overflows: a frame's `scroll` flag (CSS `overflow: auto|scroll`, `overflow-y-auto` in the Tailwind export) makes it fixed height and clipped, and the wheel over it scrolls its content on the canvas and in Present, then pans once the content ends. The offset is view state, not saved; hit-testing and overlays follow it.
|
||||
* Toggles: a scroll button beside each screen's name (shown when it scrolls, overflows, or is hovered), Scroll overflow on a selected frame, and Screens scroll on the page for every screen at once. A fit-content screen that grew goes back to its min height.
|
||||
* Agents are told to keep screens at their size and use overflow:auto for long content.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fixes #19, #20, #21)
|
||||
|
||||
* #19: a clipping frame draws its border after its children, so filled rows no longer hide it. Clipping stays rectangular: children's square corners still show past a rounded border.
|
||||
* #20: a border written with a colour variable (`1px solid var(--border)`) binds the stroke, a literal one unbinds it, and reads show `var(--name)` for bound strokes. `find_nodes` says when it stopped at 500 matches.
|
||||
* #21: hug (fit-content) frames grow around growing children (CSS flex-basis auto) instead of staying at their min height; with short content the min height stays and the growing child fills it, so a pinned footer works.
|
||||
|
||||
### 2026-10-25: 0.8.0 (follow agents, skeletons)
|
||||
|
||||
* Follow agents (Settings, Chat; on by default): in Design and Chat mode alike, the canvas pans, and switches page, to the layer any agent changes (the chat, Claude Code over MCP, a helper). Moving the canvas yourself pauses it for a moment; off, a chip offers the agent's page.
|
||||
* Skeletons: the moment the model starts writing a tool call (`ToolDraft` and `ToolInput` events from Claude Code's stream and the API), the canvas shows a shimmering skeleton over its target, or where a new artboard goes, labelled with what it is doing, until the call has run. Fresh agent content comes in through a skeleton that fades, so an outline never frames empty space.
|
||||
* Top bar: the title and the agent pill leave room measured from the buttons on the right, so nothing overlaps at narrow widths.
|
||||
|
||||
### 2026-10-25: 0.8.0 (fixes #14 to #18)
|
||||
|
||||
* #14: `write_html` fits an `<svg>` viewBox into its width and height like a browser (centred, aspect kept).
|
||||
* #15: a note on something placed straight in its artboard treats the artboard as the screen: tidy keeps cards off it instead of in its free space.
|
||||
* #16: `not-an-instance` no longer matches look-alikes with a different icon; its fix names the `<x-paper-instance>` to write (swap_instance refuses plain frames).
|
||||
* #17: instances with their own text re-measure it when the main's text style changes, so hug buttons keep fitting their labels (also covers the size reset after a swap).
|
||||
* #18: `lint_design` reports borders whose hex equals a color variable, and `fix` binds them (`stroke`), so a mode switch reaches them.
|
||||
|
||||
### 2026-10-25: 0.7.3 (QA sub-agent, helper agents, auto mode, mid-turn messages, isolated dev settings)
|
||||
|
||||
* QA sub-agent: Claude Code chats always get a Sonnet `qa` agent that lints and screenshots built frames and returns only findings, so screenshots stay out of the main context.
|
||||
* Helper agents (`src/mcp/delegate.rs`, Settings, Chat, Helper): `delegate` hands `qa`, `read` or `build` tasks to a cheaper agent CLI (pi with MiniMax M3.1 Flash, opencode, an Ollama-backed agent, any command taking `{prompt}`). It drives PaperCraft through `papercraft-cli`, runs off the UI thread, and only its last 6,000 characters come back. With a helper set, the chat agent orchestrates: keeps the brief and taste, delegates review, bulk reading and fully specified edits.
|
||||
* Dev config isolation: `PAPERCRAFT_HOME` also isolates the chat settings (`$PAPERCRAFT_HOME/.config`), so a second copy for testing never touches the installed app's settings.
|
||||
* Live check in an isolated copy: `delegate` ran the pi helper (MiniMax M3.1 Flash) as QA, a message added mid-turn went Waiting then Seen, and the agent drafted #13.
|
||||
* Fixes #10, #11, #12, #13:
|
||||
* #10: `move_nodes` to another page keeps mains' component keys and relinks every instance to moved nodes; new `rename_file` renames a project.
|
||||
* #11: swapped nested instances persist as swap overrides (`InstanceLink.swap`), preserving component identity across outer component updates and copies.
|
||||
* #12: `move_nodes` to another page takes the moved nodes' annotations along; detached notes resolve as `Detached #n`.
|
||||
* #13: `get_computed_styles` reports a text colour bound to a variable as `var(--name)`.
|
||||
* Auto mode (the composer's Auto chip): when a turn ends on `ask_user` questions, the first (recommended) option of each is sent and the chat goes on, until Stop or a turn hits its budget. The agent is told to put its recommendation first, default what only the user can know, and end each turn with next steps.
|
||||
* The Present button uses a play icon drawn like the icons beside it. Composer chips wrap instead of running under Stop and Add. Top bar: the agent pill drops its text when it would run under the buttons on the right.
|
||||
* While a chat turn works, the composer's Add (or Enter) queues a message (`src/mcp/steer.rs`). It rides on the agent's next PaperCraft tool result for that chat, so every backend reads it mid-turn; the composer shows each as Waiting or Seen. What no tool call picked up is sent as the next message when the turn ends, or goes back into the box after Stop.
|
||||
|
||||
### 2026-10-24: 0.7.2 (UI kits, code links, issues from the app, design playbook)
|
||||
|
||||
* Code links (Figma Code Connect style): a main component carries its library import and a React and an egui template (`set_code_link`). The React, Tailwind, inline JSX and egui exports write linked instances as that code, with overrides filled in and imports gathered once; the AI spec lists them as Library Components with a "use it, do not rebuild it" rule and stops describing their inner layers.
|
||||
|
||||
@@ -62,3 +62,15 @@ SOFTWARE.
|
||||
|
||||
Inter 4.1 by Rasmus Andersson — SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). Regular,
|
||||
Medium, SemiBold, Bold, Italic and Bold Italic are bundled unmodified.
|
||||
|
||||
Interface fonts, SIL Open Font License 1.1, Latin subsets as served by Google Fonts, unmodified
|
||||
(Regular, Medium, SemiBold): Geist (Vercel), Figtree (Erik Kennedy), DM Sans (Colophon Foundry,
|
||||
Jonny Pinhorn), Nunito (Vernon Adams, Jacques Le Bailly), Quicksand (Andrew Paglinawan),
|
||||
Fredoka (Milena Brandão, Hafontia), and Varela Round (Joe Prince; Regular only).
|
||||
|
||||
## Agent logos
|
||||
|
||||
`assets/agents/*.svg` are from LobeHub Icons (`@lobehub/icons-static-svg` 1.80.0,
|
||||
https://github.com/lobehub/lobe-icons), MIT License, Copyright (c) LobeHub. They are used
|
||||
unmodified to identify each coding agent in the agent picker. The marks are trademarks of
|
||||
their owners.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cline</title><path d="M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z"></path><path d="M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z"></path></svg>
|
||||
|
After Width: | Height: | Size: 750 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>
|
||||
|
After Width: | Height: | Size: 651 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Tencent</title><path d="M9.976 1L24 9.8l-10.587.015L10.723 23H5.489L8.18 9.8H3.244L1 5.4h8.077L9.976 1z"></path></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 718 KiB After Width: | Height: | Size: 720 KiB |
|
Before Width: | Height: | Size: 673 KiB After Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 636 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 837 KiB After Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 691 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 709 KiB After Width: | Height: | Size: 712 KiB |
|
Before Width: | Height: | Size: 882 KiB After Width: | Height: | Size: 885 KiB |
@@ -59,7 +59,7 @@ function Get-PeHeader([string] $Path) {
|
||||
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
|
||||
}
|
||||
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
|
||||
foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe')) {
|
||||
foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe', 'papercraft-relay.exe')) {
|
||||
$exePath = Join-Path $Bin $name
|
||||
if (Test-Path $exePath) {
|
||||
$h = Get-PeHeader $exePath
|
||||
@@ -71,13 +71,13 @@ foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe'
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
Copy-Item (Join-Path $Bin 'papercraft.exe'), (Join-Path $Bin 'papercraft-cli.exe'), (Join-Path $Bin 'papercraft-mcp.exe') $Stage
|
||||
Copy-Item (Join-Path $Bin 'papercraft.exe'), (Join-Path $Bin 'papercraft-cli.exe'), (Join-Path $Bin 'papercraft-mcp.exe'), (Join-Path $Bin 'papercraft-relay.exe') $Stage
|
||||
foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md', 'README.md') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $Stage }
|
||||
}
|
||||
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'papercraft.exe') (Join-Path $Stage 'papercraft-cli.exe') (Join-Path $Stage 'papercraft-mcp.exe')
|
||||
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'papercraft.exe') (Join-Path $Stage 'papercraft-cli.exe') (Join-Path $Stage 'papercraft-mcp.exe') (Join-Path $Stage 'papercraft-relay.exe')
|
||||
|
||||
# ---- MSI ---------------------------------------------------------------------------------------
|
||||
$Msi = Join-Path $Dist "papercraft-$Version-windows-$Arch.msi"
|
||||
@@ -119,9 +119,22 @@ $CliZip = Join-Path $Dist "papercraft-cli-$Version-windows-$Arch.zip"
|
||||
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
|
||||
|
||||
# ---- relay zip ---------------------------------------------------------------------------------
|
||||
$RelayPortable = Join-Path $TargetDir "windows-package\papercraft-relay-$Version-windows-$Arch"
|
||||
Remove-Item -Recurse -Force $RelayPortable -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $RelayPortable | Out-Null
|
||||
Copy-Item (Join-Path $Stage 'papercraft-relay.exe') $RelayPortable
|
||||
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $RelayPortable }
|
||||
}
|
||||
$RelayZip = Join-Path $Dist "papercraft-relay-$Version-windows-$Arch.zip"
|
||||
Remove-Item -Force $RelayZip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $RelayPortable -DestinationPath $RelayZip
|
||||
|
||||
# Smoke test
|
||||
Invoke-Native 'papercraft-cli --help' { & (Join-Path $Stage 'papercraft-cli.exe') --help | Out-Null }
|
||||
|
||||
$artifacts = @($Zip, $CliZip)
|
||||
$artifacts = @($Zip, $CliZip, $RelayZip)
|
||||
if (Test-Path $Msi) { $artifacts += $Msi }
|
||||
Get-Item $artifacts | Format-Table Name, Length
|
||||
|
||||
@@ -33,7 +33,7 @@ Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sectio
|
||||
## 2. Tokens first (variables)
|
||||
|
||||
* All colors are variables (`var(--name)`), named by role: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `success`, `warning`, `danger`. Two to three surface levels at most (`bg` < `surface` < `surface-2`).
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`, or look with `get_screenshot {mode}` without switching. Themes are modes, never copies: one artboard per screen, never a light and a dark duplicate (`lint_design` flags them as `theme-copy`).
|
||||
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
|
||||
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
|
||||
* Spacing, radius and type sizes are number variables (`space/md`, `radius/sm`, `font/body`): define them once, in a collection of their own, and bind fields with `bind_variable`. Keep the values on the lists in this skill. Group names with `/` and alias semantic names to base ones (`color/button/bg` to `color/brand/primary`).
|
||||
|
||||
@@ -45,7 +45,7 @@ Lessons from redesigning a 3-page, 130-screen project with up to six agents at o
|
||||
* **Split by section, not by page.** Give each agent named sections (frame ids) and forbid the rest. New sections go below the current lowest one; re-read positions right before creating, since another agent may have just added one.
|
||||
* **Section layout**: a section frame holds a title and rows of screen slots (a route label above a fixed-size screen). Keep 640 px between screens and rows and 1600 px between sections, so notes fit beside screens. Stack sections in one column.
|
||||
* **Recreate, then delete.** Build the new screen next to the old one, finish it, then delete the old one, and record the deleted ids and names in the report. A cleanup agent once deleted buttons and lost icons by editing in place.
|
||||
* **Reuse the design system across pages**: `create_instance` / `swap_instance` / `<x-paper-instance>` take a main from any page and stay linked. Never `import_components` to use a component: that copies mains onto the page, and instances made from the copies stop following the design system. `lint_design` flags hand-made look-alikes as `not-an-instance` (with the component key to use).
|
||||
* **Reuse the design system across pages**: `create_instance` / `swap_instance` / `<x-paper-instance>` take a main from any page and stay linked. Never `import_components` to use a component: that copies mains onto the page, and instances made from the copies stop following the design system. `lint_design` flags hand-made look-alikes as `not-an-instance` (a different icon is a different component); fix with `write_html` mode replace and the `<x-paper-instance>` it names.
|
||||
* **QA pass after a build** (screen by screen at scale 1, one call at a time): text layers with stale fixed widths clip labels ("In prog", "New sess"): set `width: fit-content`. Sidebars and panels fixed at 900 px stop short on taller screens: make them fill the height and let the screen hug with a 900 px minimum. Tags and chips overflowing their box, cards overlapping buttons, footers not pinned, leftover `Text` / `Frame` names, gendered copy about people whose gender is unknown (use their name or neutral wording).
|
||||
* **Report per agent** in `specs/<feature>/<agent>-report.md`: what changed (ids), why (playbook rule ids from `playbook search`), open questions. Unknown product facts are written as questions for the team, never invented.
|
||||
|
||||
@@ -102,7 +102,7 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
|
||||
| Find nodes by text or style | `find_nodes` (text contains the phrase, any case, every page) |
|
||||
| Read all copy in a subtree | `get_tree_summary {nodeId, fullText: true}` (depth counts from the node: rows under a "Screens" frame need depth 3) |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `annotations: true` to include notes) |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `annotations: true` to include notes, `mode` to preview a theme without switching) |
|
||||
| Show the user something | `set_viewport` (page switch, fit, zoom) |
|
||||
| Document rules on the design | `annotate`, `get_annotation_template`, `get_annotations`, `update_annotation`, `delete_annotations` |
|
||||
| New screen | `create_artboard` (`width` and `height` required) |
|
||||
@@ -111,13 +111,19 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css, json or tailwind), `set_variables` (`collection`, `type`, `mode`, `alias`), `create_collection`, `delete_variables`, `set_variable_mode`, `bind_variable` |
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| Reuse | `create_component`, `create_instance`, `swap_instance` (also a nested badge inside a row instance: a swap that survives main edits), `get_components`, `<x-paper-instance>` |
|
||||
| Component properties | One component with properties instead of one per state: `set_component_props` (`text`, `bool`, `icon`, `enum` options in the overrides format; templates `text`, `active` for tab bars and navs, `status` with [{value, color}]), then `set_instance_props {nodeIds, values}` or `<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>`; `get_components` lists them |
|
||||
| Merge per-state components | `merge_variants {componentIds, name}` turns `Tab bar / Hoje`, `Tab bar / Lojas`... into one `Tab bar` with an enum, moves every instance and deletes the emptied variants |
|
||||
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode |
|
||||
| Screens | Fixed size (390x844, 1440x900...); content longer than the screen: `overflow: auto` on the screen frame, so it keeps its size and the overflow scrolls on the canvas and in Present (inspector: Scroll overflow; page: Screens scroll) |
|
||||
| Project | `rename_file {name}` (open project stays open), `move_nodes {toPageId}` keeps instances linked and notes attached |
|
||||
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
| Design in a library's look, hand off its code | `apply_ui_kit` (shadcn, antd, reactbits, egui), then `<x-paper-instance>` of its components; `set_code_link` for a component of your own; `get_jsx` / `get_design_spec` then write `<Button variant="outline">` instead of divs |
|
||||
| Avatars, more icons, photos, flags, QR | `<x-plugin use="avataaars\|dicebear\|iconify\|picsum\|flags\|qr" …/>` inside `write_html` (plugins the user turned on) |
|
||||
| References and art | `fetch_reference_images`, `zoom_image`, `generate_image` (when the user turned image generation on) |
|
||||
| Ask the user | `ask_user` (clickable answers; end your turn after it) |
|
||||
| Hand work to the helper agent | `delegate` (`role` qa, read or build, a self-contained `task`; only when Settings, Chat, Helper is set) |
|
||||
| Design rules (design-playbook) | `design_rules` (`query` or `rule` id; needs the `playbook` CLI; cite ids in the report) |
|
||||
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
|
||||
| Check quality | `lint_design` |
|
||||
|
||||
@@ -207,6 +207,7 @@ Render a node (or the whole page when nodeId is omitted or "root") to a PNG and
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `annotations` | boolean | | Also draw the note cards, badges and leader lines of notes on the captured layers; the image grows to include the cards. |
|
||||
| `mode` | string | | Render in this variable mode (e.g. "Light") without switching the document: preview every theme of one artboard while other agents keep working in the current mode. |
|
||||
| `nodeId` | string | | The node to capture; omit for the whole page. |
|
||||
| `out` | string | | Optional file path to save the PNG to (also used by papercraft-cli). |
|
||||
| `scale` | number | | Render scale: 1 (default) or 2. |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Component properties
|
||||
|
||||
## Why
|
||||
|
||||
A status badge, a tab bar or a list row is drawn once as a component, but every use differs:
|
||||
the status is Autorizada, Rejeitada or Na SEFAZ; a different tab is active; the label and the
|
||||
icon change. Today each case is its own component (`Badge / Green`, `Tabs / Lojas`, `Tabs /
|
||||
Pedidos`) or hand edits on layers, so a design needs a component per state and an agent edits
|
||||
layer by layer.
|
||||
|
||||
## What
|
||||
|
||||
1. **Properties live on the main component** (`Node.props`), each with a name and a type:
|
||||
* `text`: the text of a layer (`layer`).
|
||||
* `bool`: shows or hides a layer.
|
||||
* `icon`: the icon of an icon layer (`Icon / set:name`), any `set:name`.
|
||||
* `enum`: a list of options; each option is a set of changes in the `overrides` format
|
||||
(`{"Label": "Rejeitada", "$root": {"background": "var(--danger-bg)"}, "Dot": {"icon": "lucide:x"}}`),
|
||||
so an option can restyle, retext, show, hide or re-icon any layer. Fully custom.
|
||||
2. **Instances keep their values** (`Node.prop_values` on the instance root) and apply them as
|
||||
overrides, so edits to the main keep them; editing the definitions re-applies every
|
||||
instance's values on every page.
|
||||
3. **Templates** make the common cases one call: `text` (a text property per text layer),
|
||||
`active` (an enum choosing which item of a row, tab bar or nav is active, with active and
|
||||
inactive styles) and `status` (an enum of statuses, each a colour that tints the root and
|
||||
colours its text and icon).
|
||||
4. **Where to use it**: `set_component_props` (define, or from a template),
|
||||
`set_instance_props` (many instances in one call), `get_components` (lists properties),
|
||||
`<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>` in `write_html`,
|
||||
the inspector (a control per property on an instance; templates on a main), and code
|
||||
links (`{Status}` in a usage template is the instance's value).
|
||||
|
||||
## Not now
|
||||
|
||||
Variant sets made of several mains, nested property exposure (a row exposing its badge's
|
||||
properties), numeric properties.
|
||||
|
||||
## Acceptance
|
||||
|
||||
* A badge with an enum Status (Autorizada, Rejeitada) shows the option's colours and label on
|
||||
each instance; switching the value updates only that instance.
|
||||
* Editing the main (gap) keeps every instance's values; editing an option's colour updates
|
||||
every instance using it.
|
||||
* The `active` template on a five-item tab bar makes one property whose options are the item
|
||||
names; each instance shows its active item highlighted.
|
||||
* An icon property swaps the icon and survives a sync of the main.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Screens — plan
|
||||
|
||||
Built against `spec.md` (decisions resolved 2026-10-27).
|
||||
|
||||
## Model
|
||||
- `Node::screen` and `Node::overlay` (bools, omitted when false). Shipped in project format v3, which is not released yet, so no extra version bump is needed: older builds already refuse v3.
|
||||
|
||||
## Editor (`src/editor/screens.rs`)
|
||||
- `Role`, `refusal`, `declared`, `screen_of`, `candidates`, `device_of`, and `Editor::set_role`, which changes roles as one undo step.
|
||||
- `coverage`: inside the layer's screen, it finds layers painted after it that are visible and either overlays or opaque (fill at 95% opacity or more), clips them to the screen, and returns the union area as a visible share. A layer under 1% visible counts as hidden; under 99% visible, as partly covered.
|
||||
- `Editor::screens()` returns the declared screens, or the top-level frames when none are declared. "Screens scroll" therefore stops toggling sections.
|
||||
- Frame labels: declared screens nested in sections get labels. A frame's own ancestors don't count as covering its label.
|
||||
|
||||
## MCP
|
||||
- `set_screens {nodeIds, role}`.
|
||||
- The guard runs on every tool in `GUARDED`. It reads `nodeId`, `nodeIds`, `parentId` and `targetNodeId` at any depth in the arguments.
|
||||
- It refuses hidden targets unless `allowHidden` is set.
|
||||
- It appends a `partlyCovered` or `editedWhileHidden` note to the result.
|
||||
- `get_basic_info.screens`, `get_node_info.screen`, `lint_design.skippedHidden` (hidden layers are dropped from the findings).
|
||||
|
||||
## UI
|
||||
- The frame inspector has Screen and Overlay toggles, shown only where the role is allowed.
|
||||
- The page inspector has a "Declare N screens" button listing the candidates, as one undo step.
|
||||
- "On click" lists `ed.screens()`.
|
||||
|
||||
## Known ceilings
|
||||
- Covers are clipped to the screen only, not to frames in between.
|
||||
- Lint autofix still edits hidden layers.
|
||||
- Export and design-to-code don't use screens yet (planned for later).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Screens
|
||||
|
||||
## Problem
|
||||
PaperCraft has no notion of a "screen": a screen, a section that groups screens, a slot that labels one, and a component main are all just frames. Big projects like Interview (42k layers) nest screens three or four frames deep (Section > Screens > Row > Slot > Screen), so every frame-based feature guesses wrong:
|
||||
- The canvas shows labels only for top-level frames, so the nested screens get none.
|
||||
- The "On click" link list offers sections and labels instead of screens.
|
||||
- Scrolling picks whichever scroll-enabled frame is deepest.
|
||||
- Agents cannot tell which frame is the device viewport.
|
||||
|
||||
The owner reports a second, related problem. When a drawer or modal is open over a screen, agents still edit, lint and "fix" the content hidden underneath it. The owner can't see that work, it can't be checked in a screenshot, and it often breaks the open-state design. Evidence: owner reports in this session (2026-10-27) about agents working behind open drawers and screen scroll caught by the section frame.
|
||||
|
||||
## Goal
|
||||
A frame can be declared a Screen. Agents and the editor then treat it as one device viewport, and agent edits to layers fully hidden inside it are refused with a clear reason.
|
||||
|
||||
## Non-Goals
|
||||
- Prototyping or interactive preview (flows, transitions, overlays opening on click).
|
||||
- Responsive variants or auto-generating breakpoints from one screen.
|
||||
- Occlusion between separate screens on the canvas, or between sections. Only layers inside one screen count.
|
||||
- Pixel-exact occlusion through transparency, blur or blend modes. Translucent covers (scrims) are judged by a rule, not by rendered pixels.
|
||||
- Changing how frames that are not screens behave.
|
||||
- Migrating existing projects automatically without the owner choosing to.
|
||||
|
||||
## User Stories
|
||||
- As a designer, I want to mark a frame as a Screen (with a device size), so that PaperCraft and agents know it is a page of the product, not a container.
|
||||
- As a designer, I want screens to show their name and device on the canvas even when nested in sections, so that I can read a large page at a glance.
|
||||
- As a designer, I want "On click" and other screen pickers to list only screens, so that I can find the target among hundreds of frames.
|
||||
- As a designer working with an agent, I want the agent stopped from editing layers that are fully hidden behind an open drawer or modal, so that it can't change what I can't see.
|
||||
- As an agent, I want each layer's visibility in its screen (visible, partly covered, hidden) and what covers it, so that I can choose the right target or open the right state first.
|
||||
- As a designer, I want scrolling over a screen to scroll that screen's scroll area, not its section, so that I can review long screens in place.
|
||||
- As a designer, I want to convert existing screen-like frames into Screens in one step, so that big projects get the benefit without hand-marking each one.
|
||||
|
||||
## Acceptance Criteria
|
||||
Declaring screens:
|
||||
- [ ] Given a frame, when the owner or an agent declares it a Screen, then it is saved as a Screen with a device size, survives save and reload, and the change is one undo step.
|
||||
- [ ] Given a frame that is a component main, an instance, or inside another Screen, when someone tries to declare it a Screen, then the request is refused with a message that names the reason.
|
||||
- [ ] Given a Screen, when it is undeclared, then it goes back to an ordinary frame and its layers and layout do not change.
|
||||
- [ ] Given a project file written before this feature, when it opens, then it loads with no Screens and nothing else changes.
|
||||
- [ ] Given a project that has Screens, when an older PaperCraft build opens it, then the behaviour matches the project's existing version-compatibility rules (refused or loaded without screen data), never silent data loss.
|
||||
|
||||
Agent tools:
|
||||
- [ ] Given a page with Screens, when an agent asks for the page summary, then Screens are listed as screens (name, device, size, section they sit in), separate from other frames.
|
||||
- [ ] Given a Screen where an opaque drawer covers a layer entirely, when an agent edits that layer (styles, text, delete, move, write content into it), then the edit is refused. The reason names the covering layer and is returned as an error, not a silent no-op.
|
||||
- [ ] Given the same layer only partly covered, when an agent edits it, then the edit succeeds and the result says the layer is partly covered, by which layer, and the visible fraction.
|
||||
- [ ] Given the owner, not an agent, edits a fully hidden layer in the app, then the edit is allowed (the guard applies to agent tools only).
|
||||
- [ ] Given an agent that passes an explicit override on a refused edit, then the edit succeeds and the result records that it was hidden. (Pending Open Question 3.)
|
||||
- [ ] Given a layer that is hidden only because its parent clips it or it scrolled out of view, then it is not treated as covered. Scroll and clipping are reported separately from covering.
|
||||
- [ ] Given a Screen with a hidden layer, when an agent lints that Screen, then hidden layers are skipped (or reported once as "hidden") instead of producing fix-me findings.
|
||||
- [ ] Given an agent reads a layer's info, then the response includes its visibility state in its Screen: visible, partly covered, hidden, or not in a screen.
|
||||
|
||||
Editor:
|
||||
- [ ] Given Screens nested in sections, when the canvas is zoomed out, then each Screen shows a name label (subject to the existing label overlap rules), and sections keep theirs.
|
||||
- [ ] Given the "On click" picker, when a project has Screens, then it lists Screens (grouped by section) and no other frames.
|
||||
- [ ] Given the pointer over a Screen whose own scroll area can still move, when the owner scrolls, then that area scrolls and the section around the Screen does not.
|
||||
- [ ] Given a page with screen-like frames (same size as a known device, or named "Screen / ..."), when the owner runs "Convert to Screens", then the owner sees the candidates and confirms, and the declaration is one undo step.
|
||||
|
||||
Performance:
|
||||
- [ ] Given the Interview Admin page (19k layers), when an agent edits a layer inside a Screen, then the hidden check adds no more than 5 ms to the call.
|
||||
|
||||
## Constraints
|
||||
- Stack: Rust, egui 0.36, wgpu; existing MCP tools and `papercraft-cli` (online and offline) must behave the same.
|
||||
- Performance: no extra cost per canvas frame; the hidden check runs only when an agent tool targets layers.
|
||||
- Security: none new. The refusal is a safety rail for agents, not a permission system.
|
||||
- Compat: the project format version bump follows the existing format migration rules. Web (wasm) build keeps parity.
|
||||
|
||||
## Open Questions
|
||||
None open. Resolved 2026-10-27 (owner: "go with your recommendations"):
|
||||
1. Cover: a layer later in paint order inside the same Screen that is opaque (solid or image fill at 95%+ effective opacity), or any layer marked as an overlay (drawer, modal, sheet), even if translucent.
|
||||
2. Partly covered: an edit passes when at least 1% of the layer's area is visible; the result reports the visible share.
|
||||
3. Override: yes, agents can pass an explicit flag; the result records that the layer was hidden.
|
||||
4. Overlays: a direct child of a Screen can be marked as an overlay. To work underneath it, agents hide it (an ordinary undo step); no separate toggle.
|
||||
5. Device: presets (mobile 390×844, tablet 768×1024, desktop 1440×900, large 2560×1440) or free size. The Screen's height is the viewport; taller content scrolls.
|
||||
6. Labels: top-level frames keep labels, and nested Screens gain them. Other nested frames get none.
|
||||
7. Convert to Screens: candidates are frames named "Screen / …", frames that are the screen inside a "Slot / …" frame, or top-level frames at a preset size. The owner confirms in the app; agents declare by id only.
|
||||
8. Never inside a component main or another Screen.
|
||||
9. First release: On click, page summary, lint scope, layer info. Export and design-to-code follow later.
|
||||
@@ -31,8 +31,38 @@ struct Gfx {
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
}
|
||||
|
||||
/// Repaint delays this short are drawn on the next vsync instead of a timer: about one frame
|
||||
/// at 60 Hz, so animations follow the display's refresh rate.
|
||||
const FRAME_SLACK: Duration = Duration::from_millis(16);
|
||||
/// Layers re-tessellated in one frame past which the freed memory is handed back when idle.
|
||||
const RELIEF_MISSES: usize = 1000;
|
||||
|
||||
/// Hands memory freed by a large rebuild back to the system: the allocator otherwise keeps the
|
||||
/// freed pages in the process (counted in its footprint) for reuse.
|
||||
fn release_freed_memory() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
unsafe extern "C" {
|
||||
fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize;
|
||||
}
|
||||
// SAFETY: a null zone means every zone, and a goal of 0 as much as can be released.
|
||||
unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
// SAFETY: malloc_trim has no preconditions.
|
||||
unsafe {
|
||||
libc::malloc_trim(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortest gap between frames drawn for an agent's edits alone (20 FPS).
|
||||
const AGENT_FRAME: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct App {
|
||||
gfx: Option<Gfx>,
|
||||
/// The undecorated window edge under the pointer (Linux), which a press resizes.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: Option<winit::window::ResizeDirection>,
|
||||
pub ed: Editor,
|
||||
session: Session,
|
||||
st: UiState,
|
||||
@@ -45,6 +75,10 @@ pub struct App {
|
||||
title: String,
|
||||
/// When egui asked for a delayed repaint (tooltips, caret blink, animations).
|
||||
next_repaint: Option<Instant>,
|
||||
/// When the last frame was drawn (paces redraws for agent edits).
|
||||
last_redraw: Option<Instant>,
|
||||
/// A large rebuild freed memory: hand it back to the system once the app is idle.
|
||||
relief_due: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: Option<crate::macos::NativeMenu>,
|
||||
/// Tool calls from the MCP server, and the server itself (stops when dropped).
|
||||
@@ -79,11 +113,17 @@ pub enum Start {
|
||||
impl App {
|
||||
pub fn new(start: Start, store: Store) -> App {
|
||||
let ctx = egui::Context::default();
|
||||
theme::set_material(theme::Material::load());
|
||||
theme::set_roundness(theme::Roundness::load());
|
||||
theme::set_ui_font(theme::load_ui_font());
|
||||
theme::set_accent_choice(theme::load_accent_choice());
|
||||
theme::apply_choice(&ctx, theme::Choice::load());
|
||||
theme::apply_ui_scale(&ctx, theme::load_ui_scale(), false);
|
||||
let library = crate::library::Library::load(&crate::library::Library::path_in(&store.root));
|
||||
let mut app = App {
|
||||
gfx: None,
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
resize_edge: None,
|
||||
ed: Editor::default(),
|
||||
session: Session::new(store),
|
||||
st: UiState::default(),
|
||||
@@ -94,12 +134,16 @@ impl App {
|
||||
close_requested: false,
|
||||
title: String::new(),
|
||||
next_repaint: None,
|
||||
last_redraw: None,
|
||||
relief_due: false,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: None,
|
||||
mcp: None,
|
||||
runs: Default::default(),
|
||||
};
|
||||
app.st.library.library = library;
|
||||
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
|
||||
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
|
||||
app.ed.clipboard_io = crate::io::clipboard::System::new()
|
||||
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
|
||||
match start {
|
||||
@@ -257,6 +301,7 @@ impl App {
|
||||
self.save_project(true);
|
||||
}
|
||||
let counts = self.runs.counts(&self.ed);
|
||||
self.relief_due = true;
|
||||
match self.session.open(&mut self.ed, dir) {
|
||||
Ok(()) => {
|
||||
self.runs.end_counted(crate::project::format::now_ms(), counts, None);
|
||||
@@ -726,12 +771,14 @@ impl App {
|
||||
// A chat turn that edited is over: close its run now, not after the 30 s idle timeout.
|
||||
self.runs.finish(crate::project::format::now_ms(), &self.ed);
|
||||
}
|
||||
self.st.sync_runs(&mut self.runs, &self.ed);
|
||||
self.st.sync_runs(&self.ctx, &mut self.runs, &self.ed);
|
||||
self.st.chat.tools = self.tool_dispatch().map(crate::chat::events::ToolDispatch);
|
||||
if self.st.chat.gpu.is_none() {
|
||||
self.st.chat.gpu = self.offscreen();
|
||||
}
|
||||
let Some(g) = self.gfx.as_mut() else { return };
|
||||
let work = Instant::now();
|
||||
self.last_redraw = Some(work);
|
||||
let input = g.egui.take_egui_input(&g.window);
|
||||
let mut l = frame::layout(&self.ctx, input, &mut self.ed, &mut self.st);
|
||||
self.canvas = l.canvas;
|
||||
@@ -743,6 +790,8 @@ impl App {
|
||||
let platform = std::mem::take(&mut l.output.platform_output);
|
||||
g.egui.handle_platform_output(&g.window, platform);
|
||||
|
||||
// The wait for a drawable (vsync back-pressure) is not work: leave it out of the timing.
|
||||
let layout_ms = work.elapsed().as_secs_f32() * 1000.0;
|
||||
let frame = match g.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
@@ -770,6 +819,7 @@ impl App {
|
||||
{
|
||||
crate::macos::hide_traffic_lights(&g.window);
|
||||
}
|
||||
let work = Instant::now();
|
||||
let view = frame.texture.create_view(&Default::default());
|
||||
let size = [g.config.width, g.config.height];
|
||||
g.painter.paint(
|
||||
@@ -782,6 +832,8 @@ impl App {
|
||||
&self.ed,
|
||||
!self.st.show_grid_off,
|
||||
);
|
||||
self.st.perf.record(self.start.elapsed().as_secs_f64(), layout_ms + work.elapsed().as_secs_f32() * 1000.0);
|
||||
self.relief_due |= g.painter.cache.last_misses > RELIEF_MISSES;
|
||||
g.window.pre_present_notify();
|
||||
g.queue.present(frame);
|
||||
|
||||
@@ -834,13 +886,16 @@ impl App {
|
||||
} else {
|
||||
repaint_delay
|
||||
};
|
||||
if repaint_delay.is_zero() || pending || animating || !self.ed.requests.is_empty() {
|
||||
// A repaint due within a frame is drawn now and paced by vsync: a timer that short wakes
|
||||
// late and off the display's beat (84 FPS on a 120 Hz screen).
|
||||
if repaint_delay <= FRAME_SLACK || pending || animating || !self.ed.requests.is_empty() {
|
||||
g.window.request_redraw();
|
||||
self.next_repaint = None;
|
||||
} else if repaint_delay < Duration::from_secs(3600) {
|
||||
self.next_repaint = Some(Instant::now() + repaint_delay);
|
||||
} else {
|
||||
self.next_repaint = None;
|
||||
let timer = (repaint_delay < Duration::from_secs(3600)).then(|| Instant::now() + repaint_delay);
|
||||
// The zoom stopped between buckets: one more frame re-tessellates for it.
|
||||
let refine = g.painter.cache.refine_at();
|
||||
self.next_repaint = timer.into_iter().chain(refine).min();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,8 +1044,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
.with_corner_preference(CornerPreference::Round)
|
||||
.with_taskbar_icon(icon)
|
||||
};
|
||||
// Linux: no native frame either; the toolbar carries the window buttons and the window
|
||||
// edges resize (`edge_resize`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let attrs = attrs.with_decorations(false);
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
|
||||
self.st.custom_titlebar = cfg!(windows);
|
||||
self.st.custom_titlebar = cfg!(any(windows, all(unix, not(target_os = "macos"))));
|
||||
window.set_ime_allowed(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -1050,6 +1109,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
Some(device.limits().max_texture_dimension_2d as usize),
|
||||
);
|
||||
window.request_redraw();
|
||||
self.st.perf.gpu = format!("{} ({:?})", adapter_info.name, adapter_info.backend);
|
||||
self.gfx = Some(Gfx {
|
||||
window,
|
||||
surface,
|
||||
@@ -1069,6 +1129,10 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
log::trace!("event {event:?} -> egui repaint {}", resp.repaint);
|
||||
}
|
||||
let scale = g.window.scale_factor();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if self.st.custom_titlebar && edge_resize(&g.window, &event, &mut self.resize_edge) {
|
||||
return;
|
||||
}
|
||||
let mut redraw = resp.repaint;
|
||||
let now = self.now();
|
||||
match event {
|
||||
@@ -1105,6 +1169,14 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
// egui-winit reports `repaint: true` for RedrawRequested itself; honouring it
|
||||
// re-requested a redraw every frame (a 100% CPU busy loop). Whether another
|
||||
// frame is needed is decided inside redraw() from egui's repaint delay.
|
||||
// The frame-rate cap (Settings > Performance) holds a frame that comes too soon
|
||||
// until its turn; every redraw request ends up here, so one check covers them all.
|
||||
let since_last = self.last_redraw.map_or(Duration::MAX, |t| t.elapsed());
|
||||
if let Some(wait) = ui::perf::cap_wait(self.st.perf.prefs.frame_interval(), since_last) {
|
||||
let due = Instant::now() + wait;
|
||||
self.next_repaint = Some(self.next_repaint.map_or(due, |r| r.min(due)));
|
||||
return;
|
||||
}
|
||||
self.redraw();
|
||||
return;
|
||||
}
|
||||
@@ -1120,12 +1192,19 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let p = v2(position.x / scale, position.y / scale);
|
||||
if self.captured || self.over_canvas(p) {
|
||||
let before = (self.ed.hover, self.ed.measures.len());
|
||||
self.ed.handle(InputEvent::PointerMove {
|
||||
pos: self.to_canvas(p),
|
||||
pressure: None,
|
||||
time_ms: now,
|
||||
});
|
||||
redraw = true;
|
||||
// Hovering with the select or hand tool changes the screen only when the
|
||||
// layer under the pointer (or the Alt measures) changes: skip the frame
|
||||
// otherwise. egui keeps the move and gets it with the next frame.
|
||||
let idle = !self.captured
|
||||
&& [crate::editor::Tool::Select, crate::editor::Tool::Hand].contains(&self.ed.tool)
|
||||
&& !self.ed.is_busy();
|
||||
redraw = !idle || before != (self.ed.hover, self.ed.measures.len());
|
||||
} else if self.ed.hover.take().is_some() {
|
||||
redraw = true;
|
||||
}
|
||||
@@ -1389,7 +1468,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
}
|
||||
Some(t) => event_loop.set_control_flow(ControlFlow::WaitUntil(t)),
|
||||
None => event_loop.set_control_flow(ControlFlow::Wait),
|
||||
None => {
|
||||
if std::mem::take(&mut self.relief_due) {
|
||||
release_freed_memory();
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1485,17 +1569,21 @@ impl App {
|
||||
}
|
||||
for c in calls {
|
||||
// Network and Codex work runs off the editor thread; it only reads the project folder.
|
||||
if [crate::mcp::refs::TOOL, crate::mcp::imagegen::TOOL].contains(&c.name.as_str()) {
|
||||
// The helper calls back into this app through papercraft-cli, so it must not hold the UI thread.
|
||||
if [crate::mcp::refs::TOOL, crate::mcp::imagegen::TOOL, crate::mcp::delegate::TOOL].contains(&c.name.as_str()) {
|
||||
let dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
let chat = (c.client.name == crate::mcp::CHAT_CLIENT).then(|| c.client.session.clone());
|
||||
let provider = self.st.settings.chat.image_provider();
|
||||
let helper = self.st.settings.chat.helper_command.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = if c.name == crate::mcp::refs::TOOL {
|
||||
let r = if c.name == crate::mcp::delegate::TOOL {
|
||||
crate::mcp::delegate::run(&helper, chat.as_deref(), &c.args)
|
||||
} else if c.name == crate::mcp::refs::TOOL {
|
||||
crate::mcp::refs::fetch(dir.as_deref(), chat.as_deref(), &c.args)
|
||||
} else {
|
||||
crate::mcp::imagegen::generate(dir.as_deref(), chat.as_deref(), &provider, &c.args)
|
||||
};
|
||||
let _ = c.reply.send(r);
|
||||
let _ = c.reply.send(crate::mcp::steer::attach(&c.client, r));
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -1511,13 +1599,21 @@ impl App {
|
||||
if let Err(e) = &r {
|
||||
log::info!("mcp {}: {e}", c.name);
|
||||
}
|
||||
let _ = c.reply.send(r);
|
||||
let _ = c.reply.send(crate::mcp::steer::attach(&c.client, r));
|
||||
}
|
||||
self.st.project_name = self.session.project_name();
|
||||
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
|
||||
self.ed.revision += 1;
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
// An agent's edits are drawn at most every AGENT_FRAME: its calls can come faster than
|
||||
// the eye follows, and each redraw of a large page costs milliseconds.
|
||||
let due = self.last_redraw.map(|t| t + AGENT_FRAME).filter(|t| *t > Instant::now());
|
||||
match due {
|
||||
Some(t) => self.next_repaint = Some(self.next_repaint.map_or(t, |r| r.min(t))),
|
||||
None => {
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1564,6 +1660,11 @@ impl crate::mcp::exec::Host for App {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn rename_project(&mut self, name: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dir = self.session.rename_open(&mut self.ed, name)?;
|
||||
self.st.project_name = self.session.project_name();
|
||||
Ok(dir)
|
||||
}
|
||||
fn gpu(&mut self) -> Option<Headless> {
|
||||
self.offscreen().or_else(|| Headless::new().ok())
|
||||
}
|
||||
@@ -1664,3 +1765,39 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// An undecorated window has no resize border (Linux): the pointer within a few pixels of an
|
||||
/// edge shows a resize cursor and a press resizes. True when the event was consumed.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn edge_resize(window: &Window, event: &WindowEvent, edge: &mut Option<winit::window::ResizeDirection>) -> bool {
|
||||
use winit::window::{CursorIcon, ResizeDirection as D};
|
||||
const BORDER: f64 = 6.0;
|
||||
match event {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let size = window.inner_size();
|
||||
let b = BORDER * window.scale_factor();
|
||||
let (l, r) = (position.x < b, position.x > f64::from(size.width) - b);
|
||||
let (t, bo) = (position.y < b, position.y > f64::from(size.height) - b);
|
||||
let next = match (l, r, t, bo) {
|
||||
(true, _, true, _) => Some(D::NorthWest),
|
||||
(_, true, true, _) => Some(D::NorthEast),
|
||||
(true, _, _, true) => Some(D::SouthWest),
|
||||
(_, true, _, true) => Some(D::SouthEast),
|
||||
(true, ..) => Some(D::West),
|
||||
(_, true, ..) => Some(D::East),
|
||||
(_, _, true, _) => Some(D::North),
|
||||
(.., true) => Some(D::South),
|
||||
_ => None,
|
||||
};
|
||||
if next != *edge {
|
||||
window.set_cursor(next.map_or(CursorIcon::Default, CursorIcon::from));
|
||||
*edge = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||
edge.is_some_and(|d| window.drag_resize_window(d).is_ok())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn path_override_wins_and_hung_program_times_out() {
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "sleep 30")]);
|
||||
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "/bin/sleep 30")]);
|
||||
let mut prefs = ChatPrefs::default();
|
||||
prefs.agents.insert("opencode".into(), AgentPrefs { path: Some("/nonexistent/opencode".into()), ..Default::default() });
|
||||
assert_eq!(state(&detect_all_in(&prefs, &path), "opencode"), AgentState::NotFound);
|
||||
|
||||
@@ -71,6 +71,9 @@ impl SseParser {
|
||||
if !raw.is_object() {
|
||||
return out.push(Event::Error(format!("unexpected content_block at index {idx}: {raw}")));
|
||||
}
|
||||
if raw["type"] == "tool_use" {
|
||||
out.push(Event::ToolDraft { id: str_of(&raw["id"]), name: str_of(&raw["name"]) });
|
||||
}
|
||||
self.blocks.insert(idx, Block { raw, json: String::new() });
|
||||
}
|
||||
Some("content_block_delta") => self.delta(idx, &v["delta"], out),
|
||||
@@ -126,7 +129,13 @@ impl SseParser {
|
||||
out.push(Event::Thinking(s));
|
||||
}
|
||||
Some("signature_delta") => append(&mut b.raw, "signature", d["signature"].as_str().unwrap_or("")),
|
||||
Some("input_json_delta") => b.json.push_str(d["partial_json"].as_str().unwrap_or("")),
|
||||
Some("input_json_delta") => {
|
||||
let s = d["partial_json"].as_str().unwrap_or("");
|
||||
b.json.push_str(s);
|
||||
if b.raw["type"] == "tool_use" && !s.is_empty() {
|
||||
out.push(Event::ToolInput(s.into()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -841,7 +850,7 @@ mod api {
|
||||
// One Context event per request: the latest call's input and cache tokens (120, then 160).
|
||||
let contexts: Vec<u64> = ev.iter().filter_map(|e| if let Event::Context(n) = e { Some(*n) } else { None }).collect();
|
||||
assert_eq!(contexts, [120, 160], "{ev:?}");
|
||||
let ev: Vec<Event> = ev.into_iter().filter(|e| !matches!(e, Event::Context(_))).collect();
|
||||
let ev: Vec<Event> = ev.into_iter().filter(|e| !matches!(e, Event::Context(_) | Event::ToolDraft { .. } | Event::ToolInput(_))).collect();
|
||||
let kinds: Vec<&str> = ev
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
@@ -1014,8 +1023,10 @@ mod tests {
|
||||
/// Feed in awkward 7-byte chunks (fixtures are ASCII) to prove line buffering.
|
||||
fn run(src: &str) -> (SseParser, Vec<Event>) {
|
||||
let mut p = SseParser::default();
|
||||
let ev = src.as_bytes().chunks(7).flat_map(|c| p.push(std::str::from_utf8(c).unwrap())).collect();
|
||||
(p, ev)
|
||||
let ev: Vec<Event> = src.as_bytes().chunks(7).flat_map(|c| p.push(std::str::from_utf8(c).unwrap())).collect();
|
||||
// Tool calls are also announced while they stream; the rest of each test reads past that.
|
||||
assert!(!src.contains("\"tool_use\"") || ev.iter().any(|e| matches!(e, Event::ToolDraft { .. })), "{ev:?}");
|
||||
(p, ev.into_iter().filter(|e| !matches!(e, Event::ToolDraft { .. } | Event::ToolInput(_))).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,15 @@ fn push_text(out: &mut Vec<Event>, s: Option<&str>, thinking: bool) {
|
||||
|
||||
fn delta(e: &Value) -> Vec<Event> {
|
||||
let mut out = vec![];
|
||||
// A tool call starts streaming long before its `assistant` line: say so at once.
|
||||
if e["type"] == "content_block_start" && e["content_block"]["type"] == "tool_use" {
|
||||
let name = str_of(&e["content_block"]["name"]);
|
||||
out.push(Event::ToolDraft {
|
||||
id: str_of(&e["content_block"]["id"]),
|
||||
name: name.strip_prefix(MCP_PREFIX).map_or(name.clone(), str::to_string),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if e["type"] != "content_block_delta" {
|
||||
return out;
|
||||
}
|
||||
@@ -91,6 +100,11 @@ fn delta(e: &Value) -> Vec<Event> {
|
||||
match d["type"].as_str() {
|
||||
Some("text_delta") => push_text(&mut out, d["text"].as_str(), false),
|
||||
Some("thinking_delta") => push_text(&mut out, d["thinking"].as_str(), true),
|
||||
Some("input_json_delta") => {
|
||||
if let Some(j) = d["partial_json"].as_str().filter(|j| !j.is_empty()) {
|
||||
out.push(Event::ToolInput(j.into()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
@@ -179,7 +193,11 @@ mod process {
|
||||
/// Research runs in a Haiku sub-agent: its searches and fetches stay out of the main
|
||||
/// context, which gets only the kept references (a research step cost $0.03 instead of the
|
||||
/// $1.71 it cost in a long main session).
|
||||
const RESEARCHER: &str = r#"{"researcher": {"description": "Finds interface references on the web and brings the useful ones in. Use it for all reference research.", "prompt": "You research UI references for a designer. Search the web, then call mcp__papercraft__fetch_reference_images once with the best image or page URLs (nicelydone.club/apps/<app> for SaaS web apps, banani.co/references/apps and screenlane.com for mobile, App Store and Google Play listings; Dribbble, Behance and Figma Community block fetching). Look at what came back and keep only images that show an interface like the one asked for: no logos, icons, social cards or illustrations. If fewer than two qualify, search once more. Reply with only the kept images, one line each: the path, then what to take from it (layout, density, color, type).", "tools": ["WebSearch", "WebFetch", "mcp__papercraft__fetch_reference_images"], "model": "haiku"}}"#;
|
||||
const RESEARCHER: &str = r#"{"description": "Finds interface references on the web and brings the useful ones in. Use it for all reference research.", "prompt": "You research UI references for a designer. Search the web, then call mcp__papercraft__fetch_reference_images once with the best image or page URLs (nicelydone.club/apps/<app> for SaaS web apps, banani.co/references/apps and screenlane.com for mobile, App Store and Google Play listings; Dribbble, Behance and Figma Community block fetching). Look at what came back and keep only images that show an interface like the one asked for: no logos, icons, social cards or illustrations. If fewer than two qualify, search once more. Reply with only the kept images, one line each: the path, then what to take from it (layout, density, color, type).", "tools": ["WebSearch", "WebFetch", "mcp__papercraft__fetch_reference_images"], "model": "haiku"}"#;
|
||||
|
||||
/// Review runs in a Sonnet sub-agent: its screenshots and lint output stay out of the main
|
||||
/// context (every image there is re-read on each later step), which gets only the findings.
|
||||
const QA: &str = r#"{"description": "Reviews built screens: lints and screenshots them and reports only what is wrong. Use it for the end-of-turn check instead of taking screenshots yourself.", "prompt": "You are the QA reviewer for a design made in PaperCraft. You get the frames to check and the brief (size, density, palette, library). Call mcp__papercraft__lint_design without fix, then one mcp__papercraft__get_screenshot per frame, and get_tree_summary or get_computed_styles only to confirm a suspicion. Check: overflow and clipped text, misalignment and uneven spacing, off-palette or literal colors, contrast, target sizes for the density, empty or placeholder content, repeated look-alikes that should be instances. Do not edit anything. Reply with at most 12 findings, most severe first, one line each: frame or node id, the problem, the fix. Reply 'No findings' when it is clean.", "tools": ["mcp__papercraft__lint_design", "mcp__papercraft__get_screenshot", "mcp__papercraft__get_tree_summary", "mcp__papercraft__find_nodes", "mcp__papercraft__get_node_info", "mcp__papercraft__get_computed_styles"], "model": "sonnet"}"#;
|
||||
|
||||
/// Measured in specs/chat-tokens: 42.7k prompt tokens down to 20.6k. Bash, Edit, Write and
|
||||
/// the web tools need a permission `-p` never grants, so they only cost tokens.
|
||||
@@ -191,21 +209,23 @@ mod process {
|
||||
let mut a: Vec<String> = [
|
||||
"-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
|
||||
"--include-partial-messages", "--mcp-config", &mcp.to_string(), "--strict-mcp-config",
|
||||
"--allowedTools", "mcp__papercraft__*",
|
||||
"--allowedTools",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
if cfg.research {
|
||||
let i = a.iter().position(|x| x == "--allowedTools").unwrap() + 1;
|
||||
a[i].push_str(",WebSearch,WebFetch,Agent");
|
||||
a.extend(["--agents".into(), RESEARCHER.to_string()]);
|
||||
}
|
||||
let mut agents = serde_json::json!({"qa": serde_json::from_str::<Value>(QA).unwrap()});
|
||||
let extra = if cfg.research {
|
||||
agents["researcher"] = serde_json::from_str(RESEARCHER).unwrap();
|
||||
",WebSearch,WebFetch,Agent"
|
||||
} else {
|
||||
",Agent"
|
||||
};
|
||||
a.push(format!("mcp__papercraft__*{extra}"));
|
||||
a.extend(["--agents".into(), agents.to_string()]);
|
||||
if cfg.lean {
|
||||
// Without ToolSearch the CLI loads every PaperCraft tool schema up front.
|
||||
a.extend(LEAN_ARGS.map(String::from));
|
||||
if cfg.research {
|
||||
a.last_mut().unwrap().push_str(",WebSearch,WebFetch,Agent");
|
||||
}
|
||||
a.last_mut().unwrap().push_str(extra);
|
||||
}
|
||||
if let Some(f) = system_file {
|
||||
a.extend(["--append-system-prompt-file".into(), f.display().to_string()]);
|
||||
@@ -394,6 +414,14 @@ mod process {
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn a_tool_call_is_announced_as_it_starts_streaming() {
|
||||
let start = r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t9","name":"mcp__papercraft__write_html","input":{}}}}"#;
|
||||
assert_eq!(parse_line_partial(start), vec![Event::ToolDraft { id: "t9".into(), name: "write_html".into() }]);
|
||||
let piece = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"targetNodeId\": \"8f98b5:1\""}}}"#;
|
||||
assert_eq!(parse_line_partial(piece), vec![Event::ToolInput("{\"targetNodeId\": \"8f98b5:1\"".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sub_agent_shows_its_tool_calls_not_its_words() {
|
||||
let text = r#"{"type":"stream_event","parent_tool_use_id":"t1","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"thinking aloud"}}}"#;
|
||||
assert!(parse_line_partial(text).is_empty());
|
||||
@@ -414,7 +442,10 @@ mod process {
|
||||
let a = args(&cfg, Some(Path::new("/tmp/sys.txt")));
|
||||
let at = |f: &str| a[a.iter().position(|x| x == f).unwrap() + 1].as_str();
|
||||
assert_eq!(at("--output-format"), "stream-json");
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*");
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*,Agent");
|
||||
let agents: Value = serde_json::from_str(at("--agents")).unwrap();
|
||||
assert_eq!(agents["qa"]["model"], "sonnet");
|
||||
assert!(agents.get("researcher").is_none(), "research is off");
|
||||
assert_eq!(at("--append-system-prompt-file"), "/tmp/sys.txt");
|
||||
assert!(!a.iter().any(|x| x == "SYS" || x == "--append-system-prompt"), "the prompt text is not in argv");
|
||||
assert_eq!(at("--model"), "m");
|
||||
@@ -426,7 +457,7 @@ mod process {
|
||||
assert!(!a.contains(&"--tools".to_string()), "not lean: the user's own setup");
|
||||
let lean = args(&TurnConfig { lean: true, effort: Some("high".into()), ..cfg }, None);
|
||||
let at = |f: &str| lean[lean.iter().position(|x| x == f).unwrap() + 1].as_str();
|
||||
assert_eq!(at("--tools"), "Read,TodoWrite,ToolSearch", "ToolSearch keeps the PaperCraft schemas deferred");
|
||||
assert_eq!(at("--tools"), "Read,TodoWrite,ToolSearch,Agent", "ToolSearch keeps the PaperCraft schemas deferred");
|
||||
assert_eq!(at("--setting-sources"), "project,local");
|
||||
assert_eq!(at("--effort"), "high");
|
||||
assert!(lean.contains(&"--disable-slash-commands".to_string()));
|
||||
@@ -436,6 +467,7 @@ mod process {
|
||||
assert_eq!(at("--allowedTools"), "mcp__papercraft__*,WebSearch,WebFetch,Agent", "allowed, so -p does not deny them");
|
||||
let agents: Value = serde_json::from_str(at("--agents")).unwrap();
|
||||
assert_eq!(agents["researcher"]["model"], "haiku");
|
||||
assert_eq!(agents["qa"]["model"], "sonnet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -46,15 +46,16 @@ fn components(ed: &Editor) -> Vec<Comp> {
|
||||
for page in 0..ed.pages.len() {
|
||||
for n in ed.page_doc(page).nodes.values() {
|
||||
let Some(l) = &n.instance else { continue };
|
||||
match &l.source {
|
||||
None if n.id != l.main => *page_uses.entry((page, l.main)).or_default() += 1,
|
||||
let (main, source) = l.target();
|
||||
match source {
|
||||
None if n.id != main => *page_uses.entry((page, main)).or_default() += 1,
|
||||
None => {}
|
||||
Some(Source::Page(p)) => {
|
||||
if let Some(i) = ed.pages.iter().position(|s| s.id == *p) {
|
||||
*page_uses.entry((i, l.main)).or_default() += 1;
|
||||
*page_uses.entry((i, main)).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Some(Source::Library(lib)) => *lib_uses.entry((lib.as_str(), l.main)).or_default() += 1,
|
||||
Some(Source::Library(lib)) => *lib_uses.entry((lib.as_str(), main)).or_default() += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ pub enum Event {
|
||||
Text(String),
|
||||
Thinking(String),
|
||||
ToolStart { id: String, name: String, args: Value },
|
||||
/// The model began writing a tool call: its input streams in as [`Event::ToolInput`], and
|
||||
/// the canvas shows a skeleton where it will land until the call ends.
|
||||
ToolDraft { id: String, name: String },
|
||||
/// A piece of the input of the call being drafted (raw JSON text).
|
||||
ToolInput(String),
|
||||
/// `result` on success, `error` on failure; exactly one is set.
|
||||
ToolEnd { id: String, result: Option<Value>, error: Option<String> },
|
||||
TurnEnd { usage: Option<Usage>, session: Option<String>, stop_reason: Option<String> },
|
||||
|
||||
@@ -186,4 +186,16 @@ pub struct Chat {
|
||||
pub backend: Backend,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<Message>,
|
||||
/// What became of the issues drafted in this chat, by the drafting tool call's id.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub issues: std::collections::BTreeMap<String, IssueOutcome>,
|
||||
}
|
||||
|
||||
/// A drafted issue the user sent or dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "lowercase")]
|
||||
pub enum IssueOutcome {
|
||||
/// Filed as #number; `existing` when an issue with a matching title was there already.
|
||||
Filed { number: u64, url: String, existing: bool },
|
||||
Discarded,
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ mod tests {
|
||||
}),
|
||||
at: 5,
|
||||
}],
|
||||
issues: [("t1".to_owned(), crate::chat::IssueOutcome::Filed { number: 7, url: "u".into(), existing: false })].into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ impl Editor {
|
||||
if geometry {
|
||||
self.fixup();
|
||||
}
|
||||
match self.rec.take() {
|
||||
match self.take_rec() {
|
||||
Some((rec, key)) if keep => {
|
||||
if self.log_touched(&rec) && self.history.commit(&self.doc, rec, key) {
|
||||
self.revision += 1;
|
||||
@@ -178,6 +178,24 @@ impl Editor {
|
||||
n
|
||||
}
|
||||
|
||||
/// A copy of page `page` with its variables resolved in mode `mode` (by name), for a preview
|
||||
/// that leaves the document, its history and the canvas as they are.
|
||||
// ponytail: colours and bindings only; a mode that changes bound sizes previews them without
|
||||
// re-running layout (fit_text/fixup need the editor).
|
||||
pub fn page_doc_in_mode(&mut self, page: usize, mode: &str) -> Result<crate::model::Document, String> {
|
||||
let (cid, mid) = find_mode(&self.design, mode).ok_or(format!("no mode `{mode}`"))?;
|
||||
self.ensure_loaded(page);
|
||||
let mut modes = self.pages[page].modes.clone();
|
||||
modes.insert(cid, mid);
|
||||
let cache = self.design.cache_for(&modes);
|
||||
let mut doc = self.page_doc(page).clone();
|
||||
let mut rec = crate::history::Recording::new("Preview mode", &doc);
|
||||
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
|
||||
tx.set_cache(&cache, &BTreeMap::new());
|
||||
apply_bindings(&mut tx, &cache, &BTreeMap::new());
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Switch the active page to `mode_id` of `collection_id`: that page's modes, cache and bound
|
||||
/// fills only, one undo step. Returns the fills that changed.
|
||||
pub fn set_page_mode(&mut self, collection_id: &str, mode_id: &str) -> Result<usize, Error> {
|
||||
|
||||
@@ -67,13 +67,32 @@ pub fn node_contains(doc: &Document, id: NodeId, p: V2, zoom: f64) -> bool {
|
||||
}
|
||||
|
||||
/// Topmost visible, unlocked node under `p`, deepest first.
|
||||
///
|
||||
/// Reverse paint order (children before their parent, later siblings first), descending only
|
||||
/// into subtrees that can hold the hit: hidden and locked subtrees are skipped whole, and so
|
||||
/// are clipping frames that do not contain `p`.
|
||||
pub fn hit_test(doc: &Document, p: V2, zoom: f64) -> Option<NodeId> {
|
||||
doc.paint_order().into_iter().rev().find(|id| {
|
||||
doc.is_effectively_visible(*id)
|
||||
&& !doc.is_effectively_locked(*id)
|
||||
&& node_contains(doc, *id, p, zoom)
|
||||
&& inside_ancestor_clips(doc, *id, p)
|
||||
})
|
||||
fn walk(doc: &Document, ids: &[NodeId], p: V2, zoom: f64) -> Option<NodeId> {
|
||||
for &id in ids.iter().rev() {
|
||||
let Some(n) = doc.get(id) else { continue };
|
||||
if !n.visible || n.locked {
|
||||
continue;
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Frame { clip: true })
|
||||
&& !doc.world_rect(id).is_some_and(|r| r.contains(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(hit) = walk(doc, &n.children, p, zoom) {
|
||||
return Some(hit);
|
||||
}
|
||||
if node_contains(doc, id, p, zoom) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(doc, &doc.root, p, zoom)
|
||||
}
|
||||
|
||||
/// Screen-space rectangle of a top-level frame's name label.
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod design;
|
||||
pub mod hit;
|
||||
pub mod input;
|
||||
pub mod pages;
|
||||
pub mod screens;
|
||||
pub mod shortcuts;
|
||||
pub mod snap;
|
||||
pub mod structure;
|
||||
@@ -24,6 +25,7 @@ pub use input::{Button, InputEvent, Key, Modifiers};
|
||||
pub use pages::{PageSlot, PageStash};
|
||||
use snap::Guide;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -252,6 +254,11 @@ pub struct Editor {
|
||||
/// Process-unique stamp of the document content: changes whenever the document may
|
||||
/// have changed (edits, undo, page switch, load). Renderers reuse work while it holds.
|
||||
pub doc_stamp: u64,
|
||||
/// What changed in `doc` at each recent `doc_stamp`, oldest first, so renderers redo only
|
||||
/// those layers (`changes_since`).
|
||||
change_log: std::collections::VecDeque<(u64, Changed)>,
|
||||
/// Stamps before this one are no longer covered by `change_log`.
|
||||
change_base: u64,
|
||||
pub project_stamp: u64,
|
||||
/// Fit the camera to the content once the real viewport size is known.
|
||||
pub pending_fit: bool,
|
||||
@@ -355,6 +362,8 @@ impl Editor {
|
||||
last_click: None,
|
||||
revision: 0,
|
||||
doc_stamp: next_stamp(),
|
||||
change_log: Default::default(),
|
||||
change_base: 0,
|
||||
project_stamp: next_stamp(),
|
||||
pending_fit: false,
|
||||
headless: false,
|
||||
@@ -407,7 +416,7 @@ impl Editor {
|
||||
self.rec = Some((Recording::new("Edit", &self.doc), None));
|
||||
}
|
||||
self.revision += 1;
|
||||
self.doc_stamp = next_stamp();
|
||||
self.mark(Changed::Open);
|
||||
let (rec, _) = self.rec.as_mut().unwrap();
|
||||
Tx::new(&mut self.doc, rec)
|
||||
}
|
||||
@@ -415,7 +424,7 @@ impl Editor {
|
||||
pub fn commit(&mut self) {
|
||||
self.fixup();
|
||||
let mains = std::mem::take(&mut self.touched_mains);
|
||||
if let Some((rec, key)) = self.rec.take()
|
||||
if let Some((rec, key)) = self.take_rec()
|
||||
&& self.log_touched(&rec)
|
||||
&& self.history.commit(&self.doc, rec, key)
|
||||
{
|
||||
@@ -457,7 +466,7 @@ impl Editor {
|
||||
pub fn edit_variables<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
|
||||
self.begin(label);
|
||||
let result = f(&mut self.tx());
|
||||
if let Some((rec, key)) = self.rec.take()
|
||||
if let Some((rec, key)) = self.take_rec()
|
||||
&& self.log_touched(&rec)
|
||||
&& self.history.commit(&self.doc, rec, key)
|
||||
{
|
||||
@@ -482,27 +491,31 @@ impl Editor {
|
||||
self.cancel_gesture();
|
||||
self.end_text_edit();
|
||||
self.commit();
|
||||
if let Some(u) = self.history.undo(&mut self.doc) {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.undo(&mut self.doc) {
|
||||
self.status = format!("Undo {}", u.label);
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) {
|
||||
self.cancel_gesture();
|
||||
self.end_text_edit();
|
||||
self.commit();
|
||||
if let Some(u) = self.history.redo(&mut self.doc) {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.redo(&mut self.doc) {
|
||||
self.status = format!("Redo {}", u.label);
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
|
||||
fn after_history(&mut self) {
|
||||
fn after_history(&mut self, changed: Changed) {
|
||||
self.revision += 1;
|
||||
self.doc_stamp = next_stamp();
|
||||
self.mark(changed);
|
||||
self.selection.retain(|id| self.doc.nodes.contains_key(id));
|
||||
}
|
||||
|
||||
@@ -560,6 +573,58 @@ impl Editor {
|
||||
|
||||
// ------------------------------------------------------------------ queries
|
||||
|
||||
/// Make frames scrolling screens (fixed height, clipped, overflow scrolls) or plain again.
|
||||
/// A screen that grew to fit its content (fit-content) goes back to its min height.
|
||||
pub fn set_scroll(&mut self, ids: &[NodeId], on: bool) {
|
||||
self.edit(if on { "Scroll screen" } else { "Stop scrolling" }, |tx| {
|
||||
for id in ids {
|
||||
let Some(n) = tx.node_mut(*id).filter(|n| n.is_frame()) else { continue };
|
||||
n.scroll = on;
|
||||
n.scroll_y = 0.0;
|
||||
if on {
|
||||
n.kind = NodeKind::Frame { clip: true };
|
||||
if n.layout_child.sizing[1] != crate::model::Sizing::Fixed {
|
||||
n.layout_child.sizing[1] = crate::model::Sizing::Fixed;
|
||||
if let Some(h) = n.layout_child.min_size[1] {
|
||||
n.rect.h = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The page's screens: the declared ones, or else its top-level frames.
|
||||
pub fn screens(&self) -> Vec<NodeId> {
|
||||
let declared = screens::declared(&self.doc);
|
||||
if !declared.is_empty() {
|
||||
return declared;
|
||||
}
|
||||
self.doc.root.iter().copied().filter(|id| self.doc.get(*id).is_some_and(|n| n.is_frame())).collect()
|
||||
}
|
||||
|
||||
/// Scroll the innermost scrollable frame under `pos` (screen) by `dy` world units, when its
|
||||
/// content overflows and it can still move that way. False: the canvas pans instead.
|
||||
pub fn scroll_frame(&mut self, pos: V2, dy: f64) -> bool {
|
||||
let world = self.screen_to_world(pos);
|
||||
let hit = self
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.scroll && n.visible && dy != 0.0)
|
||||
.filter(|n| self.doc.world_rect(n.id).is_some_and(|r| r.contains(world)))
|
||||
.filter_map(|n| {
|
||||
let max = scroll_range(&self.doc, n.id);
|
||||
let next = (n.scroll_y + dy).clamp(0.0, max);
|
||||
((next - n.scroll_y).abs() > 1e-9).then(|| (self.doc.ancestors(n.id).len(), n.id, next))
|
||||
})
|
||||
.max_by_key(|(depth, ..)| *depth);
|
||||
let Some((_, id, next)) = hit else { return false };
|
||||
self.doc.nodes.get_mut(&id).unwrap().scroll_y = next;
|
||||
self.mark(Changed::Nodes(Arc::new([id])));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn screen_to_world(&self, p: V2) -> V2 {
|
||||
self.camera.screen_to_world(p)
|
||||
}
|
||||
@@ -661,21 +726,65 @@ impl Editor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Screen-space frame label rectangles for top-level frames (also used by the renderer).
|
||||
/// Screen-space frame label rectangles for top-level frames and declared screens (also
|
||||
/// used by the renderer).
|
||||
/// A frame too narrow on screen for a readable name gets none, and a label that would
|
||||
/// overlap one already placed is dropped (selected frames first, then larger ones), so a
|
||||
/// zoomed-out page shows a few clean names instead of a pile.
|
||||
pub fn frame_labels(&self) -> Vec<(NodeId, Rect)> {
|
||||
self.doc
|
||||
let screen = Rect::new(0.0, 0.0, self.viewport.x, self.viewport.y);
|
||||
// Top-level frames, then declared screens nested in sections.
|
||||
let nested = screens::declared(&self.doc).into_iter().filter(|id| self.doc.get(*id).is_some_and(|n| n.parent.is_some()));
|
||||
let bodies: Vec<(usize, NodeId, Rect)> = self
|
||||
.doc
|
||||
.root
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let n = self.doc.get(*id)?;
|
||||
if !n.is_frame() || !n.visible {
|
||||
.copied()
|
||||
.chain(nested)
|
||||
.enumerate()
|
||||
.filter_map(|(i, id)| {
|
||||
let n = self.doc.get(id)?;
|
||||
if !n.is_frame() || !n.visible || (n.parent.is_some() && !self.doc.ancestors(id).iter().all(|a| self.doc.get(*a).is_some_and(|a| a.visible))) {
|
||||
return None;
|
||||
}
|
||||
let sr = self.camera.world_rect_to_screen(&self.doc.world_rect(*id)?);
|
||||
let w = text::layout(&n.name, 12.0, None, TextAlign::Left).width;
|
||||
Some((*id, hit::frame_label_rect(&sr, w.min(sr.w.max(24.0)))))
|
||||
Some((i, id, self.camera.world_rect_to_screen(&self.doc.world_rect(id)?)))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let mut candidates: Vec<(usize, NodeId, Rect)> = bodies
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|(i, id, sr)| {
|
||||
let id = &id;
|
||||
let selected = self.selection.contains(id);
|
||||
if sr.w < MIN_LABELED_FRAME_PX && !selected {
|
||||
return None;
|
||||
}
|
||||
// Off-screen labels are neither drawn nor clickable: skip their text layout.
|
||||
if !hit::frame_label_rect(&sr, sr.w).intersects(&screen) {
|
||||
return None;
|
||||
}
|
||||
Some((i, *id, sr))
|
||||
})
|
||||
.collect();
|
||||
let selected = |id: &NodeId| self.selection.contains(id);
|
||||
candidates.sort_by(|a, b| {
|
||||
let key = |(_, id, r): &(usize, NodeId, Rect)| (!self.selection.contains(id), -(r.w * r.h));
|
||||
key(a).partial_cmp(&key(b)).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
// ponytail: pairwise overlap check, fine for the few hundred frames a page shows.
|
||||
let mut placed: Vec<(usize, NodeId, Rect)> = Vec::new();
|
||||
for (i, id, sr) in candidates {
|
||||
let w = text::layout(&self.doc.get(id).unwrap().name, 12.0, None, TextAlign::Left).width;
|
||||
let r = hit::frame_label_rect(&sr, w.min(sr.w));
|
||||
// A label over another frame's content (or another label) would hide both.
|
||||
let covers_frame = !selected(&id)
|
||||
&& bodies.iter().any(|(_, b, br)| *b != id && !self.doc.is_ancestor(*b, id) && br.intersects(&r));
|
||||
if !covers_frame && !placed.iter().any(|(_, _, p)| p.intersects(&r)) {
|
||||
placed.push((i, id, r));
|
||||
}
|
||||
}
|
||||
placed.sort_by_key(|(i, ..)| *i);
|
||||
placed.into_iter().map(|(_, id, r)| (id, r)).collect()
|
||||
}
|
||||
|
||||
fn label_at(&self, p: V2) -> Option<NodeId> {
|
||||
@@ -731,7 +840,7 @@ impl Editor {
|
||||
if self.mods.ctrl || self.mods.logo {
|
||||
let factor = (-delta.y * ZOOM_PER_SCROLL_PX).exp();
|
||||
self.camera.zoom_about(pos, self.camera.zoom * factor);
|
||||
} else {
|
||||
} else if !self.scroll_frame(pos, delta.y / self.camera.zoom) {
|
||||
self.camera.pan_by_screen(-delta);
|
||||
}
|
||||
self.revision += 1;
|
||||
@@ -2112,12 +2221,15 @@ impl Editor {
|
||||
| Gesture::ConnectorMid { .. }
|
||||
| Gesture::Erase => {
|
||||
// Roll back the open transaction.
|
||||
if let Some((rec, key)) = self.rec.take() {
|
||||
if let Some((rec, key)) = self.take_rec() {
|
||||
let mut h = History::default();
|
||||
if h.commit(&self.doc, rec, key) {
|
||||
h.undo(&mut self.doc);
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if h.commit(&self.doc, rec, key)
|
||||
&& let Some(u) = h.undo(&mut self.doc)
|
||||
{
|
||||
changed = Changed::Nodes(u.nodes.into());
|
||||
}
|
||||
self.after_history();
|
||||
self.after_history(changed);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -2513,6 +2625,67 @@ pub fn insert_subtrees_tx(
|
||||
}
|
||||
|
||||
/// A fresh process-wide document stamp.
|
||||
/// What a `doc_stamp` change did to the document.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Changed {
|
||||
/// Whatever the open transaction touches (resolved when it closes).
|
||||
Open,
|
||||
Nodes(Arc<[NodeId]>),
|
||||
All,
|
||||
}
|
||||
|
||||
/// Frames narrower than this on screen (px) show no name label.
|
||||
const MIN_LABELED_FRAME_PX: f64 = 48.0;
|
||||
|
||||
/// Changes kept for `Editor::changes_since`; a renderer further behind redoes everything.
|
||||
const CHANGE_LOG_LEN: usize = 64;
|
||||
|
||||
impl Editor {
|
||||
/// Bumps `doc_stamp` and logs what changed with it.
|
||||
pub(crate) fn mark(&mut self, changed: Changed) {
|
||||
self.doc_stamp = next_stamp();
|
||||
if let (Changed::Open, Some((s, Changed::Open))) = (&changed, self.change_log.back_mut()) {
|
||||
*s = self.doc_stamp;
|
||||
return;
|
||||
}
|
||||
if self.change_log.len() == CHANGE_LOG_LEN
|
||||
&& let Some((s, _)) = self.change_log.pop_front()
|
||||
{
|
||||
self.change_base = s;
|
||||
}
|
||||
self.change_log.push_back((self.doc_stamp, changed));
|
||||
}
|
||||
|
||||
/// Closes the open transaction, logging the layers it touched.
|
||||
pub(crate) fn take_rec(&mut self) -> Option<(Recording, Option<String>)> {
|
||||
let rec = self.rec.take();
|
||||
let touched = rec.as_ref().map(|(r, _)| Changed::Nodes(r.touched().into()));
|
||||
for (_, c) in &mut self.change_log {
|
||||
if matches!(c, Changed::Open) {
|
||||
*c = touched.clone().unwrap_or(Changed::All);
|
||||
}
|
||||
}
|
||||
rec
|
||||
}
|
||||
|
||||
/// The layers whose content changed after `doc_stamp` was `stamp`, or None when that is
|
||||
/// unknown (too long ago, another editor, a page switch): then everything may have.
|
||||
pub fn changes_since(&self, stamp: u64) -> Option<std::collections::HashSet<NodeId>> {
|
||||
if stamp < self.change_base || stamp > self.doc_stamp {
|
||||
return None;
|
||||
}
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (_, c) in self.change_log.iter().rev().take_while(|(s, _)| *s > stamp) {
|
||||
match c {
|
||||
Changed::Open => out.extend(self.rec.as_ref()?.0.touched()),
|
||||
Changed::Nodes(ids) => out.extend(ids.iter()),
|
||||
Changed::All => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_stamp() -> u64 {
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
@@ -2537,3 +2710,12 @@ fn dedup(mut v: Vec<NodeId>) -> Vec<NodeId> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// How far a scrollable frame's content reaches past its bottom edge (0 when it fits).
|
||||
pub fn scroll_range(doc: &crate::model::Document, id: NodeId) -> f64 {
|
||||
let Some(n) = doc.get(id) else { return 0.0 };
|
||||
let pad = n.layout.map_or(0.0, |l| l.padding[2]);
|
||||
// Pinned (absolute) children do not scroll, so they do not add to the range.
|
||||
let bottom = n.children.iter().filter_map(|c| doc.get(*c)).filter(|c| c.visible && !c.layout_child.absolute).map(|c| c.rect.bottom()).fold(0.0, f64::max);
|
||||
(bottom + pad - n.rect.h).max(0.0)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ impl Editor {
|
||||
}
|
||||
self.meta_dirty = true; // the active page is remembered in the manifest
|
||||
self.revision += 1;
|
||||
self.doc_stamp = super::next_stamp();
|
||||
self.mark(super::Changed::All);
|
||||
}
|
||||
|
||||
fn unique_page_name(&self, base: &str) -> String {
|
||||
@@ -296,7 +296,7 @@ impl Editor {
|
||||
self.pages[i].name = name.to_string();
|
||||
if i == self.active_page {
|
||||
self.doc.name = name.to_string();
|
||||
self.doc_stamp = super::next_stamp();
|
||||
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
|
||||
} else if let Some(s) = &mut self.pages[i].stash {
|
||||
s.doc.name = name.to_string();
|
||||
}
|
||||
@@ -477,7 +477,8 @@ impl Editor {
|
||||
self.saved_marker = saved_marker;
|
||||
self.pages[i].stash = Some(s);
|
||||
self.revision += 1;
|
||||
self.doc_stamp = super::next_stamp();
|
||||
// The active page's document is the one it was before `f`.
|
||||
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
|
||||
self.flush_cross_pages();
|
||||
r
|
||||
}
|
||||
@@ -506,9 +507,10 @@ impl Editor {
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
// A swapped nested instance follows the component it was swapped to.
|
||||
n.instance.as_ref().is_some_and(|l| {
|
||||
matches!(&l.source, Some(Source::Page(p)) if *p == src_id)
|
||||
&& mains.contains(&l.main)
|
||||
let (main, source) = l.swap.as_ref().map_or((l.main, &l.source), |s| (s.main, &s.source));
|
||||
matches!(source, Some(Source::Page(p)) if *p == src_id) && mains.contains(&main)
|
||||
})
|
||||
})
|
||||
.map(|n| n.id)
|
||||
@@ -601,7 +603,7 @@ impl Editor {
|
||||
id: self.pages[i].id.clone(),
|
||||
name: self.pages[i].name.clone(),
|
||||
};
|
||||
let path = dir.join(crate::project::PAGES_DIR).join(format!("{}.json", r.id));
|
||||
let path = crate::project::page_file(&dir, &r.id);
|
||||
let read = crate::project::read_page_file(&path, &r);
|
||||
self.install_page(&r.id, read);
|
||||
}
|
||||
@@ -623,7 +625,8 @@ impl Editor {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let changed = crate::project::finalize_page(&self.design, &mut p, &mut modes);
|
||||
let legacy = self.loader.as_ref().is_some_and(|l| crate::project::is_legacy_page(&l.dir, id));
|
||||
let changed = crate::project::finalize_page(&self.design, &mut p, &mut modes) || legacy;
|
||||
let slot = &mut self.pages[i];
|
||||
let Some(stash) = slot.stash.as_mut() else { return };
|
||||
// The manifest owns the name, and it may have been renamed meanwhile.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Screens: frames declared as one device viewport of the product (not a section, slot or
|
||||
//! component), and what inside one is hidden behind a drawer or modal ([`coverage`]), so
|
||||
//! agents don't edit what nobody can see. Spec: `specs/screens/spec.md`.
|
||||
|
||||
use super::Editor;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Document, Node, NodeId, NodeKind, Paint};
|
||||
|
||||
/// Device sizes a screen is usually declared at.
|
||||
pub const DEVICES: [(&str, f64, f64); 4] = [
|
||||
("Mobile", 390.0, 844.0),
|
||||
("Tablet", 768.0, 1024.0),
|
||||
("Desktop", 1440.0, 900.0),
|
||||
("Large desktop", 2560.0, 1440.0),
|
||||
];
|
||||
|
||||
/// A layer with less than this share of its area in view counts as hidden.
|
||||
pub const HIDDEN_BELOW: f64 = 0.01;
|
||||
/// A layer with at least this share in view counts as fully visible.
|
||||
const VISIBLE_FROM: f64 = 0.99;
|
||||
/// Fill alpha and opacity from which a layer hides what is under it.
|
||||
const OPAQUE: f64 = 0.95;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Screen,
|
||||
Overlay,
|
||||
None,
|
||||
}
|
||||
|
||||
/// The device whose width matches `w` (heights vary: screens scroll).
|
||||
pub fn device_of(w: f64) -> Option<&'static str> {
|
||||
DEVICES.iter().find(|d| (d.1 - w).abs() < 0.5).map(|d| d.0)
|
||||
}
|
||||
|
||||
/// Declared screens of the page, in paint order.
|
||||
pub fn declared(doc: &Document) -> Vec<NodeId> {
|
||||
doc.paint_order().into_iter().filter(|id| doc.get(*id).is_some_and(|n| n.screen)).collect()
|
||||
}
|
||||
|
||||
/// The declared screen `id` is inside (not `id` itself).
|
||||
pub fn screen_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
doc.ancestors(id).into_iter().find(|a| doc.get(*a).is_some_and(|n| n.screen))
|
||||
}
|
||||
|
||||
/// Why `id` can't take `role`, if it can't.
|
||||
pub fn refusal(doc: &Document, id: NodeId, role: Role) -> Option<&'static str> {
|
||||
let n = doc.get(id)?;
|
||||
let in_component = || n.component || n.instance.is_some() || doc.ancestors(id).iter().any(|a| doc.get(*a).is_some_and(|p| p.component));
|
||||
match role {
|
||||
Role::None => None,
|
||||
Role::Screen if !n.is_frame() => Some("only a frame can be a screen"),
|
||||
Role::Screen if in_component() => Some("a component main, an instance or a layer inside a main can't be a screen"),
|
||||
Role::Screen if screen_of(doc, id).is_some() => Some("it is already inside a screen"),
|
||||
Role::Screen if doc.subtree(id).iter().skip(1).any(|d| doc.get(*d).is_some_and(|c| c.screen)) => {
|
||||
Some("it holds screens (a section): declare the screens inside it")
|
||||
}
|
||||
Role::Screen => None,
|
||||
Role::Overlay if screen_of(doc, id).is_none() => Some("an overlay (drawer, modal, sheet) must be inside a screen"),
|
||||
Role::Overlay => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames that look like screens: named `Screen / ...`, the frame shown in a `Slot / ...`
|
||||
/// frame, or a top-level frame at exactly a device size (a tall section at a device width
|
||||
/// is not one). The outermost of nested candidates wins,
|
||||
/// and frames that can't be screens are left out.
|
||||
pub fn candidates(doc: &Document) -> Vec<NodeId> {
|
||||
let looks = |n: &Node| {
|
||||
n.is_frame()
|
||||
&& !n.screen
|
||||
&& (n.name.starts_with("Screen /")
|
||||
|| n.parent.and_then(|p| doc.get(p)).is_some_and(|p| p.name.starts_with("Slot /"))
|
||||
|| (n.parent.is_none() && DEVICES.iter().any(|d| (d.1 - n.rect.w).abs() < 0.5 && (d.2 - n.rect.h).abs() < 0.5)))
|
||||
};
|
||||
let found: Vec<NodeId> = doc
|
||||
.paint_order()
|
||||
.into_iter()
|
||||
.filter(|id| doc.get(*id).is_some_and(looks) && refusal(doc, *id, Role::Screen).is_none())
|
||||
.collect();
|
||||
found.iter().copied().filter(|id| !doc.ancestors(*id).iter().any(|a| found.contains(a))).collect()
|
||||
}
|
||||
|
||||
/// How much of a layer inside a screen is in view, and what covers the rest.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Coverage {
|
||||
pub screen: NodeId,
|
||||
/// Share of the layer's area not covered, 0..=1.
|
||||
pub visible: f64,
|
||||
/// Layers painted over it inside the screen: overlays and opaque layers.
|
||||
pub covers: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.visible < HIDDEN_BELOW
|
||||
}
|
||||
pub fn is_partial(&self) -> bool {
|
||||
!self.is_hidden() && self.visible < VISIBLE_FROM
|
||||
}
|
||||
pub fn state(&self) -> &'static str {
|
||||
if self.is_hidden() {
|
||||
"hidden"
|
||||
} else if self.is_partial() {
|
||||
"partly covered"
|
||||
} else {
|
||||
"visible"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage of `id` by layers painted after it in its screen. None outside a screen. Clipping
|
||||
/// and scrolling don't count as covering: only layers on top do.
|
||||
pub fn coverage(doc: &Document, id: NodeId) -> Option<Coverage> {
|
||||
let screen = screen_of(doc, id)?;
|
||||
let target = doc.world_rect(id)?;
|
||||
let screen_rect = doc.world_rect(screen)?;
|
||||
let mut order = Vec::new();
|
||||
paint_walk(doc, screen, &mut order);
|
||||
let at = order.iter().position(|x| *x == id)?;
|
||||
let mut covers = Vec::new();
|
||||
let mut rects = Vec::new();
|
||||
for &c in &order[at + 1..] {
|
||||
if doc.is_ancestor(id, c) || !shown(doc, c, screen) || !hides_below(doc, c, screen) {
|
||||
continue;
|
||||
}
|
||||
// ponytail: covers are clipped to the screen only, not to frames between; refine if
|
||||
// a clipped cover ever hides more than it shows.
|
||||
let Some(r) = doc.world_rect(c).and_then(|r| r.intersection(&screen_rect)).and_then(|r| r.intersection(&target)) else {
|
||||
continue;
|
||||
};
|
||||
covers.push(c);
|
||||
rects.push(r);
|
||||
}
|
||||
let area = target.w * target.h;
|
||||
let visible = if area <= 0.0 { 1.0 } else { (1.0 - union_area(&rects) / area).clamp(0.0, 1.0) };
|
||||
Some(Coverage { screen, visible, covers })
|
||||
}
|
||||
|
||||
/// Parents before children, children bottom to top: the order layers are painted in.
|
||||
fn paint_walk(doc: &Document, id: NodeId, out: &mut Vec<NodeId>) {
|
||||
out.push(id);
|
||||
for c in doc.get(id).map(|n| n.children.as_slice()).unwrap_or_default() {
|
||||
paint_walk(doc, *c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `id` and every ancestor up to `screen` are visible.
|
||||
fn shown(doc: &Document, id: NodeId, screen: NodeId) -> bool {
|
||||
std::iter::once(id).chain(doc.ancestors(id).into_iter().take_while(|a| *a != screen)).all(|a| doc.get(a).is_some_and(|n| n.visible))
|
||||
}
|
||||
|
||||
/// `id` hides what is under it: an overlay or inside one, or an opaque frame or rectangle.
|
||||
fn hides_below(doc: &Document, id: NodeId, screen: NodeId) -> bool {
|
||||
let chain: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id).into_iter().take_while(|a| *a != screen)).collect();
|
||||
if chain.iter().any(|a| doc.get(*a).is_some_and(|n| n.overlay)) {
|
||||
return true;
|
||||
}
|
||||
let Some(n) = doc.get(id) else { return false };
|
||||
let opacity: f64 = chain.iter().filter_map(|a| doc.get(*a)).map(|n| n.style.opacity).product();
|
||||
matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
|
||||
&& opacity >= OPAQUE
|
||||
&& n.style.fills.iter().any(|f| match f {
|
||||
Paint::Solid { color, .. } => f64::from(color.a) / 255.0 >= OPAQUE,
|
||||
Paint::Image { opacity, .. } => *opacity >= OPAQUE,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Area covered by the union of `rects` (coordinate compression; covers are few).
|
||||
fn union_area(rects: &[Rect]) -> f64 {
|
||||
let mut xs: Vec<f64> = rects.iter().flat_map(|r| [r.x, r.right()]).collect();
|
||||
let mut ys: Vec<f64> = rects.iter().flat_map(|r| [r.y, r.bottom()]).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
let mut area = 0.0;
|
||||
for xw in xs.windows(2) {
|
||||
for yw in ys.windows(2) {
|
||||
let (cx, cy) = ((xw[0] + xw[1]) / 2.0, (yw[0] + yw[1]) / 2.0);
|
||||
if rects.iter().any(|r| r.x <= cx && cx <= r.right() && r.y <= cy && cy <= r.bottom()) {
|
||||
area += (xw[1] - xw[0]) * (yw[1] - yw[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// Declares `ids` as screens or overlays, or back to plain frames: one undo step. Errors
|
||||
/// name the first layer that can't take the role, and nothing changes then.
|
||||
pub fn set_role(&mut self, ids: &[NodeId], role: Role) -> Result<(), String> {
|
||||
if let Some((id, why)) = ids.iter().find_map(|id| refusal(&self.doc, *id, role).map(|w| (*id, w))) {
|
||||
let name = self.doc.get(id).map_or("?", |n| n.name.as_str());
|
||||
return Err(format!("`{name}` can't be a {}: {why}", if role == Role::Overlay { "overlay" } else { "screen" }));
|
||||
}
|
||||
let label = match role {
|
||||
Role::Screen => "Declare screens",
|
||||
Role::Overlay => "Mark overlays",
|
||||
Role::None => "Clear screen roles",
|
||||
};
|
||||
self.edit(label, |tx| {
|
||||
for id in ids {
|
||||
if let Some(n) = tx.node_mut(*id) {
|
||||
n.screen = role == Role::Screen;
|
||||
n.overlay = role == Role::Overlay;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Color;
|
||||
|
||||
fn frame(ed: &mut Editor, parent: Option<NodeId>, r: Rect, fill: Option<Color>) -> NodeId {
|
||||
ed.edit("Add", |tx| {
|
||||
let id = tx.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Frame { clip: true }, r);
|
||||
n.style.fills = fill.map(Paint::solid).into_iter().collect();
|
||||
tx.insert(n, parent, None);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawer_hides_what_is_under_it_and_partial_passes() {
|
||||
let mut ed = Editor::default();
|
||||
let screen = frame(&mut ed, None, Rect::new(0.0, 0.0, 400.0, 800.0), None);
|
||||
let under = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 200.0, 100.0), None);
|
||||
let half = frame(&mut ed, Some(screen), Rect::new(100.0, 200.0, 200.0, 100.0), None);
|
||||
let drawer = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 200.0, 800.0), Some(Color { r: 255, g: 255, b: 255, a: 255 }));
|
||||
assert!(coverage(&ed.doc, under).is_none(), "not a screen yet");
|
||||
ed.set_role(&[screen], Role::Screen).unwrap();
|
||||
let c = coverage(&ed.doc, under).unwrap();
|
||||
assert!(c.is_hidden() && c.covers == vec![drawer], "{c:?}");
|
||||
let c = coverage(&ed.doc, half).unwrap();
|
||||
assert!(c.is_partial() && (c.visible - 0.5).abs() < 1e-9, "{c:?}");
|
||||
assert_eq!(coverage(&ed.doc, drawer).unwrap().state(), "visible");
|
||||
// A translucent drawer hides only once it is marked an overlay.
|
||||
ed.edit("Fade", |tx| tx.node_mut(drawer).unwrap().style.opacity = 0.5);
|
||||
assert_eq!(coverage(&ed.doc, under).unwrap().state(), "visible");
|
||||
ed.set_role(&[drawer], Role::Overlay).unwrap();
|
||||
assert!(coverage(&ed.doc, under).unwrap().is_hidden());
|
||||
// Hidden drawer: nothing covered.
|
||||
ed.edit("Hide", |tx| tx.node_mut(drawer).unwrap().visible = false);
|
||||
assert_eq!(coverage(&ed.doc, under).unwrap().state(), "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_are_refused_where_they_make_no_sense() {
|
||||
let mut ed = Editor::default();
|
||||
let section = frame(&mut ed, None, Rect::new(0.0, 0.0, 3000.0, 3000.0), None);
|
||||
let slot = frame(&mut ed, Some(section), Rect::new(0.0, 0.0, 1440.0, 924.0), None);
|
||||
let screen = frame(&mut ed, Some(slot), Rect::new(0.0, 24.0, 1440.0, 900.0), None);
|
||||
let inner = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 10.0, 10.0), None);
|
||||
ed.edit("Name", |tx| tx.node_mut(slot).unwrap().name = "Slot / Home".into());
|
||||
assert_eq!(candidates(&ed.doc), vec![screen]);
|
||||
assert!(ed.set_role(&[inner], Role::Overlay).is_err(), "overlay outside a screen");
|
||||
ed.set_role(&[screen], Role::Screen).unwrap();
|
||||
assert!(ed.set_role(&[inner], Role::Screen).is_err(), "screen in a screen");
|
||||
assert!(ed.set_role(&[section], Role::Screen).is_err(), "a section holding screens");
|
||||
ed.set_role(&[inner], Role::Overlay).unwrap();
|
||||
assert_eq!(ed.screens(), vec![screen]);
|
||||
ed.undo();
|
||||
ed.undo();
|
||||
assert!(ed.doc.get(screen).is_some_and(|n| !n.screen), "one undo step each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_area_counts_overlaps_once() {
|
||||
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
let b = Rect::new(5.0, 5.0, 10.0, 10.0);
|
||||
assert!((union_area(&[a, b]) - 175.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::geom::{Rect, V2, v2};
|
||||
use crate::history::Tx;
|
||||
use crate::layout;
|
||||
use crate::model::{
|
||||
AutoLayout, Direction, Document, InstanceLink, Node, NodeId, NodeKind, Sizing, Source,
|
||||
AutoLayout, Direction, Document, InstanceLink, Node, NodeId, NodeKind, Sizing, Source, Swap,
|
||||
};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
@@ -109,6 +109,15 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
|
||||
if !ov("size") {
|
||||
inst.rect.w = main.rect.w;
|
||||
inst.rect.h = main.rect.h;
|
||||
// Its own text gets its own box: the main's was measured for the main's words.
|
||||
if ov("text")
|
||||
&& let Some(l) = crate::text::layout_node(inst)
|
||||
{
|
||||
if matches!(inst.kind, NodeKind::Text { auto_width: true, .. }) {
|
||||
inst.rect.w = l.width.max(1.0);
|
||||
}
|
||||
inst.rect.h = l.height;
|
||||
}
|
||||
}
|
||||
// Bindings follow the main, field by field, except in overridden groups.
|
||||
let fields: BTreeSet<String> = main.bindings.keys().chain(inst.bindings.keys()).cloned().collect();
|
||||
@@ -157,11 +166,13 @@ pub fn instantiate_from(
|
||||
n.children = Vec::new();
|
||||
n.component = false;
|
||||
n.component_key = None;
|
||||
n.props.clear();
|
||||
n.comments.clear();
|
||||
n.instance = Some(InstanceLink {
|
||||
main,
|
||||
overrides: Vec::new(),
|
||||
source: source.clone(),
|
||||
swap: None,
|
||||
});
|
||||
if let Some(r) = root_rect {
|
||||
n.rect = r;
|
||||
@@ -199,17 +210,24 @@ pub fn sync_library_instance(tx: &mut Tx, inst: NodeId, ext: &Document) {
|
||||
|
||||
/// Bring one instance subtree in line with its main counterpart. `ext` is the document of
|
||||
/// the main's page when the link has a `source`; without it such an instance is left alone.
|
||||
/// A swapped nested instance syncs from the component it was swapped to (`ext` is then that
|
||||
/// component's page) and keeps its own place, like a root.
|
||||
pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&Document>) {
|
||||
let Some(n) = tx.doc().get(inst) else { return };
|
||||
let Some(link) = n.instance.clone() else {
|
||||
return;
|
||||
};
|
||||
let ext = ext.filter(|_| link.source.is_some());
|
||||
let main = match ext {
|
||||
None => tx.doc().get(link.main),
|
||||
Some(d) => d.get(link.main),
|
||||
let (main_id, source) = match &link.swap {
|
||||
Some(s) => (s.main, s.source.clone()),
|
||||
None => (link.main, link.source.clone()),
|
||||
};
|
||||
if link.source.is_some() && ext.is_none() {
|
||||
let is_root = is_root || link.swap.is_some();
|
||||
let ext = ext.filter(|_| source.is_some());
|
||||
let main = match ext {
|
||||
None => tx.doc().get(main_id),
|
||||
Some(d) => d.get(main_id),
|
||||
};
|
||||
if source.is_some() && ext.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(main) = main.cloned() else {
|
||||
@@ -220,6 +238,10 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
if updated != *n {
|
||||
*tx.node_mut(inst).unwrap() = updated;
|
||||
}
|
||||
// A layer whose content was replaced (an icon swapped by a property) keeps its children.
|
||||
if link.overrides.iter().any(|o| o == "children") {
|
||||
return;
|
||||
}
|
||||
// Children: match by link; create missing, remove stale, follow main's order.
|
||||
let kids: Vec<NodeId> = tx.doc().get(inst).unwrap().children.clone();
|
||||
let mut by_main: Vec<(NodeId, Option<Source>, NodeId)> = kids
|
||||
@@ -231,28 +253,47 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
|
||||
.map(|l| (l.main, l.source.clone(), *k))
|
||||
})
|
||||
.collect();
|
||||
for k in &kids {
|
||||
let stale = tx
|
||||
.doc()
|
||||
.get(*k)
|
||||
.and_then(|c| c.instance.as_ref())
|
||||
.is_none_or(|l| !main.children.contains(&l.main) || l.source != link.source);
|
||||
if stale {
|
||||
tx.remove(*k);
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
for (at, k) in kids.iter().enumerate() {
|
||||
let link = tx.doc().get(*k).and_then(|c| c.instance.clone());
|
||||
let stale = link.as_ref().is_none_or(|l| !main.children.contains(&l.main) || l.source != source);
|
||||
if !stale {
|
||||
continue;
|
||||
}
|
||||
// An instance written over a nested one (before swaps existed) takes the place of the
|
||||
// main's child at its index, as a swap, instead of being thrown away.
|
||||
let free = main.children.get(at).filter(|mc| !by_main.iter().any(|(m, s, _)| m == *mc && *s == source));
|
||||
let of_a_component = |l: &InstanceLink| match &l.source {
|
||||
None => tx.doc().get(l.main).is_some_and(|m| m.component),
|
||||
s if *s == source => ext.and_then(|d| d.get(l.main)).is_some_and(|m| m.component),
|
||||
_ => false,
|
||||
};
|
||||
if let (Some(l), Some(mc)) = (link.filter(|l| l.swap.is_none() && of_a_component(l)), free) {
|
||||
let mc = *mc;
|
||||
let n = tx.node_mut(*k).unwrap();
|
||||
n.instance = Some(InstanceLink { main: mc, overrides: l.overrides, source: source.clone(), swap: Some(Swap { main: l.main, source: l.source }) });
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
by_main.push((mc, source.clone(), *k));
|
||||
continue;
|
||||
}
|
||||
tx.remove(*k);
|
||||
by_main.retain(|(_, _, x)| x != k);
|
||||
}
|
||||
for (i, mc) in main.children.iter().enumerate() {
|
||||
match by_main.iter().find(|(m, s, _)| m == mc && *s == link.source) {
|
||||
match by_main.iter().find(|(m, s, _)| m == mc && *s == source) {
|
||||
Some((_, _, k)) => {
|
||||
let k = *k;
|
||||
if tx.doc().children(Some(inst)).iter().position(|x| *x == k) != Some(i) {
|
||||
tx.move_to_index(k, i);
|
||||
}
|
||||
sync_node(tx, k, false, ext);
|
||||
// A kid swapped to a component on another page syncs from there (cross-page flush).
|
||||
let kid_ext = match tx.doc().get(k).and_then(|c| c.instance.as_ref()).and_then(|l| l.swap.as_ref()) {
|
||||
Some(s) if s.source != source => None,
|
||||
_ => ext,
|
||||
};
|
||||
sync_node(tx, k, false, kid_ext);
|
||||
}
|
||||
None => {
|
||||
instantiate_from(tx, ext, link.source.clone(), *mc, Some(inst), Some(i), None);
|
||||
instantiate_from(tx, ext, source.clone(), *mc, Some(inst), Some(i), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,9 +357,10 @@ impl Editor {
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.instance
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.main == *c && l.source.is_none())
|
||||
n.instance.as_ref().is_some_and(|l| match &l.swap {
|
||||
Some(s) => s.main == *c && s.source.is_none(),
|
||||
None => l.main == *c && l.source.is_none(),
|
||||
})
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
@@ -592,7 +634,8 @@ impl Editor {
|
||||
|
||||
/// The page and node a link points at, when that node exists.
|
||||
fn link_main(&self, l: &InstanceLink) -> Option<(usize, &Node)> {
|
||||
let page = match &l.source {
|
||||
let (main, source) = l.target();
|
||||
let page = match source {
|
||||
None => self.edit_page_index(),
|
||||
Some(Source::Page(p)) => self.pages.iter().position(|s| s.id == *p)?,
|
||||
Some(Source::Library(_)) => return None,
|
||||
@@ -602,19 +645,19 @@ impl Editor {
|
||||
} else {
|
||||
&self.pages[page].stash.as_ref()?.doc
|
||||
};
|
||||
doc.get(l.main).map(|n| (page, n))
|
||||
doc.get(main).map(|n| (page, n))
|
||||
}
|
||||
|
||||
/// The main of `l` is on a page that is still being read, so it cannot be looked at yet.
|
||||
fn link_pending(&self, l: &InstanceLink) -> bool {
|
||||
matches!(&l.source, Some(Source::Page(p)) if self.pages.iter().any(|s| s.id == *p && s.pending))
|
||||
matches!(l.target().1, Some(Source::Page(p)) if self.pages.iter().any(|s| s.id == *p && s.pending))
|
||||
}
|
||||
|
||||
/// A clone of the document holding the main of instance node `id`, when it is another page.
|
||||
pub(super) fn source_doc(&self, id: NodeId) -> Option<Document> {
|
||||
let l = self.doc.get(id)?.instance.as_ref()?;
|
||||
let Source::Page(_) = l.source.as_ref()? else {
|
||||
return match l.source.as_ref()? {
|
||||
let Source::Page(_) = l.target().1.as_ref()? else {
|
||||
return match l.target().1.as_ref()? {
|
||||
Source::Library(id) => self.library_docs.get(id).cloned(),
|
||||
Source::Page(_) => None,
|
||||
};
|
||||
@@ -1012,6 +1055,28 @@ pub fn swap_instance_tx(
|
||||
true
|
||||
}
|
||||
|
||||
/// Swap a nested instance (one inside an instance) to another component: it keeps its place in
|
||||
/// the outer instance, so later syncs of the outer main keep the swap.
|
||||
pub fn swap_nested_tx(tx: &mut Tx, ext: Option<&Document>, source: Option<Source>, inst: NodeId, new_main: NodeId) -> bool {
|
||||
let src = ext.unwrap_or(tx.doc());
|
||||
if !src.get(new_main).is_some_and(|c| c.component) {
|
||||
return false;
|
||||
}
|
||||
let name = src.get(new_main).unwrap().name.clone();
|
||||
for k in tx.doc().children(Some(inst)).to_vec() {
|
||||
tx.remove(k);
|
||||
}
|
||||
let Some(n) = tx.node_mut(inst) else { return false };
|
||||
let Some(link) = n.instance.as_mut() else { return false };
|
||||
link.swap = Some(Swap { main: new_main, source });
|
||||
link.overrides.retain(|o| o == "name");
|
||||
if !link.overrides.iter().any(|o| o == "name") {
|
||||
n.name = name;
|
||||
}
|
||||
sync_node(tx, inst, true, ext);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -44,12 +44,15 @@ pub struct Undone {
|
||||
pub design: Option<DesignSystem>,
|
||||
pub design_from: Option<DesignSystem>,
|
||||
pub page_modes: Option<PageModes>,
|
||||
/// The layers the step changed.
|
||||
pub nodes: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn undone(&self, forward: bool) -> Undone {
|
||||
Undone {
|
||||
label: self.label.clone(),
|
||||
nodes: self.entries.iter().map(|e| e.id).collect(),
|
||||
design: self
|
||||
.design
|
||||
.as_ref()
|
||||
|
||||
@@ -71,7 +71,10 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
let kn = doc.get(*k).unwrap();
|
||||
match kn.layout_child.sizing[axis] {
|
||||
Sizing::Hug => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
|
||||
Sizing::Fill => 0.0,
|
||||
// A growing child still brings its content (CSS flex-basis auto); its current
|
||||
// size is what it grew to, so it is not used.
|
||||
Sizing::Fill if kn.layout.is_some() => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
|
||||
Sizing::Fill => clamp_size(&kn.layout_child, axis, 0.0),
|
||||
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
|
||||
}
|
||||
})
|
||||
@@ -164,31 +167,44 @@ pub fn layout_frame(tx: &mut Tx, id: NodeId) -> bool {
|
||||
layout_frame(tx, k);
|
||||
}
|
||||
}
|
||||
// 2. This frame's hug size.
|
||||
// 2. This frame's hug size, then 3. its children. A child that hugs its content and got a
|
||||
// new width here (stretched, filled) re-measures: its height depended on that width. If it
|
||||
// changed, this frame measures and arranges once more.
|
||||
let ma = main_axis(&l);
|
||||
let ca = 1 - ma;
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let sizing = n.layout_child.sizing;
|
||||
let mut rect = n.rect;
|
||||
for axis in [0, 1] {
|
||||
if sizing[axis] == Sizing::Hug {
|
||||
let any_fill = flow_children(tx.doc(), id)
|
||||
.iter()
|
||||
.any(|k| tx.doc().get(*k).unwrap().layout_child.sizing[axis] == Sizing::Fill);
|
||||
// A fill child on the main axis turns hug into fixed (Figma rule).
|
||||
if !(any_fill && axis == ma) {
|
||||
for pass in 0..2 {
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let sizing = n.layout_child.sizing;
|
||||
let mut rect = n.rect;
|
||||
for axis in [0, 1] {
|
||||
// Hug fits the content, growing children's included (CSS fit-content); a min size
|
||||
// leaves room that the growing children then fill.
|
||||
if sizing[axis] == Sizing::Hug {
|
||||
let s = hug_size(tx.doc(), id, axis);
|
||||
let (p, _) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, s.max(0.0));
|
||||
}
|
||||
let (p, cur_s) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
|
||||
}
|
||||
if rect != n.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
arrange(tx, id, &l, rect, ma, ca);
|
||||
let stale: Vec<NodeId> = flow_children(tx.doc(), id)
|
||||
.into_iter()
|
||||
.filter(|k| {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
kn.layout.is_some() && (0..2).any(|ax| kn.layout_child.sizing[ax] == Sizing::Hug && (hug_size(tx.doc(), *k, ax) - get(&kn.rect, ax).1).abs() > 0.5)
|
||||
})
|
||||
.collect();
|
||||
if stale.is_empty() || pass == 1 {
|
||||
break;
|
||||
}
|
||||
for k in stale {
|
||||
layout_frame(tx, k);
|
||||
}
|
||||
let (p, cur_s) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
|
||||
}
|
||||
if rect != n.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
arrange(tx, id, &l, rect, ma, ca);
|
||||
let after: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
|
||||
@@ -732,7 +732,7 @@ mod tests {
|
||||
start_head: Default::default(),
|
||||
end_head: Default::default(),
|
||||
};
|
||||
inner.instance = Some(InstanceLink { main: NodeId(1), overrides: vec![], source: None });
|
||||
inner.instance = Some(InstanceLink { main: NodeId(1), overrides: vec![], source: None, swap: None });
|
||||
root.children = vec![NodeId(2), NodeId(3)];
|
||||
inner.parent = Some(NodeId(1));
|
||||
line.parent = Some(NodeId(1));
|
||||
|
||||
@@ -312,7 +312,7 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
|
||||
tw_padding(l.padding, &mut c);
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Frame { clip: true }) {
|
||||
c.push("overflow-hidden".into());
|
||||
c.push(if n.scroll { "overflow-y-auto" } else { "overflow-hidden" }.into());
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text {
|
||||
@@ -399,19 +399,22 @@ fn instance_comp_attr(doc: &Document, n: &Node) -> String {
|
||||
|
||||
/// The code link of a node: its own (a main component) or its main component's (an instance).
|
||||
pub(crate) fn code_link<'a>(doc: &'a Document, n: &'a Node) -> Option<&'a crate::model::CodeLink> {
|
||||
n.code.as_ref().or_else(|| n.instance.as_ref().and_then(|l| doc.get(l.main)).and_then(|m| m.code.as_ref()))
|
||||
n.code.as_ref().or_else(|| n.instance.as_ref().and_then(|l| doc.get(l.target().0)).and_then(|m| m.code.as_ref()))
|
||||
}
|
||||
|
||||
/// A code link template with `{Name}` replaced by the text of the layer named `Name` under `id`
|
||||
/// (the instance's own text, overrides included). An unknown name stays as written.
|
||||
/// A code link template with `{Name}` replaced by the instance's property `Name`, else the text
|
||||
/// of the layer named `Name` under `id` (overrides included). An unknown name stays as written.
|
||||
fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) -> String) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find('{') {
|
||||
let Some(j) = rest[i..].find('}').map(|j| i + j) else { break };
|
||||
let name = &rest[i + 1..j];
|
||||
// A property value of the instance first (`{Status}`), else a layer's text (`{Label}`).
|
||||
let text = (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || " _-".contains(c)))
|
||||
.then(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
|
||||
.then(|| {
|
||||
doc.get(id).and_then(|n| n.prop_values.get(name).cloned()).or_else(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
|
||||
})
|
||||
.flatten();
|
||||
out.push_str(&rest[..i]);
|
||||
match text {
|
||||
@@ -1396,7 +1399,7 @@ fn build_spec_tree(
|
||||
|
||||
let mut instance_of: Option<Value> = None;
|
||||
if let Some(link) = &n.instance
|
||||
&& let Some(main) = doc.get(link.main)
|
||||
&& let Some(main) = doc.get(link.target().0)
|
||||
&& main.component
|
||||
{
|
||||
instance_of = Some(
|
||||
@@ -1646,6 +1649,8 @@ fn build_spec_tree(
|
||||
"fillVar": fill_var,
|
||||
"fills": n.style.fills.iter().map(|f| paint_json(doc, f)).collect::<Vec<_>>(),
|
||||
"stroke": stroke.map(|c| c.to_hex()),
|
||||
// A bound stroke (`bind_variable` field `stroke`) is written as its variable, like fills.
|
||||
"strokeVar": stroke.and(n.bindings.get("stroke")).map(|v| format!("--{}", v.trim_start_matches("--"))),
|
||||
"strokeWidth": stroke.map(|_| n.style.stroke_width),
|
||||
"cornerRadius": n.style.corner_radius,
|
||||
"cornerRadiusToken": token_for(doc, n.style.corner_radius, &["radius"]),
|
||||
@@ -1756,7 +1761,7 @@ pub(crate) fn to_ai_spec_with_categories(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(main) = inst.instance.as_ref().map(|l| l.main) else {
|
||||
let Some(main) = inst.instance.as_ref().map(|l| l.target().0) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(&main) {
|
||||
@@ -1806,7 +1811,7 @@ fn library_components(doc: &Document, root: NodeId) -> Vec<Value> {
|
||||
stack.extend(n.children.iter().rev());
|
||||
continue;
|
||||
};
|
||||
let main = n.instance.as_ref().map_or(id, |l| l.main);
|
||||
let main = n.instance.as_ref().map_or(id, |l| l.target().0);
|
||||
let name = doc.get(main).map_or_else(|| n.name.clone(), |m| m.name.clone());
|
||||
match out.iter_mut().find(|e| e["mainId"] == json!(main.0)) {
|
||||
Some(e) => e["usedBy"].as_array_mut().unwrap().push(json!(id.0)),
|
||||
@@ -1977,6 +1982,222 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
|
||||
Ok(md)
|
||||
}
|
||||
|
||||
/// Format the AI spec as compact text for an implementer: notes, the tokens the subtree uses,
|
||||
/// kit components, component definitions and one line per node. No reference code, no
|
||||
/// per-node enforcement prose (`verify_code` checks those). About 12x fewer tokens than JSON.
|
||||
pub fn to_ai_spec_compact(doc: &Document, root: NodeId, target: &str) -> Result<String, String> {
|
||||
to_ai_spec_compact_with_categories(doc, root, target, &crate::model::annotation::default_categories())
|
||||
}
|
||||
|
||||
pub(crate) fn to_ai_spec_compact_with_categories(
|
||||
doc: &Document, root: NodeId, target: &str,
|
||||
categories: &[crate::model::annotation::AnnotationCategory],
|
||||
) -> Result<String, String> {
|
||||
let spec = to_ai_spec_with_categories(doc, root, target, categories)?;
|
||||
Ok(format_compact(&spec))
|
||||
}
|
||||
|
||||
fn format_compact(spec: &Value) -> String {
|
||||
let mut out = format!("# {}\n\n", spec["name"].as_str().unwrap_or("Frame"));
|
||||
if let Some(notes) = spec["annotations"].as_array().filter(|a| !a.is_empty()) {
|
||||
out.push_str("## Notes\n");
|
||||
for n in notes {
|
||||
out.push_str(n.as_str().unwrap_or("").trim_start_matches('/').trim());
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
collect_tokens(&spec["layoutTree"], &mut used);
|
||||
for d in spec["componentDefinitions"].as_array().into_iter().flatten() {
|
||||
collect_tokens(&d["layoutTree"], &mut used);
|
||||
}
|
||||
if !used.is_empty() {
|
||||
out.push_str("## Tokens used\n");
|
||||
for u in &used {
|
||||
let key = u.trim_start_matches("--");
|
||||
let v = spec["tokens"]["colors"][key].as_str().or(spec["tokens"]["dimensions"][key].as_str()).unwrap_or("?");
|
||||
out.push_str(&format!("{u}: {v}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(lib) = spec["libraryComponents"].as_array().filter(|l| !l.is_empty()) {
|
||||
out.push_str("## Kit components\n");
|
||||
for l in lib {
|
||||
out.push_str(&format!("{} ({}): {}\n", l["name"].as_str().unwrap_or(""), l["kit"].as_str().unwrap_or(""), l["import"].as_str().unwrap_or("")));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if let Some(defs) = spec["componentDefinitions"].as_array().filter(|d| !d.is_empty()) {
|
||||
out.push_str("## Component definitions\n");
|
||||
for d in defs {
|
||||
out.push_str(&format!("### {}\n", d["name"].as_str().unwrap_or("")));
|
||||
compact_node(&d["layoutTree"], 0, &mut out);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out.push_str("## Tree\nFormat: Kind \"layer\" WxH layout style. hug/fill = sizing. Tokens as --name. Text: \"content\" size/lineHeight weight color.\n\n");
|
||||
compact_node(&spec["layoutTree"], 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_tokens(n: &Value, used: &mut std::collections::BTreeSet<String>) {
|
||||
let mut add = |v: &Value| {
|
||||
if let Some(s) = v.as_str() {
|
||||
used.insert(s.to_owned());
|
||||
}
|
||||
};
|
||||
add(&n["autoLayout"]["gapToken"]);
|
||||
for t in n["autoLayout"]["paddingTokens"].as_array().into_iter().flatten() {
|
||||
add(t);
|
||||
}
|
||||
for f in n["style"]["fills"].as_array().into_iter().flatten() {
|
||||
add(&f["var"]);
|
||||
}
|
||||
add(&n["style"]["fillVar"]);
|
||||
add(&n["style"]["strokeVar"]);
|
||||
add(&n["style"]["cornerRadiusToken"]);
|
||||
add(&n["text"]["fontSizeToken"]);
|
||||
add(&n["icon"]["colorVar"]);
|
||||
for c in n["children"].as_array().into_iter().flatten() {
|
||||
collect_tokens(c, used);
|
||||
}
|
||||
}
|
||||
|
||||
/// A token name when bound, else the raw value.
|
||||
fn tok(var: &Value, value: &Value) -> String {
|
||||
if let Some(v) = var.as_str() {
|
||||
return v.to_owned();
|
||||
}
|
||||
match value {
|
||||
Value::Number(n) => px_num(n.as_f64().unwrap_or(0.0)),
|
||||
Value::String(s) => s.clone(),
|
||||
_ => "0".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_size(n: &Value) -> String {
|
||||
let axis = |mode: &Value, px: &Value| match mode.as_str() {
|
||||
Some("hug") => "hug".to_owned(),
|
||||
Some("fill") => "fill".to_owned(),
|
||||
_ => px_num(px.as_f64().unwrap_or(0.0)),
|
||||
};
|
||||
format!(
|
||||
"{}×{}",
|
||||
axis(&n["sizing"]["horizontal"], &n["bounds"]["width"]),
|
||||
axis(&n["sizing"]["vertical"], &n["bounds"]["height"])
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_layout(al: &Value) -> Vec<String> {
|
||||
if al.is_null() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = vec![if al["direction"] == "row" { "row" } else { "col" }.to_owned()];
|
||||
if al["wrap"] == true {
|
||||
parts.push("wrap".into());
|
||||
}
|
||||
let gap = tok(&al["gapToken"], &al["gap"]);
|
||||
if gap != "0" {
|
||||
parts.push(format!("gap={gap}"));
|
||||
}
|
||||
let pad: Vec<&Value> = al["padding"].as_array().map(|a| a.iter().collect()).unwrap_or_default();
|
||||
if pad.iter().any(|v| v.as_f64().unwrap_or(0.0) != 0.0) {
|
||||
let toks = al["paddingTokens"].as_array();
|
||||
let parts_pad: Vec<String> = pad.iter().enumerate().map(|(i, v)| tok(toks.and_then(|t| t.get(i)).unwrap_or(&Value::Null), v)).collect();
|
||||
parts.push(format!("pad=[{}]", parts_pad.join(",")));
|
||||
}
|
||||
if let Some(m) = al["alignMain"].as_str().filter(|m| *m != "start") {
|
||||
parts.push(format!("main={m}"));
|
||||
}
|
||||
if let Some(c) = al["alignCross"].as_str().filter(|c| *c != "start") {
|
||||
parts.push(format!("cross={c}"));
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn compact_style(st: &Value) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
for f in st["fills"].as_array().into_iter().flatten() {
|
||||
if f["type"] == "solid" {
|
||||
parts.push(format!("bg={}", tok(&f["var"], &f["color"])));
|
||||
} else if let Some(t) = f["type"].as_str() {
|
||||
parts.push(format!("bg={t}"));
|
||||
}
|
||||
}
|
||||
if !st["stroke"].is_null() {
|
||||
parts.push(format!("border={}/{}", tok(&st["strokeVar"], &st["stroke"]), px_num(st["strokeWidth"].as_f64().unwrap_or(1.0))));
|
||||
}
|
||||
if st["cornerRadius"].as_f64().unwrap_or(0.0) > 0.0 {
|
||||
parts.push(format!("r={}", tok(&st["cornerRadiusToken"], &st["cornerRadius"])));
|
||||
}
|
||||
if let Some(o) = st["opacity"].as_f64().filter(|o| *o != 1.0) {
|
||||
parts.push(format!("op={o}"));
|
||||
}
|
||||
if st["clip"] == true {
|
||||
parts.push("clip".into());
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn compact_node(n: &Value, depth: usize, out: &mut String) {
|
||||
let ind = " ".repeat(depth);
|
||||
let name = n["name"].as_str().unwrap_or("");
|
||||
let kind = n["kind"].as_str().unwrap_or("node");
|
||||
let line = match kind {
|
||||
"text" => {
|
||||
let t = &n["text"];
|
||||
let mut bits = vec![
|
||||
format!("Text {name:?}"),
|
||||
format!("{:?}", t["content"].as_str().unwrap_or("")),
|
||||
format!("{}/{}", tok(&t["fontSizeToken"], &t["fontSize"]), px_num(t["lineHeight"].as_f64().unwrap_or(0.0))),
|
||||
t["fontWeight"].as_u64().unwrap_or(400).to_string(),
|
||||
];
|
||||
if let Some(a) = t["align"].as_str().filter(|a| *a != "left") {
|
||||
bits.push(a.to_owned());
|
||||
}
|
||||
bits.push(format!("color={}", tok(&n["style"]["fillVar"], &n["style"]["fill"])));
|
||||
if t["autoWidth"] != true {
|
||||
bits.push(format!("w={}", px_num(n["bounds"]["width"].as_f64().unwrap_or(0.0))));
|
||||
}
|
||||
bits.join(" ")
|
||||
}
|
||||
"icon" => {
|
||||
let i = &n["icon"];
|
||||
format!(
|
||||
"Icon {}:{} {} color={}",
|
||||
i["set"].as_str().unwrap_or(""),
|
||||
i["name"].as_str().unwrap_or(""),
|
||||
px_num(i["size"][0].as_f64().unwrap_or(0.0)),
|
||||
tok(&i["colorVar"], &i["color"])
|
||||
)
|
||||
}
|
||||
"library" => {
|
||||
let l = &n["library"];
|
||||
let usage = l["usage"].as_str().filter(|u| !u.is_empty()).or(l["react"].as_str()).unwrap_or("");
|
||||
format!("Kit {} {}", l["kit"].as_str().unwrap_or(""), usage.lines().next().unwrap_or(""))
|
||||
}
|
||||
_ if !n["instanceOf"].is_null() => {
|
||||
let of = n["instanceOf"]["component"].as_str().unwrap_or("");
|
||||
format!("Instance {name:?} of {of:?} {}", compact_size(n))
|
||||
}
|
||||
"vector" => format!("Vector {name:?} {}", compact_size(n)),
|
||||
_ => {
|
||||
let mut bits = vec![format!("{}{} {name:?}", kind[..1].to_ascii_uppercase(), &kind[1..]), compact_size(n)];
|
||||
bits.extend(compact_layout(&n["autoLayout"]));
|
||||
bits.extend(compact_style(&n["style"]));
|
||||
bits.join(" ")
|
||||
}
|
||||
};
|
||||
out.push_str(&format!("{ind}{line}\n"));
|
||||
if matches!(kind, "icon" | "library") || !n["instanceOf"].is_null() {
|
||||
return;
|
||||
}
|
||||
for c in n["children"].as_array().into_iter().flatten() {
|
||||
compact_node(c, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2009,6 +2230,7 @@ mod tests {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
source: None,
|
||||
swap: None,
|
||||
});
|
||||
frame.layout = Some(AutoLayout {
|
||||
direction: Direction::Vertical,
|
||||
@@ -2232,7 +2454,7 @@ mod tests {
|
||||
let inst = frame(&mut doc, "Button / Outline");
|
||||
label(&mut doc, inst, text);
|
||||
let n = doc.nodes.get_mut(&inst).unwrap();
|
||||
n.instance = Some(InstanceLink { main, overrides: Vec::new(), source: None });
|
||||
n.instance = Some(InstanceLink { main, overrides: Vec::new(), source: None, swap: None });
|
||||
n.parent = Some(screen);
|
||||
doc.nodes.get_mut(&screen).unwrap().children.push(inst);
|
||||
}
|
||||
|
||||
@@ -654,8 +654,30 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
missing.is_empty().then_some((k, text))
|
||||
})
|
||||
.collect();
|
||||
// A border naming a color variable (`1px solid var(--border)`) binds the stroke to it, so a
|
||||
// mode switch reaches it; a border written with a literal colour drops that binding.
|
||||
let stroke_link: Option<Option<String>> = styles.iter().rev().find(|(k, _)| BORDER_PROPS.contains(&k.as_str())).map(|(_, v)| {
|
||||
let mut rest = v.as_str();
|
||||
while let Some(i) = rest.find("var(--") {
|
||||
let name = rest[i + 6..].split([')', ',']).next().unwrap_or_default().trim();
|
||||
if VARS.with(|m| m.borrow().contains_key(name)) {
|
||||
return Some(name.to_owned());
|
||||
}
|
||||
rest = &rest[i + 6..];
|
||||
}
|
||||
None
|
||||
});
|
||||
let mut a = apply_resolved(n, &resolved, ctx);
|
||||
a.ignored.extend(unknown);
|
||||
match stroke_link {
|
||||
Some(Some(name)) => {
|
||||
n.bindings.insert("stroke".into(), name);
|
||||
}
|
||||
Some(None) => {
|
||||
n.bindings.remove("stroke");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for name in linked {
|
||||
if let Some(Paint::Solid { var, .. }) = n.style.fills.last_mut() {
|
||||
*var = Some(name.to_string());
|
||||
@@ -664,6 +686,9 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
a
|
||||
}
|
||||
|
||||
/// Properties whose colour is the stroke's.
|
||||
const BORDER_PROPS: [&str; 9] = ["border", "outline", "borderColor", "stroke", "borderTop", "borderRight", "borderBottom", "borderLeft", "borderTopColor"];
|
||||
|
||||
const COLOR_PROPS: [&str; 5] = [
|
||||
"backgroundColor",
|
||||
"background",
|
||||
@@ -1200,10 +1225,15 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
|
||||
true
|
||||
}
|
||||
"overflow" if n.is_frame() => {
|
||||
"overflow" | "overflowY" if n.is_frame() => {
|
||||
if let NodeKind::Frame { clip } = &mut n.kind {
|
||||
*clip = matches!(lower.as_str(), "hidden" | "clip" | "scroll" | "auto");
|
||||
}
|
||||
// A scrolling screen: fixed size, its overflow scrolls on the canvas.
|
||||
n.scroll = matches!(lower.as_str(), "scroll" | "auto");
|
||||
if !n.scroll {
|
||||
n.scroll_y = 0.0;
|
||||
}
|
||||
true
|
||||
}
|
||||
"backgroundColor" | "background" | "backgroundImage" | "fill" if !is_text => {
|
||||
@@ -1776,7 +1806,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
}
|
||||
}
|
||||
if let NodeKind::Frame { clip: true } = n.kind {
|
||||
put("overflow", "hidden".into());
|
||||
put("overflow", if n.scroll { "auto" } else { "hidden" }.into());
|
||||
}
|
||||
if !n.visible {
|
||||
put("display", "none".into());
|
||||
@@ -1815,7 +1845,14 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
if let Some(c) = n.style.fill() {
|
||||
// A colour bound to a variable reads as that variable, like a background does.
|
||||
let var = n.style.fills.iter().find_map(|p| match p {
|
||||
Paint::Solid { var: Some(v), .. } => Some(v.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(v) = var.or_else(|| n.bindings.get("text-color").cloned()) {
|
||||
put("color", format!("var(--{})", v.trim_start_matches("--")));
|
||||
} else if let Some(c) = n.style.fill() {
|
||||
put("color", color_css(c));
|
||||
}
|
||||
}
|
||||
@@ -1844,6 +1881,8 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
if let Some(s) = n.style.stroke
|
||||
&& n.style.stroke_width > 0.0
|
||||
{
|
||||
// A stroke bound to a variable reads as that variable.
|
||||
let stroke = n.bindings.get("stroke").map_or_else(|| color_css(s), |v| format!("var(--{})", v.trim_start_matches("--")));
|
||||
let kind = if n.style.dash.is_empty() {
|
||||
"solid"
|
||||
} else if n.style.dash[0] <= n.style.stroke_width * 1.5 {
|
||||
@@ -1859,14 +1898,11 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
.enumerate()
|
||||
{
|
||||
if sides[i] > 0.0 {
|
||||
put(key, format!("{} {kind} {}", px(sides[i]), color_css(s)));
|
||||
put(key, format!("{} {kind} {stroke}", px(sides[i])));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
put(
|
||||
"border",
|
||||
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
|
||||
);
|
||||
put("border", format!("{} {kind} {stroke}", px(n.style.stroke_width)));
|
||||
}
|
||||
}
|
||||
if let Some(r) = n.style.radii {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! `delegate`: the chat agent hands review, reading or a well-specified build to a helper agent
|
||||
//! on a cheaper model (pi, opencode, an Ollama-backed agent: any CLI that takes a prompt and has
|
||||
//! a shell). The helper drives PaperCraft through `papercraft-cli`, which reaches this app (it
|
||||
//! inherits `PAPERCRAFT_MCP_PORT`), and only its final answer comes back into the main context.
|
||||
|
||||
use super::{Out, ToolResult};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const TOOL: &str = "delegate";
|
||||
pub const ROLES: [&str; 3] = ["qa", "read", "build"];
|
||||
/// The user's pi with MiniMax M3.1 Flash: fast, cheap, sees images.
|
||||
pub const PI_COMMAND: &str = "pi -p --no-session -nc -ns --model minimax/MiniMax-M3.1-Flash {prompt}";
|
||||
/// opencode with its default model; add `-m provider/model` (an `ollama/...` one runs locally).
|
||||
pub const OPENCODE_COMMAND: &str = "opencode run {prompt}";
|
||||
const TIMEOUT: Duration = Duration::from_secs(900);
|
||||
const POLL: Duration = Duration::from_millis(250);
|
||||
/// The tail of the helper's output that comes back: its answer, not its tool log.
|
||||
const MAX_ANSWER: usize = 6000;
|
||||
|
||||
/// Helpers running for a chat, each with its stop flag; the chat's Stop button sets them.
|
||||
static RUNNING: Mutex<Vec<(String, Arc<AtomicBool>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Stop the helpers the chat `chat` started (its Stop button).
|
||||
pub fn stop(chat: &str) {
|
||||
for (c, flag) in RUNNING.lock().unwrap().iter() {
|
||||
if c == chat {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the helper `command` (`{prompt}` is passed as `$PAPERCRAFT_PROMPT`) for one task.
|
||||
/// `chat`: the chat that asked, so its Stop button ends the helper too.
|
||||
pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolResult {
|
||||
if command.trim().is_empty() {
|
||||
return Err("No helper agent is set up. The user can choose one in Settings, Chat, Helper; do the work yourself.".into());
|
||||
}
|
||||
let role = args["role"].as_str().filter(|r| ROLES.contains(r)).ok_or("role must be qa, read or build")?;
|
||||
let task = args["task"].as_str().map(str::trim).filter(|t| !t.is_empty()).ok_or("give the task with everything the helper needs")?;
|
||||
let cli = crate::chat::path::which("papercraft-cli").ok_or("papercraft-cli is not installed, so a helper cannot reach PaperCraft")?;
|
||||
let prompt = format!("{}\n\n{}\n\nTask:\n{task}", preamble(&cli.display().to_string()), role_rules(role));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &command.replace("{prompt}", "%PAPERCRAFT_PROMPT%")]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &command.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")]);
|
||||
c
|
||||
};
|
||||
// Its own process group, so stopping it also ends the agent the shell started.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", &prompt)
|
||||
// An app opened from Finder has a bare PATH: give the helper the user's shell PATH.
|
||||
.env("PATH", crate::chat::path::resolved_path())
|
||||
.current_dir(std::env::temp_dir())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("could not start the helper: {e}"))?;
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let key = chat.unwrap_or_default().to_owned();
|
||||
RUNNING.lock().unwrap().push((key, flag.clone()));
|
||||
let started = Instant::now();
|
||||
let ended = loop {
|
||||
match child.try_wait() {
|
||||
Err(e) => break Err(e.to_string()),
|
||||
Ok(Some(_)) => break Ok(()),
|
||||
Ok(None) => {}
|
||||
}
|
||||
let late = started.elapsed() > TIMEOUT;
|
||||
if late || flag.load(Ordering::Relaxed) {
|
||||
kill_group(&mut child);
|
||||
break Err(if late { format!("the helper took longer than {} minutes", TIMEOUT.as_secs() / 60) } else { "stopped by the user".into() });
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
};
|
||||
RUNNING.lock().unwrap().retain(|(_, f)| !Arc::ptr_eq(f, &flag));
|
||||
ended?;
|
||||
let done = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
let out = String::from_utf8_lossy(&done.stdout);
|
||||
if !done.status.success() || out.trim().is_empty() {
|
||||
let err = String::from_utf8_lossy(&done.stderr);
|
||||
return Err(format!("the helper failed: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("no output")));
|
||||
}
|
||||
Ok(vec![Out::Text(tail(out.trim(), MAX_ANSWER).to_owned())])
|
||||
}
|
||||
|
||||
/// End the helper and everything it started.
|
||||
fn kill_group(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
let _ = Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn preamble(cli: &str) -> String {
|
||||
format!(
|
||||
"You are a helper agent for a design agent working in PaperCraft, a design app. You do not see its \
|
||||
chat: everything you need is below. Drive PaperCraft only with its CLI from your shell: \
|
||||
`{cli} call <tool> '<json args>'` (tool names: `{cli} list`; a tool's arguments: `{cli} schema <tool>`). \
|
||||
For an image result add `-o <file>.png` and look at the file if you can view images. Work only on \
|
||||
what the task names. End with only your answer in plain text, no preamble."
|
||||
)
|
||||
}
|
||||
|
||||
fn role_rules(role: &str) -> &'static str {
|
||||
match role {
|
||||
"qa" => "Role: QA reviewer. Do not edit. Run lint_design without fix, then check each frame the task names \
|
||||
(get_screenshot if you can view images, else get_tree_summary and get_computed_styles): overflow and \
|
||||
clipped text, alignment and spacing, literal or off-palette colors, contrast, target sizes, placeholder \
|
||||
content, look-alikes that should be instances. Reply with at most 12 findings, most severe first, one \
|
||||
line each: node id, problem, fix. Or 'No findings'.",
|
||||
"read" => "Role: reader. Do not edit. Read what the task names (pages, frames, specs, files, URLs) and return only \
|
||||
the key points it asks for, at most 25 lines, with node ids where they matter.",
|
||||
_ => "Role: builder. Make exactly the edits the task specifies with the variables, tokens and components it names \
|
||||
(write_html with var(--name) and <x-paper-instance>). Do not redesign or add anything else. Then run \
|
||||
lint_design on what you changed. Reply in at most 10 lines: what you made (node ids) and lint warnings left.",
|
||||
}
|
||||
}
|
||||
|
||||
/// The last `max` bytes of `s`, cut at a character boundary.
|
||||
fn tail(s: &str, max: usize) -> &str {
|
||||
let mut i = s.len().saturating_sub(max);
|
||||
while !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
&s[i..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(unix)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_task_reaches_the_helper_as_data_and_only_its_answer_returns() {
|
||||
if crate::chat::path::which("papercraft-cli").is_none() {
|
||||
return;
|
||||
}
|
||||
// The task tries to run a command; it must arrive as plain text.
|
||||
let task = "check Home\"; touch pwned; echo \"";
|
||||
let out = run("printf '%s' {prompt} | tail -c 40", None, &json!({"role": "qa", "task": task})).unwrap();
|
||||
let Out::Text(t) = &out[0] else { panic!() };
|
||||
assert!(t.ends_with(task), "{t}");
|
||||
assert!(!std::env::temp_dir().join("pwned").exists());
|
||||
assert!(run("", None, &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings"));
|
||||
assert!(run("echo", None, &json!({"role": "design", "task": "x"})).is_err());
|
||||
assert!(run("exit 3", None, &json!({"role": "read", "task": "x"})).is_err());
|
||||
assert_eq!(tail("héllo", 4), "llo");
|
||||
// Stop ends a running helper, and the process it started, at once.
|
||||
let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), &json!({"role": "qa", "task": "x"})));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let begun = Instant::now();
|
||||
stop("chat-stop-test");
|
||||
assert_eq!(t.join().unwrap().unwrap_err(), "stopped by the user");
|
||||
assert!(begun.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -87,24 +87,11 @@ fn strip_token_declaration_blocks(code: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < code.len() {
|
||||
let rest = &code[i..];
|
||||
let starts_decl = ["root", "theme", "host", "data-mode", "data-theme"]
|
||||
// Byte-safe prefix test: slicing `rest[..n]` panics when byte n falls inside a
|
||||
// multibyte char (a `ó` in "Rótulo" took the app down on a real screen's code).
|
||||
let starts_decl = [":root", "@theme", ":host", "[data-mode", "[data-theme"]
|
||||
.iter()
|
||||
.any(|kw| {
|
||||
let pfx_len = match *kw {
|
||||
"root" | "host" => 1 + kw.len(), // :root, :host
|
||||
"theme" => 1 + kw.len(), // @theme
|
||||
_ => 1 + kw.len(), // [data-mode, [data-theme
|
||||
};
|
||||
if rest.len() < pfx_len {
|
||||
return false;
|
||||
}
|
||||
let head = &rest[..pfx_len];
|
||||
head.eq_ignore_ascii_case(":root")
|
||||
|| head.eq_ignore_ascii_case("@theme")
|
||||
|| head.eq_ignore_ascii_case(":host")
|
||||
|| head.eq_ignore_ascii_case("[data-mode")
|
||||
|| head.eq_ignore_ascii_case("[data-theme")
|
||||
});
|
||||
.any(|p| rest.get(..p.len()).is_some_and(|head| head.eq_ignore_ascii_case(p)));
|
||||
if starts_decl && let Some(open_rel) = rest.find('{') {
|
||||
let mut depth = 0usize;
|
||||
let mut end_idx = None;
|
||||
@@ -138,7 +125,28 @@ fn web_var_referenced(code: &str, var_name: &str) -> bool {
|
||||
let outside = strip_token_declaration_blocks(code);
|
||||
let css_var = format!("--{var_name}");
|
||||
let tw_color_var = format!("--color-{var_name}");
|
||||
outside.contains(&css_var) || outside.contains(&tw_color_var)
|
||||
outside.contains(&css_var) || outside.contains(&tw_color_var) || tailwind_utility_references(&outside, var_name)
|
||||
}
|
||||
|
||||
/// `bg-primary`, `text-muted-foreground/80`, `hover:border-ring`: Tailwind v4 utilities generated
|
||||
/// from `@theme` colour tokens reference the variable without spelling `--name`. A hit needs a
|
||||
/// utility prefix letter before `-name` and no identifier char after it (`bg-primary-foreground`
|
||||
/// is not a reference to `primary`).
|
||||
fn tailwind_utility_references(code: &str, var_name: &str) -> bool {
|
||||
let needle = format!("-{var_name}");
|
||||
let bytes = code.as_bytes();
|
||||
let mut from = 0;
|
||||
while let Some(pos) = code[from..].find(&needle) {
|
||||
let start = from + pos;
|
||||
let end = start + needle.len();
|
||||
let before_ok = start > 0 && bytes[start - 1].is_ascii_alphabetic();
|
||||
let after_ok = end >= bytes.len() || !(bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-' || bytes[end] == b'_');
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn count_web_absolute(code: &str) -> usize {
|
||||
@@ -742,6 +750,7 @@ mod tests {
|
||||
main: comp_id,
|
||||
overrides: Vec::new(),
|
||||
source: None,
|
||||
swap: None,
|
||||
});
|
||||
|
||||
let drift_code = r#"
|
||||
@@ -757,3 +766,20 @@ mod tests {
|
||||
assert!(rules.contains(&"spacing-drift"), "{rules:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tailwind_reference_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tailwind_v4_utilities_count_as_variable_references() {
|
||||
assert!(web_var_referenced(r#"<div className="bg-background text-foreground">"#, "background"));
|
||||
assert!(web_var_referenced(r#"className="hover:text-muted-foreground/80""#, "muted-foreground"));
|
||||
assert!(web_var_referenced(r#"className="border-ring""#, "ring"));
|
||||
// `bg-primary-foreground` references primary-foreground, not primary.
|
||||
assert!(!web_var_referenced(r#"className="bg-primary-foreground""#, "primary"));
|
||||
// A word that merely ends with the name is not a utility.
|
||||
assert!(!web_var_referenced("const x = 'anything-ring_'", "ring"));
|
||||
assert!(web_var_referenced("style={{ color: 'var(--primary)' }}", "primary"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,8 +155,16 @@ fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf
|
||||
let out = std::env::temp_dir().join(format!("papercraft-imagegen-{}-{}-{:08x}", std::process::id(), crate::project::format::now_ms(), fastrand::u32(..)));
|
||||
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
|
||||
let script = shell_script(template);
|
||||
let mut child = Command::new("/bin/sh")
|
||||
.args(["-c", &script])
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = Command::new("cmd.exe");
|
||||
c.args(["/C", &script]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new("/bin/sh");
|
||||
c.args(["-c", &script]);
|
||||
c
|
||||
};
|
||||
let mut child = cmd
|
||||
.env("PAPERCRAFT_PROMPT", prompt)
|
||||
.env("PAPERCRAFT_ASPECT", aspect)
|
||||
.env("PAPERCRAFT_RATIO", ratio(aspect))
|
||||
@@ -230,6 +238,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_custom_command_gets_the_prompt_as_data_not_as_shell() {
|
||||
// The prompt tries to run a command; it must arrive as plain text.
|
||||
let prompt = "dragon\"; touch pwned; echo \"";
|
||||
|
||||
@@ -71,16 +71,34 @@ fn solid(n: &Node) -> Option<(Color, Option<&str>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a node's top fill looks like behind its children: a solid's color, a gradient's stops
|
||||
/// averaged; `Some(None)` for an image, whose color is unknown.
|
||||
fn fill_color(n: &Node) -> Option<Option<Color>> {
|
||||
match n.style.fills.last()? {
|
||||
Paint::Solid { color, .. } => (color.a > 0).then_some(Some(*color)),
|
||||
Paint::Linear { stops, .. } | Paint::Radial { stops, .. } if !stops.is_empty() => {
|
||||
let avg = |f: fn(&Color) -> u8| {
|
||||
(stops.iter().map(|s| f(&s.color) as f64).sum::<f64>() / stops.len() as f64).round() as u8
|
||||
};
|
||||
let c = Color::rgba(avg(|c| c.r), avg(|c| c.g), avg(|c| c.b), avg(|c| c.a));
|
||||
(c.a > 0).then_some(Some(c))
|
||||
}
|
||||
Paint::Image { .. } => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color text sits on: the nearest opaque fill up the tree (translucent ones blend over
|
||||
/// what is behind them); white when nothing is filled.
|
||||
fn backdrop(doc: &Document, id: NodeId) -> Color {
|
||||
/// what is behind them); white when nothing is filled; `None` over an image.
|
||||
fn backdrop(doc: &Document, id: NodeId) -> Option<Color> {
|
||||
let mut layers: Vec<Color> = Vec::new();
|
||||
let mut cur = doc.get(id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
let Some(n) = doc.get(p) else { break };
|
||||
if n.visible
|
||||
&& let Some((c, _)) = solid(n)
|
||||
&& let Some(c) = fill_color(n)
|
||||
{
|
||||
let c = c?;
|
||||
layers.push(c);
|
||||
if c.a == 255 {
|
||||
break;
|
||||
@@ -88,10 +106,12 @@ fn backdrop(doc: &Document, id: NodeId) -> Color {
|
||||
}
|
||||
cur = n.parent;
|
||||
}
|
||||
layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(Color::rgb(255, 255, 255), blend)
|
||||
Some(
|
||||
layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(Color::rgb(255, 255, 255), blend),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_default_name(name: &str) -> bool {
|
||||
@@ -128,6 +148,9 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
ids.extend(doc.subtree(*r));
|
||||
}
|
||||
let vars: BTreeMap<String, Color> = doc.variables.clone();
|
||||
// With two or more modes a raw colour cannot follow a theme switch: an error, not a hint.
|
||||
let themed = doc.modes.len() >= 2;
|
||||
let (hard, literal_sev) = if themed { ("error", "error") } else { ("warning", "info") };
|
||||
// Colors used literally, with the first node using each, for the variables report.
|
||||
let mut literal: BTreeMap<String, (usize, NodeId)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
@@ -146,8 +169,9 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
};
|
||||
// ---- text
|
||||
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
|
||||
if let Some((fg, _)) = solid(n) {
|
||||
let bg = backdrop(doc, id);
|
||||
if let Some((fg, _)) = solid(n)
|
||||
&& let Some(bg) = backdrop(doc, id)
|
||||
{
|
||||
let fg = blend(bg, fg);
|
||||
let ratio = contrast(fg, bg);
|
||||
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
|
||||
@@ -245,7 +269,7 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
|
||||
add(
|
||||
"hard-coded-color",
|
||||
"warning",
|
||||
hard,
|
||||
format!("{} is the value of --{name}", c.to_hex()),
|
||||
format!("write var(--{name}) so a theme change reaches it"),
|
||||
);
|
||||
@@ -254,16 +278,96 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
e.0 += 1;
|
||||
}
|
||||
}
|
||||
// Borders too: a theme change must reach them like it reaches fills.
|
||||
if let Some(name) = hard_stroke(n, &vars) {
|
||||
add(
|
||||
"hard-coded-color",
|
||||
hard,
|
||||
format!("border {} is the value of --{name}", n.style.stroke.unwrap().to_hex()),
|
||||
format!("bind_variable {{field:\"stroke\", variable:\"{name}\"}} so a theme change reaches it"),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (hex, (count, first)) in literal {
|
||||
out.push(Issue {
|
||||
rule: "hard-coded-color",
|
||||
severity: "info",
|
||||
severity: literal_sev,
|
||||
node: first,
|
||||
message: format!("{hex} is used {count}× and is not a variable"),
|
||||
fix: "add it with set_variables, or use an existing variable".into(),
|
||||
});
|
||||
}
|
||||
out.extend(theme_copies(doc, roots));
|
||||
out
|
||||
}
|
||||
|
||||
/// Words that name a theme in an artboard name (the project's mode names count too).
|
||||
const THEME_WORDS: &[&str] = &[
|
||||
"light", "dark", "claro", "escuro", "day", "night", "dia", "noite", "modo", "mode", "theme",
|
||||
"tema",
|
||||
];
|
||||
|
||||
/// Top-level artboards in `roots` that copy a sibling for another theme: names that differ
|
||||
/// only by a theme word, or the same layers and text in different colours.
|
||||
fn theme_copies(doc: &Document, roots: &[NodeId]) -> Vec<Issue> {
|
||||
let modes: Vec<String> = doc.modes.keys().map(|m| m.to_lowercase()).collect();
|
||||
let base = |name: &str| -> String {
|
||||
name.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty() && !THEME_WORDS.contains(w) && !modes.iter().any(|m| m == w))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
// Kinds, child counts and text, colours left out.
|
||||
fn shape(doc: &Document, id: NodeId, out: &mut String) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
out.push_str(n.kind.type_name());
|
||||
out.push_str(n.kind.text().map_or("", |t| t.as_str()));
|
||||
out.push_str(&n.children.len().to_string());
|
||||
for c in &n.children {
|
||||
shape(doc, *c, out);
|
||||
}
|
||||
}
|
||||
fn colours(doc: &Document, id: NodeId, out: &mut Vec<Option<Color>>) {
|
||||
let Some(n) = doc.get(id) else { return };
|
||||
out.push(n.style.fills.last().map(|p| p.preview_color()));
|
||||
out.push(n.style.stroke);
|
||||
for c in &n.children {
|
||||
colours(doc, *c, out);
|
||||
}
|
||||
}
|
||||
let boards: Vec<&Node> = doc.root.iter().filter_map(|id| doc.get(*id)).filter(|n| n.is_frame()).collect();
|
||||
let mut out = Vec::new();
|
||||
for n in boards.iter().filter(|n| roots.contains(&n.id)) {
|
||||
let other = boards.iter().find(|o| {
|
||||
if o.id == n.id || o.name == n.name {
|
||||
return false;
|
||||
}
|
||||
let (bn, bo) = (base(&n.name), base(&o.name));
|
||||
if !bn.is_empty() && bn == bo {
|
||||
return true;
|
||||
}
|
||||
let (mut sn, mut so) = (String::new(), String::new());
|
||||
shape(doc, n.id, &mut sn);
|
||||
shape(doc, o.id, &mut so);
|
||||
if sn != so || n.children.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (mut cn, mut co) = (Vec::new(), Vec::new());
|
||||
colours(doc, n.id, &mut cn);
|
||||
colours(doc, o.id, &mut co);
|
||||
cn != co
|
||||
});
|
||||
if let Some(o) = other {
|
||||
out.push(Issue {
|
||||
rule: "theme-copy",
|
||||
severity: "warning",
|
||||
node: n.id,
|
||||
message: format!("`{}` looks like a theme copy of `{}`", n.name, o.name),
|
||||
fix: "use one artboard bound to variables with a value per mode, switch with set_variable_mode, preview with get_screenshot {mode}; show theme-only ornaments with bind_variable {field:\"visible\"} to a boolean".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -283,6 +387,11 @@ fn same_shape(a: &Document, ia: NodeId, b: &Document, ib: NodeId) -> bool {
|
||||
if na.kind.type_name() != nb.kind.type_name() || na.children.len() != nb.children.len() {
|
||||
return false;
|
||||
}
|
||||
// An instance cannot swap its icon, so a different icon is a different component.
|
||||
let icon = |n: &Node| n.name.strip_prefix(ICON_PREFIX).map(str::to_owned);
|
||||
if (icon(na).is_some() || icon(nb).is_some()) && icon(na) != icon(nb) {
|
||||
return false;
|
||||
}
|
||||
if na.is_frame()
|
||||
&& (na.style.fills.first() != nb.style.fills.first()
|
||||
|| (na.style.corner_radius - nb.style.corner_radius).abs() > COPY_RADIUS_TOLERANCE
|
||||
@@ -304,6 +413,18 @@ pub fn component_mains(ed: &crate::editor::Editor) -> Vec<(&Document, NodeId)> {
|
||||
docs.flat_map(|d| d.nodes.values().filter(|n| n.component).map(move |n| (d, n.id))).collect()
|
||||
}
|
||||
|
||||
/// The color variable an unbound, opaque border's colour equals.
|
||||
fn hard_stroke(n: &Node, vars: &BTreeMap<String, Color>) -> Option<String> {
|
||||
let s = n.style.stroke.filter(|s| s.a == 255 && n.style.stroke_width > 0.0)?;
|
||||
if n.bindings.contains_key("stroke") {
|
||||
return None;
|
||||
}
|
||||
vars.iter().find(|(_, v)| **v == s).map(|(name, _)| name.clone())
|
||||
}
|
||||
|
||||
/// Name prefix of a layer placed with `<x-paper-icon>` (`Icon / lucide:check`).
|
||||
const ICON_PREFIX: &str = "Icon / ";
|
||||
|
||||
/// `not-an-instance`: frames under `roots` built by hand to look like one of `mains` (same
|
||||
/// structure, size within 4px) instead of being an instance of it. `lint_design {fix}` leaves
|
||||
/// these alone; swapping is the user's call.
|
||||
@@ -317,13 +438,12 @@ pub fn not_an_instance(doc: &Document, roots: &[NodeId], mains: &[(&Document, No
|
||||
let near = |m: &Node| (m.rect.w - n.rect.w).abs() <= COPY_SIZE_TOLERANCE && (m.rect.h - n.rect.h).abs() <= COPY_SIZE_TOLERANCE;
|
||||
let hit = mains.iter().find(|(d, m)| d.get(*m).is_some_and(near) && same_shape(doc, id, d, *m));
|
||||
let Some(m) = hit.and_then(|(d, m)| d.get(*m)) else { continue };
|
||||
let key = m.component_key.as_deref().unwrap_or(&m.name);
|
||||
out.push(Issue {
|
||||
rule: "not-an-instance",
|
||||
severity: "warning",
|
||||
node: id,
|
||||
message: format!("`{}` looks like component `{}` but is not an instance of it", n.name, m.name),
|
||||
fix: format!("swap_instance with componentKey {key}"),
|
||||
fix: format!("write_html mode replace on it with <x-paper-instance component=\"{}\"/> (plus overrides for its texts)", m.name),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -397,6 +517,10 @@ pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> b
|
||||
}
|
||||
}
|
||||
|
||||
// Hard-coded borders that equal a color variable get bound to it.
|
||||
let stroke_var = hard_stroke(&n, &vars);
|
||||
changed |= stroke_var.is_some();
|
||||
|
||||
// 3. Snap text size to >= 12px and nearest type-scale value.
|
||||
let mut new_kind = n.kind.clone();
|
||||
if let crate::model::NodeKind::Text { font_size, .. } = &mut new_kind {
|
||||
@@ -436,6 +560,9 @@ pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> b
|
||||
}
|
||||
|
||||
if changed && let Some(m) = tx.node_mut(id) {
|
||||
if let Some(v) = stroke_var {
|
||||
m.bindings.insert("stroke".into(), v);
|
||||
}
|
||||
m.layout = new_layout;
|
||||
m.style.fills = new_fills;
|
||||
m.kind = new_kind;
|
||||
@@ -486,6 +613,31 @@ mod tests {
|
||||
assert_eq!(hard(&doc), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_text_on_a_dark_gradient_passes_contrast() {
|
||||
let mut doc = Document::default();
|
||||
let bg = doc.alloc_id();
|
||||
let mut frame = Node::new(bg, crate::model::NodeKind::Frame { clip: false }, crate::geom::Rect::new(0.0, 0.0, 400.0, 200.0));
|
||||
frame.style.fills = vec![Paint::radial(Color::rgb(0x2a, 0x21, 0x40), Color::rgb(0x0d, 0x0d, 0x14))];
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(
|
||||
id,
|
||||
crate::model::NodeKind::Text { text: "Hello".into(), font_size: 14.0, auto_width: true, align: Default::default() },
|
||||
crate::geom::Rect::new(24.0, 24.0, 40.0, 20.0),
|
||||
);
|
||||
n.style.fills = vec![Paint::solid(Color::rgb(0xcd, 0xd6, 0xf4))];
|
||||
n.parent = Some(bg);
|
||||
frame.children.push(id);
|
||||
doc.nodes.insert(bg, frame);
|
||||
doc.nodes.insert(id, n);
|
||||
doc.root.push(bg);
|
||||
let low = |d: &Document| lint(d, &[bg], &DEFAULT_SCALE).iter().filter(|i| i.rule == "contrast").count();
|
||||
assert_eq!(low(&doc), 0);
|
||||
// Dark text on the same gradient still fails.
|
||||
doc.nodes.get_mut(&id).unwrap().style.fills = vec![Paint::solid(Color::rgb(0x30, 0x30, 0x40))];
|
||||
assert_eq!(low(&doc), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_made_copy_of_a_component_is_flagged_and_an_instance_is_not() {
|
||||
use crate::mcp::{Out, exec};
|
||||
@@ -511,11 +663,33 @@ mod tests {
|
||||
let found = hits(&r);
|
||||
assert_eq!(found.len(), 1, "only the hand-made copy: {r}");
|
||||
assert_eq!(found[0]["node"], copy["createdNodeIds"][0]);
|
||||
assert!(found[0]["fix"].as_str().unwrap().starts_with("swap_instance with componentKey "), "{}", found[0]);
|
||||
assert!(found[0]["fix"].as_str().unwrap().contains("<x-paper-instance component=\"Button\"/>"), "{}", found[0]);
|
||||
let r = call("lint_design", json!({"fix": true}));
|
||||
assert_eq!(hits(&r).len(), 1, "fix does not swap: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_icon_is_a_different_component() {
|
||||
// Issue #16.
|
||||
let mut doc = Document::default();
|
||||
use crate::geom::Rect;
|
||||
use crate::model::NodeKind;
|
||||
let chip = |doc: &mut Document, icon: &str| {
|
||||
let (root, kid) = (doc.alloc_id(), doc.alloc_id());
|
||||
let mut r = Node::new(root, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 60.0, 20.0));
|
||||
r.children = vec![kid];
|
||||
let mut i = Node::new(kid, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 14.0, 14.0));
|
||||
i.name = format!("Icon / {icon}");
|
||||
i.parent = Some(root);
|
||||
doc.nodes.insert(root, r);
|
||||
doc.nodes.insert(kid, i);
|
||||
root
|
||||
};
|
||||
let (star, heart, star2) = (chip(&mut doc, "phosphor:star"), chip(&mut doc, "phosphor:heart"), chip(&mut doc, "phosphor:star"));
|
||||
assert!(!same_shape(&doc, star, &doc, heart));
|
||||
assert!(same_shape(&doc, star, &doc, star2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofix_snaps_grid_and_links_variables() {
|
||||
let mut doc = Document::default();
|
||||
|
||||
@@ -22,12 +22,15 @@ pub mod lint;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod imagegen;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod delegate;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refs;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod relay;
|
||||
pub mod relay_proto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
pub mod steer;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Messages the user adds while a chat turn is working. Each goes out with the next PaperCraft
|
||||
//! tool result for that chat, so any backend sees it mid-turn without starting a new turn; what
|
||||
//! no tool call picked up is sent as the next message when the turn ends.
|
||||
|
||||
use super::{Client, Out, ToolResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static QUEUE: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
|
||||
|
||||
pub fn add(chat: &str, text: &str) {
|
||||
QUEUE.lock().unwrap().get_or_insert_default().entry(chat.to_owned()).or_default().push(text.to_owned());
|
||||
}
|
||||
|
||||
/// What is still waiting for the agent in `chat`.
|
||||
pub fn pending(chat: &str) -> Vec<String> {
|
||||
QUEUE.lock().unwrap().as_ref().and_then(|q| q.get(chat).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn take(chat: &str) -> Vec<String> {
|
||||
QUEUE.lock().unwrap().as_mut().and_then(|q| q.remove(chat)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A chat's tool result with the messages the user added since, for the agent to read now.
|
||||
pub fn attach(client: &Client, r: ToolResult) -> ToolResult {
|
||||
if client.name != super::CHAT_CLIENT {
|
||||
return r;
|
||||
}
|
||||
let mut outs = r?;
|
||||
let added = take(&client.session);
|
||||
if !added.is_empty() {
|
||||
outs.push(Out::Text(format!(
|
||||
"[The user added this while you work. Take it into account from now on, then go on with the task.]\n{}",
|
||||
added.join("\n\n")
|
||||
)));
|
||||
}
|
||||
Ok(outs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn added_messages_ride_on_the_next_tool_result_of_that_chat_only() {
|
||||
add("steer-a", "make it blue");
|
||||
let other = attach(&Client::chat("steer-b"), Ok(vec![])).unwrap();
|
||||
assert!(other.is_empty());
|
||||
let mcp = Client { name: "Claude Code".into(), session: "steer-a".into() };
|
||||
assert!(attach(&mcp, Ok(vec![])).unwrap().is_empty(), "only chat turns");
|
||||
let outs = attach(&Client::chat("steer-a"), Ok(vec![])).unwrap();
|
||||
assert!(matches!(&outs[..], [Out::Text(t)] if t.ends_with("make it blue")));
|
||||
assert!(pending("steer-a").is_empty(), "delivered once");
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
|
||||
&& !m.contains_key("fileId")
|
||||
&& !matches!(
|
||||
name,
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules"
|
||||
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate"
|
||||
)
|
||||
{
|
||||
m.insert("fileId".into(), file_id());
|
||||
@@ -70,6 +70,11 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
|
||||
} else {
|
||||
json!({"destructiveHint": false, "readOnlyHint": false})
|
||||
};
|
||||
if crate::mcp::exec::GUARDED.contains(&name)
|
||||
&& let Some(m) = props.as_object_mut()
|
||||
{
|
||||
m.insert("allowHidden".into(), json!({"type": "boolean", "description": "Edit layers that are hidden behind an overlay or opaque layer in their screen (refused otherwise)."}));
|
||||
}
|
||||
json!({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
@@ -145,6 +150,14 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"rename_file",
|
||||
"Renames a project: its name and its folder. Without fileId, the open project (saved \
|
||||
first; it stays open). Returns the new folder path.",
|
||||
json!({"name": {"type": "string", "description": "The new project name."}}),
|
||||
&["name"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"create_page",
|
||||
"Creates a new page at the end of the page list and returns its id, without switching \
|
||||
@@ -237,6 +250,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
|
||||
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
|
||||
"annotations": {"type": "boolean", "description": "Also draw the note cards, badges and leader lines of notes on the captured layers; the image grows to include the cards."},
|
||||
"mode": {"type": "string", "description": "Render in this variable mode (e.g. \"Light\") without switching the document: preview every theme of one artboard while other agents keep working in the current mode."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&[],
|
||||
@@ -256,6 +270,17 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_inventory",
|
||||
"The whole project for planning in one call: each page's screens (top-level frames \
|
||||
with id, size and a size class: mobile, tablet, desktop, email, pdf-a4), its \
|
||||
component, instance and annotation counts and the notes still marked `? (team)`, \
|
||||
plus every component and the token group sizes. Use it before planning an \
|
||||
implementation instead of one get_tree_summary and get_annotations per page.",
|
||||
json!({}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_design_spec",
|
||||
"Export a deterministic AI Design-to-Code specification for a frame or subtree. \
|
||||
@@ -267,7 +292,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id to export."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json"], "description": "Output format: \"markdown\" (default) or \"json\"."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json", "compact"], "description": "Output format: \"markdown\" (default), \"json\", or \"compact\" (terse text for an implementer: notes, tokens used, kit components, one line per node; no reference code; about 12x fewer tokens than json)."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
@@ -338,6 +363,21 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["kind", "title", "body"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"delegate",
|
||||
"Hand a task to the helper agent the user set up (a cheaper model with PaperCraft's CLI, \
|
||||
weaker at design): `qa` reviews frames and returns findings, `read` reads many pages, \
|
||||
frames, specs or URLs and returns key points, `build` makes edits you fully specify. \
|
||||
It does not see this chat: the task carries everything (frame names or ids, the brief, \
|
||||
the variables and components to use, exact markup for a build). Only its short answer \
|
||||
comes back.",
|
||||
json!({
|
||||
"role": {"type": "string", "enum": ["qa", "read", "build"]},
|
||||
"task": {"type": "string", "description": "The complete, self-contained instructions."},
|
||||
}),
|
||||
&["role", "task"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"design_rules",
|
||||
"Look up design-playbook rules (interaction, accessibility, motion, forms, tokens, \
|
||||
@@ -842,12 +882,111 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_component_props",
|
||||
"Give a main component properties that each instance sets: `text` (a layer's text), \
|
||||
`bool` (shows a layer), `icon` (an icon layer's icon, any set:name) and `enum` (one of \
|
||||
options, each a set of changes in the overrides format: layer name, `$root` or a path \
|
||||
`Item > Label`, to a text or a style object that may also hold `text`, `icon` and \
|
||||
`visibility`). Or start from a template: `text` (a property per text layer), `active` \
|
||||
(which item of a tab bar, nav or segmented row is active; activeStyle/inactiveStyle \
|
||||
optional) or `status` (options [{value, color, icon?}] tint the root and colour the \
|
||||
label and icon). Use one component with properties instead of a component per state. \
|
||||
Instances on every page re-apply their values.",
|
||||
json!({
|
||||
"componentId": node_id("The main component."),
|
||||
"props": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"name": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["text", "bool", "icon", "enum"]},
|
||||
"layer": {"type": "string", "description": "text, bool, icon: the layer's name."},
|
||||
"options": {"type": "array", "items": {"type": "object", "properties": {"value": {"type": "string"}, "set": {"type": "object"}}, "required": ["value", "set"]}},
|
||||
"default": {"type": "string"},
|
||||
}, "required": ["name", "kind"]}},
|
||||
"template": {"type": "string", "enum": ["text", "active", "status"]},
|
||||
"name": {"type": "string", "description": "Template: the property's name (Active, Status)."},
|
||||
"options": {"type": "array", "description": "status template: [{value, color, icon?}].", "items": {"type": "object"}},
|
||||
"label": {"type": "string", "description": "status template: the label layer (default: the first text)."},
|
||||
"activeStyle": {"type": "object"},
|
||||
"inactiveStyle": {"type": "object"},
|
||||
"remove": {"type": "array", "items": {"type": "string"}, "description": "Property names to drop."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_prototype_links",
|
||||
"Prototype links for Present mode: clicking a layer (a button, a row, a tab) goes to a \
|
||||
screen on the same page. `to: null` removes a link. Link every entry point you design \
|
||||
(back arrows, list rows, tabs) so the flow can be clicked through.",
|
||||
json!({
|
||||
"links": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"nodeId": {"type": "string"},
|
||||
"to": {"type": ["string", "null"], "description": "The screen (a frame on the same page)."},
|
||||
}, "required": ["nodeId"]}},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["links"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_screens",
|
||||
"Declare frames as screens (one device viewport of the product, not a section, slot or \
|
||||
component) or as overlays (a drawer, modal or sheet inside a screen), or clear the role \
|
||||
with `none`. One undo step. Inside a screen, edits to layers fully hidden behind an \
|
||||
overlay or opaque layer are refused (pass allowHidden:true to edit them on purpose), \
|
||||
and partly covered ones are reported. get_basic_info lists screens; get_node_info tells \
|
||||
a layer's visibility in its screen.",
|
||||
json!({
|
||||
"nodeIds": ids("Frames to declare."),
|
||||
"role": {"type": "string", "enum": ["screen", "overlay", "none"], "description": "Default screen."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"merge_variants",
|
||||
"Turn per-state components (`Tab bar / Hoje`, `Tab bar / Lojas`, `Status / Sucesso`...) \
|
||||
into one component with an enum property: each variant's differences from the base \
|
||||
(fills, colours, borders, weight, radius, visibility, icons; texts with includeText) \
|
||||
become its option, every instance on every page moves to the base with its value, \
|
||||
the base takes the family name and the emptied variants are deleted. A variant with \
|
||||
layers the base lacks is left out (reported).",
|
||||
json!({
|
||||
"componentIds": {"type": "array", "items": {"type": "string"}, "description": "The variants (same page)."},
|
||||
"name": {"type": "string", "description": "The property's name (Active, Status, State)."},
|
||||
"baseId": {"type": "string", "description": "The variant kept (default: the first)."},
|
||||
"values": {"type": "array", "items": {"type": "string"}, "description": "Option names, in componentIds order (default: each name after its last ' / ')."},
|
||||
"includeText": {"type": "boolean", "description": "Texts that differ are part of the options (default false: instances keep their own texts)."},
|
||||
"textLayers": {"type": "array", "items": {"type": "string"}, "description": "Only these layers' texts are part of the options (a path or its end, e.g. \"Status > Label\"); the other texts stay each instance's."},
|
||||
"deleteMerged": {"type": "boolean", "description": "Delete the emptied variants (default true)."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["componentIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_instance_props",
|
||||
"Set property values on instances in one call ({\"Status\": \"Rejeitada\", \"Label\": \"NF 10.371\"}); \
|
||||
get_components lists each component's properties and options. In write_html: \
|
||||
<x-paper-instance component=\"Badge\" props='{\"Status\":\"Rejeitada\"}'/>.",
|
||||
json!({
|
||||
"nodeIds": {"type": "array", "items": {"type": "string"}},
|
||||
"values": {"type": "object", "additionalProperties": {"type": ["string", "boolean"]}},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeIds", "values"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"swap_instance",
|
||||
"Swap an instance root to point at a different main component, preserving overrides \
|
||||
on descendants matched by name and layer type. componentKey may be a component on \
|
||||
any page or an enabled library; no import needed.",
|
||||
json!({"nodeId": node_id("The instance root node to swap."),
|
||||
"Swap an instance to point at a different main component. An instance root keeps \
|
||||
overrides on descendants matched by name and layer type. A nested instance (a badge \
|
||||
inside a row instance) becomes a swap override: it keeps its place, and later edits \
|
||||
to the outer main keep the swap; set its texts after. componentKey may be a component \
|
||||
on any page or an enabled library; no import needed.",
|
||||
json!({"nodeId": node_id("The instance (root or nested) to swap."),
|
||||
"componentId": node_id("The new main component id."),
|
||||
"componentKey": {"type": "string", "description": "Instead of componentId: a key from get_components, including a library component."},
|
||||
"pageId": page_id()}),
|
||||
@@ -1070,13 +1209,13 @@ pub fn edits_doc(name: &str, args: &Value) -> bool {
|
||||
/// `None` for a tool with no class, so a tool added without one fails the test below.
|
||||
fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
Some(match name {
|
||||
"list_resources" | "get_basic_info" | "get_selection" | "get_node_info" | "get_children"
|
||||
"list_resources" | "get_basic_info" | "get_inventory" | "get_selection" | "get_node_info" | "get_children"
|
||||
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "find_nodes" | "get_font_family_info"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
|
||||
| "diff_frames" | "publish_library" | "list_libraries" => false,
|
||||
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
|
||||
| "create_file" | "finish_working_on_nodes" => false,
|
||||
| "create_file" | "rename_file" | "finish_working_on_nodes" => false,
|
||||
"lint_design" => args["fix"] == true,
|
||||
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
|
||||
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
|
||||
@@ -1085,6 +1224,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
| "enable_library" | "update_library" | "disable_library"
|
||||
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
|
||||
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
|
||||
| "set_component_props" | "set_instance_props" | "merge_variants" | "set_prototype_links" | "set_screens"
|
||||
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -270,8 +270,11 @@ pub fn place_among(doc: &Document, a: &Annotation, placed: &[Placed]) -> Option<
|
||||
let mut obs = obstacles(doc, Some(target));
|
||||
obs.push(t);
|
||||
// The screen holding the target: a card never sits on its
|
||||
// background, only the line may enter it.
|
||||
let screen = screen_of(doc, target);
|
||||
// background, only the line may enter it. A target placed straight in its artboard has no
|
||||
// inner screen: the artboard is the screen (a phone frame), not a board for notes.
|
||||
let inner = screen_of(doc, target);
|
||||
let screen = inner.or(frame);
|
||||
let frame = frame.filter(|_| inner.is_some());
|
||||
let route_obs = route_obstacles(doc, target);
|
||||
let blocked = |r: &Rect| {
|
||||
let g = r.inflate(MARGIN);
|
||||
|
||||
@@ -103,11 +103,23 @@ impl Document {
|
||||
|
||||
/// World-space position of a node's local coordinate origin's parent space.
|
||||
pub fn parent_origin(&self, parent: Option<NodeId>) -> V2 {
|
||||
self.origin_below(parent, false)
|
||||
}
|
||||
|
||||
/// Where `parent`'s content starts, for a child that is (`pinned`) or is not absolutely
|
||||
/// positioned: a scrolled frame shifts its flow content up, while its absolute children (a
|
||||
/// tab bar, a floating button) stay put, like `position: fixed` inside a scrolling screen.
|
||||
fn origin_below(&self, parent: Option<NodeId>, pinned: bool) -> V2 {
|
||||
let mut o = V2::ZERO;
|
||||
let mut cur = parent;
|
||||
let mut pinned = pinned;
|
||||
while let Some(id) = cur {
|
||||
let Some(n) = self.nodes.get(&id) else { break };
|
||||
o = o + n.rect.min();
|
||||
if !pinned {
|
||||
o = o - V2 { x: 0.0, y: n.scroll_y };
|
||||
}
|
||||
pinned = n.layout_child.absolute;
|
||||
cur = n.parent;
|
||||
}
|
||||
o
|
||||
@@ -115,7 +127,7 @@ impl Document {
|
||||
|
||||
pub fn world_rect(&self, id: NodeId) -> Option<Rect> {
|
||||
let n = self.nodes.get(&id)?;
|
||||
Some(n.rect.translate(self.parent_origin(n.parent)))
|
||||
Some(n.rect.translate(self.origin_below(n.parent, n.layout_child.absolute)))
|
||||
}
|
||||
|
||||
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
||||
|
||||