diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7314c82..2d1dd07 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,20 @@ "allow": [ "Bash(awk NR==26 *)", "Bash(perl *)", - "Bash(awk 'NR==26' \"C:/Users/Arlin/01 DEVELOPMENT/fenja-bifrost/protected/timeline.js\")" + "Bash(awk 'NR==26' \"C:/Users/Arlin/01 DEVELOPMENT/fenja-bifrost/protected/timeline.js\")", + "Bash(python3 -c \"import json;d=json.load\\(open\\('.image-slots.state.json'\\)\\);[print\\(k, {kk:\\(vv[:40] if isinstance\\(vv,str\\) else vv\\) for kk,vv in v.items\\(\\)} if isinstance\\(v,dict\\) else v\\) for k,v in d.items\\(\\)]\")", + "Bash(mkdir -p protected/fenja/board)", + "Bash(python3 -)", + "Bash([ -f \"protected/fenja/board/$n.jpg\" ])", + "Bash(curl -s http://127.0.0.1:3000/)", + "Bash(git add *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(npm run *)", + "Bash(claude update *)", + "Bash(git checkout *)", + "Bash(git branch *)", + "Bash(git ls-tree *)" ] } } diff --git a/.image-slots.state.json b/.image-slots.state.json new file mode 100644 index 0000000..c7b3769 --- /dev/null +++ b/.image-slots.state.json @@ -0,0 +1 @@ +{"member-2":{"u":"data:image/webp;base64,UklGRjY2AABXRUJQVlA4ICo2AACw2QCdASo+AT4BPlEijkWjoiGTSR5IOAUEs7cJqAYmFDWA9VjL+/N0nleYbyLTi4VFws1F1ydgPQtyl9tOo14X55P8Xwb+e+o097+DZjuAH+r5zceH/k8bH81/0PYX/qX+r9Yj/Y8w/7f/w+nD///u7/an//+6p+1howJwzgGzeYB6ZVMAccmhwI651N1jns8gmWQ7+dCGf0ZcgHVjszz/5QVPne41J1pGdnRlWG+dP2LWyNA/ncEXLNTQd2T3Ku/ehCPE8fM261M/OCLBnmgSkKqSd75EolS7zsrSixlxKubTBBbFohyEn7KXdmfRHnD45q6+jbNJ+G4pulD0wULtPoOdegSCc0rJtNfaCNqHA/B96Esp96EVH+Bhc4VSOjQyh7yhOUYtvGHbe9fswOHCzdG5F8Xov9Qd2HZaRXOGBLdNaOcHYUzk2wjWTu8Ck45zACrCdEuuztATwyWjUj8mnSPt21vf8BkjFTRsVQbIYPG3uI+PiRZljLSnEClOFWU/oGnvAGh3zn1ApVppqv7LIVFfYeFTsFDyN0UTp+6drE0nJk86tdtxmgYxPcaINjeSFtzb7+BGMhxCnvMilAidtwx2aVuC7q97tg1YI2vx80ooxzaA9DVtQHKmaCVNFZ5k7UWqnOFpYQyQSNLEPf3Ik58eS+sPC3toI+zno8yg1PieCJ7zJvkM+EWETBZOfhhKE0IIs/UL/w0nn+Qd47zjXspC9V6nv6PoRt940z4KhjNHz/ZidabHTMwftS05ibD8Bn953UOhSEy5EC769J13i5bsBoeYZMZz6AOhItvGVWw03lwpFTJgvXEv0A5dM6tedCcivDQVZqU9vYzcXCXp5Ve+Vs6etHLNoO5cvmCZ+wYlwNj7aWCJMHL9q++ayFNkCVWkarfo4LJ6OyKIZ/dWp8hY09ALrFfhKtRzKXRkPxZxdNU6Zfb7DYZ563v1jbjbvJCRTu6wkBAFmZLLACY75BYTQo+ZOEmJ93pNomP61Vmo9ZzmZmpTsiIaItZffVXQQd+NbnNrkSH9e2AuVQiCl6MF244sPu+ZhdfbMEC9d+drhaEwBycshiXA9QJfmzTiq5nMI7Smeue4UkFXr4pW1YosBF+SWlrlL8J575bidbKp+SHsO3t00BheKN5IDuQ6n2sT2sIj6fQA0US7dpHFP78lzGAhIiwCEEA+ss4P/bWioFz08yfK5vr9oDoSZY07svSwixNE1mvH715YeWGBOQdFHYwYjm0vAx5zxxAm14ALifnjVWNu+gvueg5jhEPqtjbZkxNOUDevbYvj7PWBDobpDwRI/HmDNgYgEeFylOdZeZ6gunL/dlg7PHiGDQY0ZYaY3cFXBPaKnxkGd3l+Bbc1pMnshX0CQbYDHd946rRkQ6DJAogWaduIEvzv39kW1NmlEjOaXlCdtn6zFRcod5xSNrdSWhCC1bq4X5vzk3+4MyqBskRiQEoIrxLHqXtMDYODQosNoTyj9YeUqb1EBZnUaTUXHw1vTe8ftkYaiWmdBl2m8ITwy0ich+6lL9GEEGaQjb0sFw5QcnRz/dboDaYAMndUXBptsXrc6BI781Z7p+YgZOyjeeBRvUBeYUGRgldFyInkR0MZPHM6YjwClHZeGCz8L+iUc/Zesb+qt5aS/zzLRHkqobH3emwoGoSnAl14JIYC+OyV2/kQyDf+yn/krO4fySUHqP2834v0meOkPwgaHXhExDZmESK1MDxbs/uvrmlKLOzaoncYXrwCfSyc2/X7tvv+QDXxdEGOHZmGPOgJ9IvnIzS9xL6e3ZA1YOwEdjm3tiRAxFZf2hKn/nN/VspbqnHhOhc0kfCCHOF72AT+T9Z29N3mhgvDQsL3B03eDbUi2s/xsg4z1yMafQ5IozvM6FAKbRC9CtQZoyzvbFxaaHUAJb3V7El+Us4ZOOWC6QvVnGeVYqeZrGP5NHXSCENwU2YQrryoLcJxpGZPLYe02Ug3tOWW17II+QfrE/udbcdR87TrKeNP6dJhzkdzsQ6iKWK3ZCGZI7ZiP2nOgnRj4yM4OzxFiRtPA4sOkBblStIVIAP5Va17Ud1GVyrkBA5BRxEI053B76iPHRRngvTfHu+25gPeXULomgoCXi/DJxqNoHxaEQL0ti78VJLjdEAi9yb78QfVB2LHCYi8m2bIQNE3TXMrrWsyH3Vo8ZzJBGIbdd1soi+XoIHPoMi/0LlxTqEuYf5Fbh66qXPXQmKtG9PGQ7vEOEBKLPSRMjDZRXWAW55Yd+vXTTJfbMU1cJtYvqxUrTP0Or/OZm+7yfwBctN90LipB01NduzR6rTNSGk/8SpQ8PjdBiTa6Z5H8VQAAP7z8pxDZMY5F7t2+VwtC2CL/3QRQEv3Hfod4xNEyRvkU6LWCC9KMn3AAYQMTOGRkIk0qOgyoU0IgT0ElXvXG6kh3fnlVJMWlBhQ6CKUmLYQbfACxePOp0GavPodvYsy0LXFWlLLkqNbkD9aQd2hzoVVOCgoK4llJQaBv9byQ4nc2RGfB2/Cn3dASsOj9B8TA+HbRx0pv6g7otAkGw9VvzIVuDjsdusL0EsTqgdmU8aC/K/HPfb0lkRgzxhlIl9DIvCzF0nx9N7mmYrB9SCGlG+lItJEKrRrJPewUyRCXIgSSfCBC+6vq0gZWIY1BKM5oha/J/s+zoW9Hme4609JP/6BrFzc1/dbVcE7rrm20hJhdY/sBh3wkUtQyywzWfXNEkswxEzPSiAsD/XnbGI6IutVTpm4BZHW8fOnwgYYpWw9LsqRKUdTDPwDv6hFFzSoofAbATYBqYAK6bSl2TYNpnxtrebBbG+N+x+dffE+tLIh1IZoz8DDkhUKHsXqis3sJ5HlnVSm+TmubmHQnfq6+hXAc7MhAOiKS+t/Tmcx+3izvuSZiETPtyH1Mk2jniK1G6/ftctXXZyW05g3lPxAMH9c84uBip3pEquhaBlSTV84yHySkBGU6uIgjxceXm4cBGiOeVhteILPhwwlZ1nBOpnDKc8BF6rVfoG01iCIakD+kVV32KlMHxC0M8yZ8115dOQ83fnEZTfgOg9I241vIv5Ivu93QKMlc+Ie8UzD7RA8rjcZK4hla0OEyiXxaqv1COSKIkCe9nZ+8rX4NI7NmN334z/atcTrNLAkPWny70LhADQGLcM9ETJqCEXctZxaLNZg7tDbi2FaANxK74wHFYur/+vU4cpWwjDwwCumoIHCMDZ/3wGh84ArYY908XCRTCrudkKIii0Y+uhPI+2QRNWuF7dW7IlWCbvh4RjBwt6nfQWCzL1d0u56uXgkKo4Fz+xXOHEg6HIXMzxvlzPJOfLZ5Q5+LRTg0yo1zS9DM/oTMvWJvEOrbmqZaQfqU4CugAABayDn/1Y/z9xfiapaWFaZXV+vHGIqZK3d9XHEJs0mcLSIvcrzkzyDJdWgqXjryl792eFNJQjF1yvtaDU0Tpt3Tlq2/Dh3ExiWRfG90zWYXy7i83VQT19YiEijK0SUsOMNVq/gDhKDCMwRgSegkeigAmI7qPJRm5gIlMl+0vxC2aa/t/Qy1aK/oBwFJJK74Z25u0XgiPPC3wRXtaNLLwWP2RYpseroIiTNn6rpUnwKO/MNTqWVFZMG1ZwEN92lrJD6ojegVko3XeARIilr2Et6AOwK8W0gD4KYva81/cxLcMVhKb0WtDLMDoj1jD4XrvThw4VYmALEdX01D9A1tuOFdK64b47vztodygMNDH/0pbBFesjakECoJMjvIieFXsjVutjc8gKzgEIzliEclXW5kcF18pKBgnu9QuqjNzmKBH1bhKx3363aXqCvisKxHFghxPtuxffB/fG/hBqo0mZKiKaTCEgQ3GrFawbL4PaIXTyQVCNUWOvUANMHcJ0MLFfnMOgaHDIoRoO6lC74o6YTjQwaWbCAX8AAHZP8B1OoM96Qbb+bAA5u+sL2MwgXHCWqDA90xazGAPGXHAuaWA9PfMuz8GIyiI5Hi2ColO6bjcr1QljLYTB6wcAlxLSAQA4tNz0/t7WM1Vh78fi7tBEEoqVQr2kD9Zt3ALSdqdXUsczs3OdhaWlUZlBaM2NctRjGJD5UYsN1aSt8AIkqgjz3EKfvy28kMIo9sStDPBo3hKc3hkKwYnYuLbqEaOWfn+eqz+/ibhXFoz5iy7UYn0oOt4cSrnwKNPcJjxnSDU9VdPrE4YYgtqcpJDBO/LdbnUL2BRa1aGPvlhoLF2wzCjUxKZyX+NziIMxSWqCCpH6UdLh0k8QjR3pxwyLLArKtx36UlJ7HJFTSdVbhDajRNwIOKQeGoIwW43nv4U/bMvR205pWh7V2IdCM0n7ulw0aAamdvsPOiz0bUWd0qhuC577EzyHmOWySD5lBB3O/EZbHUUs8+ViXJ+cUGaTJkp6zXcyk0Ebxn0TpDPSmuZsjNn9btRXJ/z+7FI6Ct9ulTD5kV8hcuav7navP21X3t/mVolAAiBnyrmBnftNsZkt+zM9V9+1QQZADWKzB92AdMIBD2qHMK8jqb9QE6pfZLHTOv7CMl7soXWACH+PdPO8D/FYEBCIYmJUq3zErpBfX3M8HjILA/pi3mkJYE5fmE213UgS4JSlAfgA2s7iy+bx2ubKWf4Q5y6lFfJ2Wqvw8LWwSyCQI0MpacoEzWaYjLeMwvPO1M3Epi7OmeEQstAU1JGo+dkQvDyZ+xt/t9PNxPWKYDHZq0ciaQ9rEsEK425iPsFeKpem5DmxGdc6mNqIUNZ6sf2dcirmOPun1zI2UQcFgLsD4ZUTb9zcYHdaXOeEcwQmaRbVO393Zgo5L/k9b3lCfRdajcBi9NzDePmzRVZ04DAb1vDfoNELJOmI5JHfem0t4PoPm4nrYvG/X1JVbfBTVw1k3V/VFIzs5PBph/3Nihj+99o1fA3WVB7m0zw5IeFnO95HrLiy56Xze0ZfFVaW60seGcaOd0dq6vEwYUj/0BiL0Uj5DAmFQjPCX8tuStqgtRq1TVm9EzXE5X76EnVuuYZj72ALZ1xLSmZNQ1Lni3GrqKi0HDGvPzNf1VFPFTnfexUpu7aLl5Mw5JD4uhO+Phzc2PITKKJp16wtfzjIyO8ww7Ln2TVY59oB4WuGRztVJtedryLD3Dna+9tXoI8IglfsU41Pb8Ads1vAX0fKS3opgl6f/Z3kRyr2QswJB6pNhJMfe2PlY4QwuPrmSZjPVe+SGiPBgdQLXitueRg7LGC3teQk4h4WBi3mqm5P0xnWC8IhAOWo/guvpIzz264zPtaAWG+30Om6ue6u5x/vGzGsK8x4RGRQwXFnUkWBKfv2XQFhYWxeokTxdG4N58AYnqJO29KBhF3pC7Ra64jdJ17vePr7wtIGct/4e5bxvez5obbLyMY49CTuJAiIsKoKs3NieDlFceeDbgx80jiwlQRr4pqYMoHTA3CAcMR58YwNMRLZezQOQ5RJa44SgwL50KA9pX1dkzEWpTBd5oKaBZuXm0//rZ2iw7tzrFX4g0pHujBKAEQRkmy1b9J672+MFz5t/fIzLPHw2wUTb0YEg2lpxwd+nDrKtdeHtDhtWqtpYEDSz0gYSmbeOfIK5orpJjfyHjs9QqE9+bWgNkmodZMT2otuODxMpIpPyqx3QgGMBWlxD+QTXg2/Y6nEWOYdAeNZHS0z+GvhKd3VKbbyIl12zJcCefdLDYqbOwz7tgoCMgF/sWfO73Nq6Qsg3fDl0Xl4EwGUQeZdxUP+XQBDRu72lcxHbobXzDkGK6kZv7BP2KUSldpINKR2HuVSVy7ldDfWb4F529PAjUs0hgatSXM5d5z94/cDRCY+9pafFh9v0iievjSPafEEP0Nzoia2HB1zt1e0dYV2fkVg51poUx1QnAHrufPDIiRU/G0oZ8mW0otgny+Q84AYwL1e7e+Yhr4/5uTk2/ToG6j143oSAzg4e0o5gqgRpWc4p9nI8FWLJev0M/CEIN05ZDo8oS8yhfIavomnuvEL5yh6pBLeToHTL8qbtL3VTHEywF9njFjJ6KdK/DcEbT36mNIiCNPMyPmHHVuWTeBtdWQ0dId/PeOC2flXopOckTmN1isx7YqxuvFftR7ZUZQVCEAHN88Le3M+018bcSIu3FmzQ6EA8BeBct+lBCb60b68QCMu8sjUPq5xn4Lra6ukF3ncXZQqJSQYEBibBXoL1xieLfMNDJEsfl3q1y3L14wxyXPXTeuDJWnUThzK0sOoO64/b7TCJKYYiRIImhz5Peh0ezkEB36iaNVc4CnzwL+xTtlP8csjvjfjKsMS14rIo1KkbQCj4XiinnwHSm2jXK4H8DFlRcXkbUJTxD/kAeUjbyTL0gOnclrilF9x/nkpqzI/3CEKxDC4Bk+BdjcSeP5NUCiunvYBjjuLXLcy/OV0YbxuQhWOQxZG6Qh6+XHGGdYe+v0gSa6BNFsvmxab//z7z60Dh4yIsV+bg82Vgeo2ndEL9W51dQXBvqQwnDttJj0FfqfWb53HE4IgX6byKUz8Oa6LguFuA+SrEqnS36n2bTK0a7WY1YCZ54/+IPAivjx0wsCzJm6XzUGfvA3EnTRJkRjgkDczOJwbYp2wxdMjucFFZtUFGAv7EnhqiAl5N9TSGIIJKe12Oe1cdDSEr6NRDvxcbyQqVIAfDdF+Er7TKfw7ic4Ay83tk9i3XYf+WbhcAMsHAUqpmsvG6NaWpZKkd+H9shqrbz9VP+au2jyCXdOsi9zRA5TXwwiMcdX+ThBPVrl1khWlsm/DHso0pxKT4kNZZp9ZID+AAfL46Yax4ZYZ+5Aj9sYLHIuLM4JGbgl6eGuTzK/gzZQ8Q1eM2R9r+meuXiteaiZ4iXxisPMPZJHEsjkITSrNFtJHsyfd+zzZlmzmmeKszyesz4NWqzUA6MYcUVw6jKB5OjZ6GTDhNL1W8zqvMI2oxIcBhl37gxKGnuu7b2XK+lI6hs8SeYQcxatJb9TAqBbJu+hnDjJVCVGMFpn6K65kBKBgYoMrrH31XsbmI1UCdfJve9m4NlBFhOvSa+pjeA4pmRf0Bsc5XpVR7BuC/Z207/F3TtA5T+0QqejoPqPBlBb5Ouwlyjf2u82sIJlrgM22E43s0XFDityP79MTvG+lN39Ix4dSEdICOVpYwCr5aD/heA9JOb8MQAvA2rxVCZ3qmsTFp0vUZmyjXF2cP1WmVciNY8PwBFy1Ro0ILfnN8DoKxEnm+BwGl6n9cE2BX2tzKKWePVtE6BCxZNywGdi1gL+r/TbdUR3Uzrb8kkO1nqrjtL3f89HT1WpAu7VwpWMWfTwvBlixftOaIAXerOOXWU3Iq5J3SW8fBAGXqflZPJDcnitvuUVGrQJKvYIUYS984MNmR5EcGwYT2SvlOeZpi0zNPKlVJYtzj8ytwzvVSOnMLYVIYS2XjIRwBaJkHzRzBnSgZAFs+VWID7uN+fKkDvtPoOvJFia3eUTdzTDt5d0xj2cQQBNwlEn/NcUD1+3JUSiSZ0KbaxoqvTjtwJTwDF+Udr7LpyGZiSyZxGHTZKQe38d7Shu7mR1vgPSiX7oJVtjjAwQI0dTUTNBEa9HiX1c+XmoO5DPGr2GC7qL0+yuanGvh/OSJw+b5a159FgEyO6haSuSjhqDXiqxWVaGu50ZW6Uqji4CSawJq/99sebg4CvXojNs5TcNeoX7kWy8l78SSGpKqMfZ644jVXckLeV3r3H1zUtYkXXAN8agzgetx3k+NGZio4j9L1jz/E87PlPu2AV1LcTZ4KeMksCshQAPBJKUDK5/Kt4cCrR3soIvrKUJxL9wK6blx28mqLLLrgLX10VDkYybYoyUImVwF/CtVB2syBJegBeOpP5WEF2hbAhidvvtTDXiOj5Q/RuxxIXjVqMdSNdoFUMczCeP5ixkfa31oyYfukb5e1njRuPls6N1K++zCvQR+mnDZsOJe/0+OlTQjTVDq4mHHUOXhT0zcABbkNvsBwjY0rW/9h53JT/G/859LBLSJiBqSFM8GLCQkcywAVLfubwd+mZ3SvYKJrXtw6DHSX8pxbScZeKgSssFfGeVsqLjMHy9zNS4Np6ZbLt3syj39EUIkUkvWkCdLb39iG5SgBBiLd8+k/+t45Oo/oOAbosMh7Ph0Qx/qU6E8XwxW1aWGlpVxYDlTeWMYwmZvdUIePmoc6O2ogfRVSy/qD7l0IaSXG+7jEEkkl79YPpPjaQF4mT0x/5AG/ivJygu45D31411EdS+LMb4WYaGKYXsUqNWtOAThBzKuS785I9QzCfjBw+AxXi/Z8Y9ia1OSl2FHpg8PlXK6tOn3QBevM3sNitilUGqZ2coF31OkVGqZ0T2bDvIf+nJugjTKaiCZgaSQZnrwPA/P22b3oIqKO1mjnC3ZVL96PtVUUAHuAlvDGuf/7JFukaxP2QlpJuOYKXyDfgNZBlc9iTPYRLBiUJfduBSWlzs4s6ymyOeRZ6BVCUX+f1k1TZPhNF/eKgjEJQsjH2vBObM+bN01i2j5qxZ4dVcvgzqp6DmrdrG4qHG0GKdk+EtN1skrvgznpzuiJcN+Z42uBi0yonT+hWwjdGXCtJSwEC/+hNyrQLPjKEM43ByajkGWmKX924W+yMBrcEKdx2UuY3JV79Jh0g600oT04z25MrUHsdzgTPnhwrALZNTl+ZX2F69oghQ7FPoS6xZvbBeYW+zY/+9xNpQQx+fZRuo6RQqgQZvBOM7C1vHxofNkMBzEV7IcJYeIp4lIVjkXi59Gi2rxkx1VgIFrnPOrqVCEwIpmOexcO47+99WY0lxLB1V+2kXEZUfvgKgP8rA5PS4jqE7CXbBFTlS/tddTqWwDsgP9dkLW9keSD/qqLiaY3THCsYxkYAQ2DT1n8gKg9YXgZepkbp3Wfiax6xdQbovcWx9zWUBn+6E7fnaevgxg3NlS6/Tc9FAuMhkilRwG+4S59SDzEsGHF7NYHALv3ywGnJHPTM7TI64BBXdaW6U9107zhrasDof9oDbD7pHD6NBUrojUxKnd+EZ8i/2fhxs/eFz0Mxa+8UdhR5qMF8xpYoeo3Hj8pgDy11JEGto3tPzoLnGfIN1zS1cC1JLSxD1PFprfHK4KYp+F+NsxCq/yutMEk1KhAw8KpCi1gYAU0vuAWTJgJUScRKACNB0wmcbk7iNlzEOFHLgLg4jc52la10ui+uaNLKs4aXjfPRt4KJnX8XrxQjABKfYqiyH0vlMuWf7yZLDglqPAl2aNjaTg/aYXNFYnqtZwA53wuYtZS/6ztjek7D32tc+esTbFFBSp3tZYpfMHO3cYNrbEXfQdn9RkECGBZ3+lBJc0ZnDcV+5HW+BqsU09OEmMV5zh3ILgj3K7Ach3FPlB8VIW4xMPM39lvau2aQVH0gVcTvj4rU+T0OoVMsmyYuV/gCW48s4/kP/Tvcqx5PVAx252TNA/ZZHaHHrR377tOe2dMxKvzA7pQyqKcU+9TavqcOULElPbcYf8GOfzOQD5jtQaxLtmrXarSBrw18Upc+58FhI0aFiH/51cIroiEmAwadPfPdAOfgWtsMrGDiRFBnuR9EL63X+IyjvfkV4I1s0WoqEv5nyj5f7JbeU8X6/XeGQsERV4z/V5EsCzoPz4c/kZBgaNLbrdCADgA3CV2sXvndeTuOEDsqLYnAGzws8WJoGEJ7Wn323krmu3CGHJ1n8ImNos5WobByP6gtR/N0/S/A12XxKnzLR7Fdp6lXH1wsPs+9pNVkzgkIvKqSNIIIpAu9yGGWQ3H3cx6UCZoqyS3dU24jIP9Cyqtd8EAUW5f6sUmzrKWg7ZeaPv/wZxJxIQV9vsB8O7yZ8IY2QI/zqdWX6iXN2+UVfJPc/xnB+XpiwVcJHLRBkx76hZMGC8ZyQc/ecpXZ32qnQKM6Ex08/jNYKtQYFNe4lB15WFsRuQo5CJpjKvwEXJkV6TfPN2mepf0dtqD6YfaA4uE/eKsMG8jAbTtHFG++7n9XjCNrlcYU136uKisjBkO0fhWTSl6aHK9x34nO1oXJm3Gjm/fxhtsivSJHuma0heouUAl52pbmaVV0qfCGqUmP3WIlzYdjCSPF24ynuEPbhoboX6m0cQ0Y9b3KB1AdnD0hmzQwMVSSN5qo6S+GtRz+EK1G8a7DBzNC14qbKNz5TvgHmZZ/OomplvJa+HD+UJvu0Uib3w428ckUUccRr6Zhu5W2pZXgbtckTVEemEFPgIWFXuu165M33Ciaax8HlKW4KGfYqF8pdjvWCII9oi35JM2G63DN1Olzo6AhtqB4TeHuPB6FvgBMrCLHCj1izQTBH86/pAm/5IkjPAsxgxGCMNk+kDbo+g72+z+nqvtAp/CzBjwmOjd5tMlGHhOJ73/0pRA6syxm00D6BlZ6yKaLh6isN7hrVs69w63spCUjjekp8YIgR5bdrpDOk1dBd/k2xF3SLBc2VmuZLX4tvok2HYL6IwVQAHI1E+ijGhV8KIP0LPL0a+fIxWFYtxwnA4+hjAy0dV+pcXsSAX6YeHzRh+KVefhzEKeZIxcwuv7qvBS00mQjRc0LgVuObp9cVJoxXOv+Kr2R9tnbxuCyxBRkS3Jhde8ppPTPLlLjfqXrVW2Lat3n8QC51c95GVl4yR2TZCBIEPTqqk6hmOZ1onjgccxgBDzvy9Lgyv/d7h7NfdUXHHBEkhF5oCuo9PEnrwMO6JoRQyRm8HrmbCWVaprWbb9zKYGV4+VHX8DYTtAnv5DDwR1zk9YCiRHMHW/+mLiroCWce0YoUub4zNj+TQt69uIOgRwWqncZZz5402GwOQijNImiBYuSU3TXVril6a5Yg2RQIiSRegWQ5UGAhuDu/0ICp60w/wHhAkZkao12afDleymg07dMICndc/PHAdz/4qN7EjCde9ZWlNd0i3+UUhMVuiuBKgCNX1k/h1bNXvQnXNlr3Jg10DtYYF4dES5P0Z/pv0f95pFAj3wpYZ3HzwSA2kXUaKxYq5E04ZFP3iGkaAI4CQk/8m/Z5kVtGgXuPAl9HnOcFL1W+V+mMhQ2GS3dW6EcbDagMv8MzXZA9bI1L843c/DXQRt67bxTm3QB10REgVcmu1oj1BMksixk7aT/Upo8nx6jAL39bkS7dGmMECaL1DRX4R44f1T2TgaNpHCCo+WpHZ+3X3+57xXeWAwxcnBugSWhdv/FO2WERj2hy8mo3zVHXpIA4SsV8hS/JB58+QxslvoCrvHLrIZdFh4kKjNTL/JJJ20ME96RecVbiVu2FzdnusatqUM2vmxFvlIp0r4Nl5w76LlRUZ+F50NPqVAF0xq+RXOwW+nW0WMw8HyBNmPBCp4EQ5EXNxGRvRiZ9Bz6IQFyE/iso/tyWnldZluKyRKwFbF+GAiZSWZvCiBK7Gs+Nnbh/6oqnqedTMR7DeOZ0wwn54fKuYStQBRbGuN7xLkcPWpWwT64nBEYmxKcM6/dFi9SWXH4y1rgM2xE8B9krue2vhAwvw+Pn06XYojKc5ntczHTg6J3nvNs2+mObsn95kFw/82WgckkNeTJhZwIyakQdGPEAjv0Y1WrNfV9eICGQ2kZy5j7in6mg+L+xQ2sY0D8Za//eHG4dUI8A1NjtRyd4bnz6EKCecRY4LVaeOoMl3CCqLhkQAQ83pmCaXN3/McDwZzwRM+JQMmiEkWlToXbom7f3xvwdUNbCMLlPZEGe7Sn4yl4Ke9wyw3G/JsuFMB5dHbhLL3uHyX7/IVn8gjA3N7MS83TqBCLfJjKkjeinGn0KM+Z46+uBVmmq8fHTSAkh4DR/zvRslJ4XXeHvD65BoETFzycMwMTNkqagUw7qt0J3nWwLI1tqHc6V6L0fxaSm6Xd6JL5E5z0AYy79R4q6aYK6n2R8v3E37Wpo9liBoIAvOF0cdVnr+bPllYrRzoWFu5g8KjBZ2x+FHC7tSlT+LYV2c69yiMHF9mZUa0NffrmHSCBozwqGYt5lHyx62GdQOhBTXU4aq1dtLPkmkvWdwS0/iQ9EfbHW7+ZZXT4ASxYOu6TuRUKvHzXRKEshopga2DInes5UxxMORrThRH+NDskngFXQUGjFEHk3h4kAFZsagiQ8SFhCyrJaDxX9Q5InOqzbyXlC/EXIV2R7W4utCjatydu5/um3TDVQwLRiiTYcBfWFUgCs7gzQetfswIDLssdPy7Vz370InUoDfUl8qaHr4oVXB+o2VEbLJ7vYS29fZulgTOtT/jAJrg4tJa+s3Q7fHhkYn4f+Fx0fndvtjWzoNaM36ZUotOP2wAvAIv3WC77Y1re/DAo3Qc4GpWVZSCMcfJrz98X0iQOqqE1hobLP96hCj3bDL83qgEtWnjDqQ1S1avkj8w6uRUlMUa1CblT3fONbYpUa4tWjp9uxoOoAtdope/9rl/1S+bbNZTZWNkuBSInM7nZ4G4Bp8WWl1MgE1rV8Ur75SChi6jBv4pmMTbH8/+nG5Hgyuemq0lE0KGxdtDtR7SS3ktDyM8A1AkZBFYwnwBTXrRoNbj1MRERXPbzQW/ISR1w1Oes+TC2WIQbRwimV1lrHbPtypm7H9NWUHawTZ+BV9K44iVhPtjdxhcPCK5lvipOvMuRMvEKzxWXLjCQ9KhEmAvFqMmiRZt6FoB/qUrZTKITGEOQsb3vivXFS3OwmbBFv4Uh0zvsEGrC5ETGzJUuxk/7aBzP+lHNOYlTkpwrjbkCU34n04ZjfcMDgrTAzK+ZjzEFMgS/41XbBqP5/Ovskn8NU+CuayQlaiXouP2sHdQ+DqqUijyHwziA5jeOPX70MPRZu3K1JVkRq1Ek3fD9FjbOrms6NxcjQ73XxvIr53BPZqz0SMXJpUJQLULHAFadHQGe6GwbbEt5rrVHYZJjqjkGm/N2QMwhxVAMU/r+4k6u7dA/ajuFGq+jTUgHbUtVl5Ya/Xrb855NbMK3eBEjUgrUA+MomSwRa7YPZRsUfyg7vvk/1QbvCaQdSe0XU9WpTeI6d2RgLIFKognSYe8ym06ZlAInloY3D7y2cSodqzV/fVgqJdRCHs/04fj1ZKPgMW0a+cMOacoTCkCXv0/Fpa+hdiYrtf2Vy0csgmw3Us5oIydvES+2Fobpv1i0NH7Itf2llTgFYXj+sM3ued4Hu5UmPr7J0BX33CsVYDJa25XNv64aoaM0fzYFDXejnvl+ky8r5TpXNNb9/+xHpV/rGfRXi2mzhtGMST14VfFYHq7pmY9fUC5iGqo86w3Di+fBgEWxn6GM2FFl9hi7DQFqGNV5VwH2pZFLFunRyH4SP2cDhkxLt40igk5PMw8jMLPP+C/m4hWH/XZ206mzK6vYrvNpRvK1bz8XAWQWp3SWauP94iGvVK6yYqLPmfDYmkIIXhNEkVk2+qKCjDbcaLu/9vagv6zAaW0F1dwJAqt4xD852C170+Rkt/lBpFRlQKGyYSUZRftvYsCxI5dRblz/pDBpQWdThdaqktJsZym030Tjg8sWIxPx/QcS+NISHssOPEoBzUkvikwl44C8+Yo/FV9/3OxiIDpG5b6rtoQdOD2PoPF/j+PDxWQayG/HLoW5+gmHoVUpoGgjI0apo3dN8do2ijhFhcfJquofxfMvwneSHEbT9XBxvxgfNorVaqK8pMrj7apN4VM7K0Bo9gJ3sdce8Ue4w5zEtIQJd2tjAoMdTVBT0feQ+p0I528bLwok+b5A8MbevDOanTeSiFAGlIyMhywImdV713EyeVfc0a1rFH9L+kLlFj0nYj6dj201zvPvmd8id5oTclpKIwljNOB4V+ezySeu4eBo6dL9Lsk3be6Qh9WSUcB9oh+HEAFkkCz4+CL/E9tt2clKuDA5TrW6lgYGyZLUagVgodrO/bBAU+JXyXiyngqNAffW/GBsG7EMn1JGyNLID4jDOABQ+1OaF9jLUP7jmnVjKGKSNlsKDy0JrpbzioZ4ORTcMHkz1zVaKvtJHzZ9DzUVXc3TcwjJVL8FcJkue1jD7m36HRvE34lWXws2gqdm87J7SXeDgDtecHSbFS8CMYwuzBpDaaYkCI05AePjQrm4Z7Y5crTlQUC8vvgtQdsRK7E+SlCayv10tDVb4EL1gxdUYXr1LcOEj2RcM+qUbxtYA+lE9HjW9OB1QJLiIHTzE8lIzNZw+lm4Ef+nYjkHJIu2V7qhTknaxReQyTJ9GMEes3XL9Po1fRXocyZGWZmKwyLizbHCQ+3NHU2Gest3Ph8bbg0PFzx11y3n3xg3MSCaUJBURl0x3fOfQY9+78ZWhu7dvW7MGSTJWF7iwhrovoRqNdgbBpGU2K5CB9f/fDzTBgxebYQWxz7G6/bJrE/PIjX6iVVYMihQzcrLQ7p0hyoJ4s13R6iH9Kpjvys8V8QM2TPsvZXniZcmz7otqtmB4TGTWvn0RYWHhqNK/kaqT/psW8ySss6RFj2tBHZPjqqsZhJ+SgHF7x8Z8IHBAuI7aZ44Dyj6mV2ZgiyJK4U6JKxSeSa7Ch1tdknNAah9gwE2SvUrNPYeNEVC+8vyNlCj5xIdPi/FZyCHCycapY0/46PmcWGpyyTsn7IX+delUcbHwjmjzv+QplqiAkscjCatzFhHSOTRVIWaYAjRr9xFbGNJ58AJOjOCLhFI29ZYPukx1sDrQrpZ9x/xUS5FpE3gCX9Cnalftd82xuiPoF43Zu2ksceoU7248vYfO9MpfS6xBmdmujJ5e1+LDEhD9PgvV7qgbLXioWBXcX/sHUtCMdmCUA4tX/I8WZQ3o8mekgRPlWdyuJggeFxUV7R7kICC0g1QbXF3ROcWUReU1hEfShrI8EF5Fqiy226v9ja3wa5JM7rIpfagIrKzPLk/nYhrHwy5gxofACLPB5cKgEWS/ifccHxTWpyMH47IpjP2iXEfUr6Shrfc2R6DebA3sjuT0a+auGcONWrA3ehACyWBoxTg1NUPwW/MUqhbYkd2Uv7OzS3/lFHZGdej7/UpbvC6ucRNOPvFydgWyibUwuoQpRowvt8zwu6X1D2W/1SXBjDBM1H8TPzhnrsZGA9TwEDWailCEtDJR6JPBwbYmIwjynwpciKZqZ4/RptiHmj5oYneTSPl6rJsEbkJNZ9YHD3LrgdqFnYA8bxTbtgkrJoaQ3zwNZLLTRAAqnM53nJFzU4ukT8CBmEa/TVEDdlSQbuOnlAMQ78RWBKW+5qq3brURxEGe7+5ACxAtioRXlje+E+lx+eCBelLc7bnPcVkE9WW6Ys4moCyyK5kXBjXeBX3NbpSJgQpMjsb6RQMod3mrOJL0GFTuvI3U5XLWhUZyg3IfdEEUsaxr9gB/o0IZ5vOt7gaUHsX/BFrEC/uyOy+Uz8rMipgJghobKTR4bLAd6wc8dK3oiCqZR2lcUEJNTzv+49rnZUjg5mpiN9dcinFifkoKIctLouTTtSFRhQKOJSGm6OI7fh3Ltbx6JJRqxYHWm3RU+QSY0WjXq4V7uB88On7onTlM4++wWW94cotiCzT59G6H7xiqBOH6MpNMZzWukG6YnvBA2BYizRMSB8dVk2MfzXZVFQRwk72IHAtrMOrJbNNUmg8kdRwzIDtbv5ODZ6IfZeYvhGIYSndZeDHcEqWr8PKS+EI2S1bZCfqJtAzmvem06TP4r0Q8u7qciOJbnmoAqb3o+LaWlMhKIayzComwL5F/fd1ueHBSFQOYuAoRYp9I6lW+rPcsRen7BQ47dUVOVsoHMzpUWXerMf0nO8UtJFeeXqEvUwEhbOzNaqAf6bIZDJGvz3SZ8Kej1+6QH1++MJRgu/A68ouaqiQjhNGjWS6WE35XNR3WS1dVWWVeE5c4lIo5fQIAukP+3wZHaW+xR5pSeQPfwwYDvOsy0D5Qzdxqh/p7q/KAfp4P0d4S/PwbX5V/ZBIarU7to9qE8tuvGe/0052dSKibQZkPkVI+GSvMz/3g7+tWRCl7oEQbPQmdhvihHd0kkT8xy+LLG5rOSXY7xLZX9qPbhowdCAwtPiJ7MwXljw08Dr35QHaVTIAXt5kFRFXFXjTSYN3aDmsmzaXVJmrAbcEKRrRC+zsKJRvz2tcGEz06Pzz9dyJrjCybVy7bxOXNeAmShlF/eTiMdpnouGgnN06NkepJZ90gI90hg/i5FJbpMtVQhfq9ph9tfk1p6hUlpIp8pC/AAsbcP5culGNbjEw25wPK9TCSCgboWGk0PMfOeaOXxU4Ge3muZjGJlsfhCLfpuKhaJN2+ZNeUkIsJypckKOHUsw2SHOaFrUp5UEQGG/cm2jFBtYZVmt7NnRGguU1ZaSqEUyjhkIhfwR28hiYuucdWKh+j9Ocir215igwX4b+UZ3DZ8nE3o44snqW5sHK+E1zV5kul3qs+I+DGzLsbMwy9Sc55lcNChNS+nJQHbzeZrq8oorNY0y4Dd3R7DcmFaxh4UovRfx/NGWolWAAFR0Tr8SmKcWdBXLEkzltwp8Lucdp6kzNpcYfnP57a7v77PNG6QZSXkII3jFMTQD/N0cpMKJB1zGIWzQbi7zMMSHdCUcVEDVIJnePKKZPnWam0sPwqOOqKp++iQ5YmgALUuruGkTSDA04o01AT/96odAAH5s05eB/7Q02TwJtCEBPZf1PS/DYnyGVykltVvrZ/F/n6jIoYtoPz8QP+QPHNktaS003ycDKu0u4AJnnOB6GQaGV3zwU7sXFKu4fcwF9oWi9FLKUOUpyjskmZ703lMNwxCpngjie6PQtg24mwuy1TKaZO+nP+BAM1HsO+ElOqQb1YlRx1qhmrhmjuhCn+8+u0WDiqJShu2ugTjm1q75dIVyFxE9u2NsTdZKi+EAqTPirlCHVcDewQVPqQ0uGte0Gc7OD9nMZ4uTo2HbLLjU8rAJM9b+cf9eaL2dyhLTUO8WDMKQA4hcwwod4avvjOfjQ0fZSWJbDuoWOE1D+RrbK3TYUV10Pfx4DBD/RQYqO32rtdqxDxZpGR5OrPgqG4l9MPs9uvQjuCyvm8caN6xSAKc9CZ8jRqMfXVAlIW+PPWaCDXQ7CKcqtY5P71YO7fBgduO679bGXJFkbmkUV3NE1/WNYErR6W3uchM1xWjKKeXqNt2rh3lfpZmRVL1UxWsqdDJfjPIQ1a3g3GtdE0a1ip2IWrsIvG44Gjy7j2WOd9eBKqayhDte7OpS6uciFY410tzkh0Dv0wJcj5wIxjrOyxTfU2DPOAOtY0PCILl/7Qj2UHpUx5kpAOhKq7DNu+k4G4ZAvOubZ/3IJqJl1I426yjvNwECFeGrcoGvkHGclSFj2rHn68upNO7sTho/jHY+r7xpAZcXopntpQyITv7/vs7LWT50geOya+48h7sP0jb/w8xV3KkPjWG4a3T8FAgye4eN118V3GdC6a2i8BG44HmI234m4gfFb+X3bI2bi7NCRS9/eTI1SvrQ6X4s4boRjawaDlYR4+MV9PWbMtn3I9cNBwRUP9h50ebAlJxVCwm/r3ONI9/Qx8JbwUv3nj6R56JYzTTCzcm5zE+VDl47djWZfNjtBa2kJCzXqiSpwGbfo3ihqDESsUUalx7JryUL+jGpuUCkv+9llM4NO1GvF5SIwjG7FLksXV5SmQ6HVPDNhg3NHjZPj092w0rsr+hDWcAcIpwkah7XvOZs3GovUpuZL1R5SYNymoEKeJ0TmhokrkwPUeqloYU+eBIZ0oFkmIkj6F2+bgvCZ3oQEM0Vtm3dYKROTQAKs7fIYtDn7w8uEaZiJHyMe+4x3xD2ZCnX6ncDSi7eT3aIV52nhsYDqgEzWN5mkfjzKWoT/4RGD9P+GnZeO/LOlsVX3ssN1J5o5x701PFtrMUrGriv3cY1F1HpFMGgFIatRdSBmggAtjBwra5kbs4D3BN+dUP0lrs/nDCb2ZizhbH+ucRjfjYfqh1w64fraJXJdTYNJZl4APvQRemTCam3CLaJAMle8Iynwka5JKVO9f49Hd/aWJ+RJ8FtelRoj+gvuK/vR27E8lCQwKidh2e+Xgy9Fce039zLybDMPM9uDIOvQqolHVMlhFGC0DCs1o5T7fmiV4BoGpiFecvtSFvM4H2gCD3naMrGmY0ZqfBnAraTgAFfdEz7GtRAH9f+DOvTrSMOhrBuHqn5oBoo0u01ku3xhKKNxOMIClrQfimBp93peos3ogbzqUUvCtZnDeD/mnB/6z9m58V21fzatV5Kd/13GygXwAzEIAWxIHpr2fhKRDIq5hNoH4t66PYQbllsZrj1tlexX/7CgoV53iQCVgKSnoyVqRCWWYe2xn19a8LDnAEBugmxB1e3ZCTNMX9N54LaI5Jc8BvJHLwm9svk9LChd7Ye9vrGKmC9tp9O5qDJ4ETnT1/8FNblqPNdmWr5D8N+1huGUdn47tlO5LS67xEYsIvfUlac98dm/rIy8B7z8FuSIVWxo+dOuRWYLb5EnvOdchIJmwNdR69s6Ek27JTeOc3SVQMaa2xFP0UQOrMzAVNy9O9y8BC2YVC1TD7RTM3Ao6WMvma3trgid3wLrqkGdyPrY4du7d+onUTWkABvwFGg4EXREFXTiAIkgnixZF6ngrbHmX3s6hfnutXWt7z6LhCHu5ptSo14e2My6UGSv5LNoEpFIo49lZ5RoG/KbXGxtypSf8hAAAA=","s":1,"x":0,"y":0},"member-3":{"u":"data:image/webp;base64,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","s":1,"x":0,"y":0}} \ No newline at end of file diff --git a/advisory board roster/Anna.png b/advisory board roster/Anna.png new file mode 100644 index 0000000..75f573d Binary files /dev/null and b/advisory board roster/Anna.png differ diff --git a/advisory board roster/Mads(2).png b/advisory board roster/Mads(2).png new file mode 100644 index 0000000..4c71279 Binary files /dev/null and b/advisory board roster/Mads(2).png differ diff --git a/advisory board roster/Mathies.png b/advisory board roster/Mathies.png new file mode 100644 index 0000000..dd87863 Binary files /dev/null and b/advisory board roster/Mathies.png differ diff --git a/advisory board roster/Torben(2).png b/advisory board roster/Torben(2).png new file mode 100644 index 0000000..f0c8e7c Binary files /dev/null and b/advisory board roster/Torben(2).png differ diff --git a/advisory board roster/Ulla(1).png b/advisory board roster/Ulla(1).png new file mode 100644 index 0000000..a7b09f0 Binary files /dev/null and b/advisory board roster/Ulla(1).png differ diff --git a/advisory board roster/Untitled design(6).png b/advisory board roster/Untitled design(6).png new file mode 100644 index 0000000..db8a898 Binary files /dev/null and b/advisory board roster/Untitled design(6).png differ diff --git a/advisory board roster/Williamv3.png b/advisory board roster/Williamv3.png new file mode 100644 index 0000000..24df310 Binary files /dev/null and b/advisory board roster/Williamv3.png differ diff --git a/advisory board roster/fenja-advisory-board-2026-05-28(2).png b/advisory board roster/fenja-advisory-board-2026-05-28(2).png new file mode 100644 index 0000000..8652316 Binary files /dev/null and b/advisory board roster/fenja-advisory-board-2026-05-28(2).png differ diff --git a/advisory board roster/sørenv2.png b/advisory board roster/sørenv2.png new file mode 100644 index 0000000..4ae3266 Binary files /dev/null and b/advisory board roster/sørenv2.png differ diff --git a/advisory-board-post/.image-slots.state.json b/advisory-board-post/.image-slots.state.json new file mode 100644 index 0000000..c7b3769 --- /dev/null +++ b/advisory-board-post/.image-slots.state.json @@ -0,0 +1 @@ +{"member-2":{"u":"data:image/webp;base64,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","s":1,"x":0,"y":0},"member-3":{"u":"data:image/webp;base64,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","s":1,"x":0,"y":0}} \ No newline at end of file diff --git a/advisory-board-post/assets/fenja-icon-black.svg b/advisory-board-post/assets/fenja-icon-black.svg new file mode 100644 index 0000000..98c2247 --- /dev/null +++ b/advisory-board-post/assets/fenja-icon-black.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/advisory-board-post/assets/fenja-icon-white.svg b/advisory-board-post/assets/fenja-icon-white.svg new file mode 100644 index 0000000..4878705 --- /dev/null +++ b/advisory-board-post/assets/fenja-icon-white.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/advisory-board-post/assets/fenja-logo-full.png b/advisory-board-post/assets/fenja-logo-full.png new file mode 100644 index 0000000..7982b9b Binary files /dev/null and b/advisory-board-post/assets/fenja-logo-full.png differ diff --git a/advisory-board-post/assets/fenja-wordmark-black.svg b/advisory-board-post/assets/fenja-wordmark-black.svg new file mode 100644 index 0000000..6bee4c1 --- /dev/null +++ b/advisory-board-post/assets/fenja-wordmark-black.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/advisory-board-post/assets/fenja-wordmark-white.svg b/advisory-board-post/assets/fenja-wordmark-white.svg new file mode 100644 index 0000000..5bf9780 --- /dev/null +++ b/advisory-board-post/assets/fenja-wordmark-white.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/advisory-board-post/assets/reference-boulderer.png b/advisory-board-post/assets/reference-boulderer.png new file mode 100644 index 0000000..78272dd Binary files /dev/null and b/advisory-board-post/assets/reference-boulderer.png differ diff --git a/advisory-board-post/assets/reference-flowerman-ochre.png b/advisory-board-post/assets/reference-flowerman-ochre.png new file mode 100644 index 0000000..259dfc9 Binary files /dev/null and b/advisory-board-post/assets/reference-flowerman-ochre.png differ diff --git a/advisory-board-post/assets/reference-flowerman-white.png b/advisory-board-post/assets/reference-flowerman-white.png new file mode 100644 index 0000000..f633125 Binary files /dev/null and b/advisory-board-post/assets/reference-flowerman-white.png differ diff --git a/advisory-board-post/assets/reference-waves.png b/advisory-board-post/assets/reference-waves.png new file mode 100644 index 0000000..ce8bb8e Binary files /dev/null and b/advisory-board-post/assets/reference-waves.png differ diff --git a/advisory-board-post/board-posts.jsx b/advisory-board-post/board-posts.jsx new file mode 100644 index 0000000..5bf50fc --- /dev/null +++ b/advisory-board-post/board-posts.jsx @@ -0,0 +1,258 @@ +// board-posts.jsx — Four LinkedIn-ready layout variations for an 8-person +// board reveal post. Each variation is a self-contained, sized artboard +// rendered inside the design canvas so they can be compared side-by-side +// and any one can be opened fullscreen. +// +// Shared image-slot ids ("member-1"..."member-8") mean once you drop a +// portrait it appears in every variation. Edit the MEMBERS array below to +// fill in real names and role-at-company lines. + +const MEMBERS = [ + { id: 'member-1', name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]' }, + { id: 'member-2', name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]' }, + { id: 'member-3', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' }, + { id: 'member-4', name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]' }, + { id: 'member-5', name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]' }, + { id: 'member-6', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' }, + { id: 'member-7', name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]' }, + { id: 'member-8', name: '[ Full Name ]', title: 'Founder', company: '[ Company ]' }, +]; + +// Reusable portrait + caption block. `size` is the portrait square edge. +function Member({ m, size, captionAlign = 'left' }) { + return ( +
+ +
{m.name}
+
{m.title}
+
{m.company}
+
+ ); +} + +// Footer brand mark — used across variations +function Mark({ light = false }) { + return ( +
+ Fenja AI +
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// A — Editorial Square (1200 × 1200) — clean 4×2 grid +// ─────────────────────────────────────────────────────────────────────── +function PostA() { + return ( +
+
+

Meet the Fenja AI Advisory Board

+
Bridging Industry & Sovereign AI
+
+ +
+ {MEMBERS.map((m) => ( + + ))} +
+ +
+ +
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// B — Catalogue Index (1080 × 1350) — numbered vertical list +// ─────────────────────────────────────────────────────────────────────── +function PostB() { + return ( +
+
+
+

A note from leadership

+

Our board.

+

+ Eight quiet experts, each chosen for their depth and discretion. + We are grateful they said yes. +

+
+
+
+ + Fenja AI +
+
+ § 01 — MMXXV +
+
+
+ +
+ {MEMBERS.map((m, i) => ( +
+
{String(i + 1).padStart(2, '0')}
+ +
+
{m.name}
+
{m.title}
+
{m.company}
+
+
+ ))} +
+ +
+
+ "A board built the way a good archive is: slowly, with care, and with people you can trust at four in the morning." +
+
+ fenja.ai / board +
+
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// C — Editorial Landscape (1200 × 627) — left text · right micro grid +// ─────────────────────────────────────────────────────────────────────── +function PostC() { + return ( +
+
+
+

Announcement

+

Introducing our board.

+

+ Eight quiet experts, gathered to steward the work ahead — in research, + in product, in counsel. +

+
+ +
+
+ {MEMBERS.map((m) => ( + + ))} +
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// D — Quiet Cover + Strip (1080 × 1350) — text hero with portrait band +// ─────────────────────────────────────────────────────────────────────── +function PostD() { + return ( +
+
+ {/* Topographic currents accent — quiet, off-axis */} + + {[0,1,2,3,4,5,6,7].map((i) => ( + + ))} + + +
+

Introducing — board of directors · MMXXV

+

Eight people. One quiet table.

+

+ We are honored to introduce the board of Fenja AI. Together, they bring + decades of experience in research, scholarship, and stewardship — + and the patience to do this work well. +

+
+ "A study in stillness, and in counsel. We are grateful, every one of us, that they said yes." +
+
+ +
+ +
+ fenja.ai +
+
+
+ +
+

The board, in order of seating

+
+ {MEMBERS.map((m) => ( + + ))} +
+
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// Canvas +// ─────────────────────────────────────────────────────────────────────── +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/advisory-board-post/colors_and_type.css b/advisory-board-post/colors_and_type.css new file mode 100644 index 0000000..547e18b --- /dev/null +++ b/advisory-board-post/colors_and_type.css @@ -0,0 +1,346 @@ +/* ============================================================= + Fenja AI — Nordic Editorial Design System + "The Digital Archivist" + ============================================================= */ + +/* ---------- Fonts ------------------------------------------ */ +@font-face { + font-family: "Manrope"; + font-weight: 200; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-ExtraLight.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 300; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Light.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Regular.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 500; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Medium.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 600; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-SemiBold.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Bold.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 800; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-ExtraBold.ttf") format("truetype"); +} + +@font-face { + font-family: "Newsreader"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-Regular.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 400; + font-style: italic; + font-display: swap; + src: url("./fonts/Newsreader-Italic.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-Bold.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 700; + font-style: italic; + font-display: swap; + src: url("./fonts/Newsreader-BoldItalic.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 800; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-ExtraBold.ttf") format("truetype"); +} + +/* ---------- Tokens ----------------------------------------- */ +:root { + /* --- Core neutrals (unbleached paper, clay, slate) --- */ + --background: #faf6ee; /* base canvas — warm paper */ + --surface: #faf6ee; + --surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */ + --surface-container-low: #f6f2e8; + --surface-container: #efeadc; + --surface-container-high: #e7e1d0; + --surface-container-highest: #ddd6c3; + --surface-variant: #ddd6c3; + + --on-surface: #383831; /* charcoal slate */ + --on-surface-variant: #5f5e5e; + --on-surface-muted: #8a887f; + + --primary: #5f5e5e; + --on-primary: #fffcf7; + + --secondary: #785f53; /* hand-rubbed wood */ + --secondary-dim: #6b5348; + --on-secondary: #ffffff; + --secondary-fixed-dim: #9a8679; + + --outline: #babab0; + --outline-variant: #babab0; /* used at 15% for ghost borders */ + + /* --- Archival Pigment accent palette (flat, matte inks) --- */ + --pigment-terracotta: #b96b58; /* warnings, critical */ + --pigment-copper: #6d8c7c; /* success, growth */ + --pigment-ochre: #c29d59; /* cautions, tertiary */ + --pigment-indigo: #5a6d83; /* info, neutral data */ + --pigment-heather: #8d7a85; /* categorical, supportive */ + + /* --- Semantic state mappings --- */ + --color-success: var(--pigment-copper); + --color-warning: var(--pigment-ochre); + --color-danger: var(--pigment-terracotta); + --color-info: var(--pigment-indigo); + + /* --- Type families --- */ + --font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif; + --font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + /* --- Type scale (clamped for responsive) --- */ + --text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 56–88 */ + --text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */ + --text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */ + --text-headline-lg: 2.25rem; /* 36 */ + --text-headline-md: 1.75rem; /* 28 */ + --text-headline-sm: 1.375rem; /* 22 */ + --text-title-lg: 1.125rem; /* 18 */ + --text-title-md: 1rem; /* 16 */ + --text-body-lg: 1.0625rem; /* 17 */ + --text-body-md: 1rem; /* 16 */ + --text-body-sm: 0.875rem; /* 14 */ + --text-label-md: 0.8125rem; /* 13 */ + --text-label-sm: 0.75rem; /* 12 */ + + /* Letter-spacing */ + --tracking-tight: -0.02em; + --tracking-snug: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.04em; + --tracking-wider: 0.08em; + + /* Line-heights */ + --leading-tight: 1.1; + --leading-snug: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.6; + --leading-loose: 1.75; + + /* --- Spacing scale (editorial, generous) --- */ + --space-1: 0.25rem; /* 4 */ + --space-2: 0.5rem; /* 8 */ + --space-3: 0.75rem; /* 12 */ + --space-4: 1rem; /* 16 */ + --space-5: 1.5rem; /* 24 */ + --space-6: 2rem; /* 32 — list separator default */ + --space-7: 2.5rem; /* 40 */ + --space-8: 2.75rem; /* 44 — hero-card padding */ + --space-10: 4rem; /* 64 */ + --space-12: 5rem; /* 80 */ + --space-16: 6rem; /* 96 */ + --space-20: 7rem; /* 112 — desktop lateral margin */ + --space-24: 8rem; /* 128 */ + + /* --- Radii --- */ + --radius-none: 0; + --radius-sm: 0.375rem; /* 6 */ + --radius-md: 0.75rem; /* 12 — primary */ + --radius-lg: 1.25rem; /* 20 */ + --radius-full: 9999px; + + /* --- Elevation (atmospheric, warm) --- */ + --shadow-none: none; + --shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06); + --shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04); + --shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04); + + /* --- Ghost border (WCAG fallback only) --- */ + --ghost-border-color: rgba(186, 186, 176, 0.15); + --ghost-border: 1px solid var(--ghost-border-color); + + /* --- Glass --- */ + --glass-blur: blur(16px); + --glass-surface: rgba(255, 252, 247, 0.8); + + /* --- Motion --- */ + --ease-standard: cubic-bezier(0.2, 0.0, 0, 1); + --ease-entrance: cubic-bezier(0, 0, 0, 1); + --ease-exit: cubic-bezier(0.3, 0, 1, 1); + --duration-fast: 140ms; + --duration-med: 240ms; + --duration-slow: 420ms; + + /* --- Layout --- */ + --content-max: 72rem; /* 1152 */ + --reading-max: 42rem; /* 672 */ +} + +/* ---------- Base semantic styles --------------------------- */ +html { + font-family: var(--font-sans); + color: var(--on-surface); + background: var(--background); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + font-size: var(--text-body-md); + line-height: var(--leading-relaxed); + color: var(--on-surface); + background: var(--background); +} + +/* Display — serif, tight, left-aligned editorial intent */ +.display-xl, +.display-lg, +.display-md { + font-family: var(--font-serif); + font-weight: 400; + letter-spacing: var(--tracking-tight); + line-height: var(--leading-tight); + color: var(--on-surface); + margin: 0 0 var(--space-5) 0; +} +.display-xl { font-size: var(--text-display-xl); } +.display-lg { font-size: var(--text-display-lg); } +.display-md { font-size: var(--text-display-md); } + +/* Headlines — serif, authoritative */ +h1, .headline-lg, +h2, .headline-md, +h3, .headline-sm { + font-family: var(--font-serif); + font-weight: 400; + color: var(--on-surface); + letter-spacing: var(--tracking-snug); + line-height: var(--leading-snug); + margin: 0 0 var(--space-4) 0; +} +h1, .headline-lg { font-size: var(--text-headline-lg); } +h2, .headline-md { font-size: var(--text-headline-md); } +h3, .headline-sm { font-size: var(--text-headline-sm); } + +/* Titles — sans, precise structural labels */ +h4, .title-lg, +h5, .title-md { + font-family: var(--font-sans); + font-weight: 600; + color: var(--on-surface); + letter-spacing: var(--tracking-normal); + line-height: var(--leading-snug); + margin: 0 0 var(--space-3) 0; +} +h4, .title-lg { font-size: var(--text-title-lg); } +h5, .title-md { font-size: var(--text-title-md); } + +/* Body */ +p, .body-md { + font-family: var(--font-sans); + font-weight: 400; + font-size: var(--text-body-md); + line-height: var(--leading-relaxed); + color: var(--on-surface); + margin: 0 0 var(--space-4) 0; + text-wrap: pretty; +} +.body-lg { + font-size: var(--text-body-lg); + line-height: var(--leading-relaxed); +} +.body-sm { + font-size: var(--text-body-sm); + line-height: var(--leading-normal); + color: var(--on-surface-variant); +} + +/* Labels — muted, small caps optional */ +.label-md, +.label-sm { + font-family: var(--font-sans); + font-weight: 500; + color: var(--on-surface-variant); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; +} +.label-md { font-size: var(--text-label-md); } +.label-sm { font-size: var(--text-label-sm); } + +/* Editorial lead — serif italic, subtle */ +.lead { + font-family: var(--font-serif); + font-style: italic; + font-size: var(--text-body-lg); + color: var(--on-surface-variant); + line-height: var(--leading-relaxed); +} + +/* Inline code / mono */ +code, kbd, samp, pre, .mono { + font-family: var(--font-mono); + font-size: 0.92em; + color: var(--on-surface); +} + +/* Links — editorial, no underline until hover */ +a { + color: var(--secondary); + text-decoration: none; + border-bottom: 1px solid rgba(120, 95, 83, 0.3); + transition: border-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} +a:hover { + color: var(--secondary-dim); + border-bottom-color: currentColor; +} + +/* Selection — warm, not blue */ +::selection { + background: rgba(120, 95, 83, 0.18); + color: var(--on-surface); +} + +/* Utility: ghost border fallback */ +.ghost-border { border: var(--ghost-border); } +.ghost-border-bottom { border-bottom: var(--ghost-border); } diff --git a/advisory-board-post/design-canvas.jsx b/advisory-board-post/design-canvas.jsx new file mode 100644 index 0000000..fa1f93e --- /dev/null +++ b/advisory-board-post/design-canvas.jsx @@ -0,0 +1,966 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// Recursively unwrap React.Fragment so <>… grouping doesn't hide +// DCSection/DCArtboard children from the type-based walks below. +function dcFlatten(children) { + const out = []; + React.Children.forEach(children, (c) => { + if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children)); + else out.push(c); + }); + return out; +} + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Fragments are flattened; wrapping in other + // elements still opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + dcFlatten(children).forEach((sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + dcFlatten(sec.props.children).forEach((ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's + // marginBottom) reflow on every scale change, vertically shifting the + // world layout — so a world point mathematically pinned under the cursor + // drifts as you zoom (content creeps up on zoom-in, down on zoom-out). + // Anchor the DOM element under the cursor instead: record its screen Y, + // apply the transform + --dc-inv-zoom, then cancel whatever vertical + // drift the reflow introduced so it stays put on screen. + let marker = null, markerY0 = 0; + if (k !== 1) { + const hit = document.elementFromPoint(cx, cy); + marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null; + if (marker) markerY0 = marker.getBoundingClientRect().top; + } + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + if (marker) { + // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any + // departure after the --dc-inv-zoom reflow is the layout drift. + const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k); + if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); } + } + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(dcFlatten(children)); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/advisory-board-post/editor.html b/advisory-board-post/editor.html new file mode 100644 index 0000000..46a0f3d --- /dev/null +++ b/advisory-board-post/editor.html @@ -0,0 +1,362 @@ + + + + + + Board post editor · Fenja AI + + + + + + + + + + +
+ + + diff --git a/advisory-board-post/editor.jsx b/advisory-board-post/editor.jsx new file mode 100644 index 0000000..3273654 --- /dev/null +++ b/advisory-board-post/editor.jsx @@ -0,0 +1,340 @@ +// editor.jsx — Board post editor. +// +// Two-column workspace: a form on the left to edit headline/subtitle and +// the 8 member entries (text + photo upload), and a sticky preview on the +// right that scales the live 1200×1200 post to 540×540 for display. A +// hidden, unscaled clone of the post sits off-screen and is what gets +// passed to html-to-image at download time, so the exported PNG is a +// pixel-clean 2400×2400 (pixelRatio 2 over the 1200 source). +// +// Everything persists to localStorage on every change — refresh-safe. +// Uploaded photos are downscaled to ~800px and re-encoded as JPEG before +// being stored, so eight portraits still fit comfortably under the 5MB +// localStorage cap. + +const { useState, useEffect, useRef, useCallback } = React; + +const STORAGE_KEY = 'fenja-board-data-v1'; +const MIGRATION_KEY = 'fenja-board-migrations'; + +// Apply one-time data migrations. Each entry runs once per browser. +function applyMigrations(parsed) { + let applied = []; + try { applied = JSON.parse(localStorage.getItem(MIGRATION_KEY) || '[]'); } catch {} + + // 2026-05-swap-34-67: user asked to swap positions 3↔6 and 4↔7. + if (!applied.includes('2026-05-swap-34-67') && parsed?.members?.length === 8) { + const m = parsed.members.slice(); + [m[2], m[5]] = [m[5], m[2]]; // index 2 ↔ 5 (positions 3 ↔ 6) + [m[3], m[6]] = [m[6], m[3]]; // index 3 ↔ 6 (positions 4 ↔ 7) + parsed = { ...parsed, members: m }; + applied.push('2026-05-swap-34-67'); + } + + try { localStorage.setItem(MIGRATION_KEY, JSON.stringify(applied)); } catch {} + return parsed; +} + +const DEFAULT_DATA = { + headlineBefore: 'Meet the Fenja AI', + headlineEm: 'Advisory Board', + subtitle: 'Bridging Industry & Sovereign AI', + members: [ + { name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]', photo: null }, + { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]', photo: null }, + { name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]', photo: null }, + { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Founder', company: '[ Company ]', photo: null }, + ], +}; + +// ──────────────────────────────────────────────────────────────────────── +// Image compression — JPEG at max 800px to keep localStorage happy +// ──────────────────────────────────────────────────────────────────────── +async function compressImage(file, maxDim = 800, quality = 0.88) { + const url = URL.createObjectURL(file); + try { + const img = new Image(); + await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; }); + const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); + const w = Math.round(img.width * scale); + const h = Math.round(img.height * scale); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL('image/jpeg', quality); + } finally { + URL.revokeObjectURL(url); + } +} + +// ──────────────────────────────────────────────────────────────────────── +// The post itself — mirrors Variation A in index.html. Renders into a +// 1200×1200 box; CSS lives in editor.html so it applies to both the +// visible scaled preview and the hidden capture host. +// ──────────────────────────────────────────────────────────────────────── +function BoardPost({ data }) { + return ( +
+
+
+

+ {data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''} + {data.headlineEm ? {data.headlineEm} : null} +

+ {data.subtitle ?
{data.subtitle}
: null} +
+ +
+ {data.members.map((m, i) => ( +
+ {m.photo + ? + :
Portrait
} +
{m.name}
+
{m.title}
+
{m.company}
+
+ ))} +
+ +
+
+ Fenja AI +
+
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────── +// Editor +// ──────────────────────────────────────────────────────────────────────── +function EditorApp() { + const [data, setData] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + let parsed = JSON.parse(saved); + parsed = applyMigrations(parsed); + // Merge with defaults to handle schema additions + return { + ...DEFAULT_DATA, + ...parsed, + members: parsed.members && parsed.members.length === 8 + ? parsed.members + : DEFAULT_DATA.members, + }; + } + } catch {} + return DEFAULT_DATA; + }); + + const [downloading, setDownloading] = useState(false); + const captureRef = useRef(null); + + // Persist on every change + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (err) { + console.warn('localStorage save failed', err); + } + }, [data]); + + const updateField = (key, value) => setData(d => ({ ...d, [key]: value })); + const updateMember = (i, key, value) => setData(d => ({ + ...d, + members: d.members.map((m, idx) => idx === i ? { ...m, [key]: value } : m), + })); + + const onPhoto = async (i, file) => { + if (!file) return; + try { + const dataUrl = await compressImage(file); + updateMember(i, 'photo', dataUrl); + } catch (err) { + console.error('Image processing failed', err); + alert('Could not read that image. Try another file?'); + } + }; + + const onDownload = async () => { + if (!captureRef.current) return; + setDownloading(true); + try { + // Make sure fonts are loaded before capture, otherwise the headline + // falls back to Times in the rendered PNG. + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + const dataUrl = await window.htmlToImage.toPng(captureRef.current, { + pixelRatio: 2, + width: 1200, + height: 1200, + cacheBust: true, + backgroundColor: '#faf6ee', + }); + const link = document.createElement('a'); + link.download = `fenja-advisory-board-${new Date().toISOString().slice(0,10)}.png`; + link.href = dataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error('Export failed', err); + alert('Export failed. Check the console for details.'); + } finally { + setDownloading(false); + } + }; + + const onReset = () => { + if (confirm('Reset to defaults? Your edits and uploaded photos will be cleared.')) { + setData(DEFAULT_DATA); + } + }; + + return ( + <> +
+
+ Fenja AI +
Board post editor
+
+
+ Compare layouts + + +
+
+ +
+
+

Edit the post.

+

Fill in the headline, subtitle, and the eight members. The preview updates as you type.

+ +
+

Headline

+
+
+ + updateField('headlineBefore', e.target.value)} + placeholder="Meet the Fenja AI" + /> +
+
+ + updateField('headlineEm', e.target.value)} + placeholder="Advisory Board" + /> +
The terminal phrase, rendered in serif italic bold.
+
+
+
+ + updateField('subtitle', e.target.value)} + placeholder="Bridging Industry & Sovereign AI" + /> +
+
+ +
+

Members · 8 portraits

+ {data.members.map((m, i) => ( +
+
+
{String(i + 1).padStart(2, '0')}
+ +
+
+
+ + updateMember(i, 'name', e.target.value)} + /> +
+
+ + updateMember(i, 'title', e.target.value)} + /> +
+
+ + updateMember(i, 'company', e.target.value)} + /> +
+
+
+ ))} +
+
+ + +
+ + {/* Hidden full-size capture target — what html-to-image actually reads. */} + + + ); +} diff --git a/advisory-board-post/fonts/Manrope-Bold.ttf b/advisory-board-post/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000..62a6183 Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Bold.ttf differ diff --git a/advisory-board-post/fonts/Manrope-ExtraBold.ttf b/advisory-board-post/fonts/Manrope-ExtraBold.ttf new file mode 100644 index 0000000..2fa671c Binary files /dev/null and b/advisory-board-post/fonts/Manrope-ExtraBold.ttf differ diff --git a/advisory-board-post/fonts/Manrope-ExtraLight.ttf b/advisory-board-post/fonts/Manrope-ExtraLight.ttf new file mode 100644 index 0000000..c55745a Binary files /dev/null and b/advisory-board-post/fonts/Manrope-ExtraLight.ttf differ diff --git a/advisory-board-post/fonts/Manrope-Light.ttf b/advisory-board-post/fonts/Manrope-Light.ttf new file mode 100644 index 0000000..8a771c2 Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Light.ttf differ diff --git a/advisory-board-post/fonts/Manrope-Medium.ttf b/advisory-board-post/fonts/Manrope-Medium.ttf new file mode 100644 index 0000000..c6d28de Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Medium.ttf differ diff --git a/advisory-board-post/fonts/Manrope-Regular.ttf b/advisory-board-post/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000..9a108f1 Binary files /dev/null and b/advisory-board-post/fonts/Manrope-Regular.ttf differ diff --git a/advisory-board-post/fonts/Manrope-SemiBold.ttf b/advisory-board-post/fonts/Manrope-SemiBold.ttf new file mode 100644 index 0000000..46a13d6 Binary files /dev/null and b/advisory-board-post/fonts/Manrope-SemiBold.ttf differ diff --git a/advisory-board-post/fonts/Newsreader-Bold.ttf b/advisory-board-post/fonts/Newsreader-Bold.ttf new file mode 100644 index 0000000..6d9e20a Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Bold.ttf differ diff --git a/advisory-board-post/fonts/Newsreader-BoldItalic.ttf b/advisory-board-post/fonts/Newsreader-BoldItalic.ttf new file mode 100644 index 0000000..bc57925 Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-BoldItalic.ttf differ diff --git a/advisory-board-post/fonts/Newsreader-ExtraBold.ttf b/advisory-board-post/fonts/Newsreader-ExtraBold.ttf new file mode 100644 index 0000000..69a726d Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-ExtraBold.ttf differ diff --git a/advisory-board-post/fonts/Newsreader-Italic.ttf b/advisory-board-post/fonts/Newsreader-Italic.ttf new file mode 100644 index 0000000..477facd Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Italic.ttf differ diff --git a/advisory-board-post/fonts/Newsreader-Regular.ttf b/advisory-board-post/fonts/Newsreader-Regular.ttf new file mode 100644 index 0000000..9fe7694 Binary files /dev/null and b/advisory-board-post/fonts/Newsreader-Regular.ttf differ diff --git a/advisory-board-post/image-slot.js b/advisory-board-post/image-slot.js new file mode 100644 index 0000000..d1eb01b --- /dev/null +++ b/advisory-board-post/image-slot.js @@ -0,0 +1,641 @@ +/** + * — user-fillable image placeholder. + * + * Drop this into a deck, mockup, or page wherever you want the user to + * supply an image. You control the slot's shape and size; the user fills it + * by dragging an image file onto it (or clicking to browse). The dropped + * image persists across reloads via a .image-slots.state.json sidecar — + * same read-via-fetch / write-via-window.omelette pattern as + * design_canvas.jsx, so the filled slot shows on share links, downloaded + * zips, and PPTX export. Outside the omelette runtime the slot is read-only. + * + * The host bridge only allows sidecar writes at the project root, so the + * HTML that uses this component is assumed to live at the project root too + * (same constraint as design_canvas.jsx). + * + * Attributes: + * id Persistence key. REQUIRED for the drop to survive reload — + * every slot on the page needs a distinct id. + * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') + * 'circle' applies 50% border-radius; on a non-square slot + * that's an ellipse — set equal width and height for a true + * circle. + * radius Corner radius in px for 'rounded'. (default 12) + * mask Any CSS clip-path value. Overrides `shape` — use this for + * hexagons, blobs, arbitrary polygons. + * fit object-fit: cover | contain | fill. (default 'cover') + * With cover (the default) double-clicking the filled slot + * enters a reframe mode: the whole image spills past the mask + * (translucent outside, opaque inside), drag to reposition, + * corner-drag to scale. The crop persists alongside the image + * in the sidecar. contain/fill stay static. + * position object-position for fit=contain|fill. (default '50% 50%') + * placeholder Empty-state caption. (default 'Drop an image') + * src Optional initial/fallback image URL. A user drop overrides + * it; clearing the drop reveals src again. + * + * Size and layout come from ordinary CSS on the element — width/height + * inline or from a parent grid — so it composes with any layout. + * + * Usage: + * + * + * + * + */ + +(() => { + const STATE_FILE = '.image-slots.state.json'; + // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the + // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. + const MAX_DIM = 1200; + // Raster formats only. SVG is excluded (can carry script; createImageBitmap + // on SVG blobs is inconsistent). GIF is excluded because the canvas + // re-encode keeps only the first frame, so an animated GIF would silently + // go still — better to reject than surprise. + const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; + + // ── Shared sidecar store ──────────────────────────────────────────────── + // One fetch + immediate write-on-change for every on the + // page. Reads via fetch() so viewing works anywhere the HTML and sidecar + // are served together; writes go through window.omelette.writeFile, which + // the host allowlists to *.state.json basenames only. + const subs = new Set(); + let slots = {}; + // ids explicitly cleared before the sidecar fetch resolved — otherwise + // the merge below can't tell "never set" from "just deleted" and would + // resurrect the sidecar's stale value. + const tombstones = new Set(); + let loaded = false; + let loadP = null; + + function load() { + if (loadP) return loadP; + loadP = fetch(STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + // Merge: sidecar loses to any in-memory change that raced ahead of + // the fetch (drop or clear) so neither is clobbered by hydration. + if (j && typeof j === 'object') { + const merged = Object.assign({}, j, slots); + // A framing-only write that raced ahead of hydration must not + // drop a user image that's only on disk — inherit u from the + // sidecar for any in-memory entry that lacks one. + for (const k in slots) { + if (merged[k] && !merged[k].u && j[k]) { + merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; + } + } + for (const id of tombstones) delete merged[id]; + slots = merged; + } + tombstones.clear(); + }) + .catch(() => {}) + .then(() => { loaded = true; subs.forEach((fn) => fn()); }); + return loadP; + } + + // Serialize writes so two near-simultaneous drops on different slots + // can't reorder at the backend and leave the sidecar with only the + // first. A save requested mid-flight just marks dirty and re-fires on + // completion with the then-current slots. + let saving = false; + let saveDirty = false; + function save() { + if (saving) { saveDirty = true; return; } + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + saving = true; + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) + .catch(() => {}) + .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); + } + + const S_MAX = 5; + const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); + + // Normalize a stored slot value. Pre-reframe sidecars stored a bare + // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. + function getSlot(id) { + const v = slots[id]; + if (!v) return null; + return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; + } + + function setSlot(id, val) { + if (!id) return; + if (val) { slots[id] = val; tombstones.delete(id); } + else { delete slots[id]; if (!loaded) tombstones.add(id); } + subs.forEach((fn) => fn()); + // A drop is rare + high-value — write immediately so nav-away can't lose + // it. Gate on the initial read so we don't overwrite a sidecar we haven't + // merged yet; the merge in load() keeps this change once the read lands. + if (loaded) save(); else load().then(save); + } + + // ── Image downscale ───────────────────────────────────────────────────── + // Encode through a canvas so the sidecar carries resized bytes, not the + // raw upload. Longest side is capped at 2× the slot's rendered width + // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG + // for photos, so there's no need for per-image format picking. + async function toDataUrl(file, targetW) { + const bitmap = await createImageBitmap(file); + try { + const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); + const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); + return canvas.toDataURL('image/webp', 0.85); + } finally { + bitmap.close && bitmap.close(); + } + } + + // ── Custom element ────────────────────────────────────────────────────── + const stylesheet = + ':host{display:inline-block;position:relative;vertical-align:top;' + + ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' + + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' + + // .frame img (clipped) and .spill (unclipped ghost + handles) share the + // same left/top/width/height in frame-%, computed by _applyView(), so the + // inside-mask crop and the outside-mask spill stay pixel-aligned. + '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + + // Reframe mode (double-click): the full image spills past the mask. The + // spill layer is sized to the IMAGE bounds so its corners are where the + // resize handles belong. The ghost inside is translucent; the real + // clipped underneath shows the opaque in-mask crop. + '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' + + ' cursor:grab;touch-action:none}' + + ':host([data-panning]) .spill{cursor:grabbing}' + + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + + ' transform:translate(-50%,-50%)}' + + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + + ':host([data-reframe]){z-index:10}' + + ':host([data-reframe]) .spill{display:block}' + + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' cursor:pointer;user-select:none}' + + '.empty svg{opacity:.45}' + + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + + '.empty .sub{font-size:11px}' + + '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' + + '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' + + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + + ' background:rgba(201,100,66,.10)}' + + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' + + ' transition:border-color .12s}' + + ':host([data-over]) .ring{border-color:#c96442}' + + ':host([data-filled]) .ring{display:none}' + + // Controls sit BELOW the mask (top:100%), absolutely positioned so the + // author-declared slot height is unaffected. The gap is padding, not a + // top offset, so the hover target stays contiguous with the frame. + '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' + + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + + ' white-space:nowrap}' + + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + + ' {opacity:1;pointer-events:auto}' + + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + + ' backdrop-filter:blur(6px)}' + + '.ctl button:hover{background:rgba(0,0,0,.8)}' + + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}'; + + const icon = + '' + + '' + + ''; + + class ImageSlot extends HTMLElement { + static get observedAttributes() { + return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id']; + } + + constructor() { + super(); + const root = this.attachShadow({ mode: 'open' }); + // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius + // on the frame (circle, pill, rounded) can't clip them. + root.innerHTML = + '' + + '
' + + ' ' + + '
' + icon + + '
' + + '
or browse files
' + + '
' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (act === 'replace') { this._exitReframe(true); this._input.click(); } + if (act === 'clear') { + this._exitReframe(false); + this._gen++; + this._local = null; + if (this.id) setSlot(this.id, null); else this._render(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + this._img.addEventListener('load', () => this._applyView()); + // Gated on editable + fit=cover so share links and contain/fill slots + // stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const base = Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (commit) this._commitView(); + } + + attributeChangedCallback() { if (this.shadowRoot) this._render(); } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill + // keep the old object-fit path and double-click is a no-op. + _reframes() { + return this.hasAttribute('data-filled') && + (this.getAttribute('fit') || 'cover') === 'cover'; + } + + // Cover-baseline geometry, shared by clamp/apply/resize. Null until the + // img has loaded (naturalWidth is 0 before that) or when the slot has no + // layout box — ResizeObserver fires with a 0×0 rect under display:none, + // and clamping against a degenerate 1×1 frame would silently pull the + // stored pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + const fit = this.getAttribute('fit') || 'cover'; + if (fit !== 'cover' || !g) { + // Non-cover, or dimensions not known yet (before img load). + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = fit; + this._img.style.objectPosition = this.getAttribute('position') || '50% 50%'; + return; + } + // Cover baseline: img fills the frame on its tighter axis at s=1, so + // pan works immediately on the overflowing axis without zooming first. + // Width/height and left/top are all frame-% — depends only on the + // frame aspect ratio, so a responsive resize keeps the same crop. The + // spill layer mirrors the same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + this._spill.style.width = w; this._spill.style.height = h; + this._spill.style.left = l; this._spill.style.top = t; + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + if (url) { + if (this._img.getAttribute('src') !== url) { + this._img.src = url; + this._ghost.src = url; + } + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + this._empty.style.display = 'flex'; + this.removeAttribute('data-filled'); + } + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/advisory-board-post/index.html b/advisory-board-post/index.html new file mode 100644 index 0000000..77cb9a9 --- /dev/null +++ b/advisory-board-post/index.html @@ -0,0 +1,251 @@ + + + + + + Board post · Fenja AI + + + + + + + + + + + +
+ + + diff --git a/advisory-board-post/screenshots/01-diag-overview.png b/advisory-board-post/screenshots/01-diag-overview.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/advisory-board-post/screenshots/01-diag-overview.png differ diff --git a/advisory-board-post/screenshots/02-diag-overview.png b/advisory-board-post/screenshots/02-diag-overview.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/advisory-board-post/screenshots/02-diag-overview.png differ diff --git a/advisory-board-post/screenshots/diag-a.png b/advisory-board-post/screenshots/diag-a.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/advisory-board-post/screenshots/diag-a.png differ diff --git a/advisory-board-post/screenshots/diag-canvas.png b/advisory-board-post/screenshots/diag-canvas.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/advisory-board-post/screenshots/diag-canvas.png differ diff --git a/advisory-board-post/uploads/fenja-logo-1000x1000.png b/advisory-board-post/uploads/fenja-logo-1000x1000.png new file mode 100644 index 0000000..7982b9b Binary files /dev/null and b/advisory-board-post/uploads/fenja-logo-1000x1000.png differ diff --git a/assets/fenja-icon-black.svg b/assets/fenja-icon-black.svg new file mode 100644 index 0000000..98c2247 --- /dev/null +++ b/assets/fenja-icon-black.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/fenja-icon-white.svg b/assets/fenja-icon-white.svg new file mode 100644 index 0000000..4878705 --- /dev/null +++ b/assets/fenja-icon-white.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/fenja-logo-full.png b/assets/fenja-logo-full.png new file mode 100644 index 0000000..7982b9b Binary files /dev/null and b/assets/fenja-logo-full.png differ diff --git a/assets/fenja-wordmark-black.svg b/assets/fenja-wordmark-black.svg new file mode 100644 index 0000000..6bee4c1 --- /dev/null +++ b/assets/fenja-wordmark-black.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/fenja-wordmark-white.svg b/assets/fenja-wordmark-white.svg new file mode 100644 index 0000000..5bf9780 --- /dev/null +++ b/assets/fenja-wordmark-white.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/reference-boulderer.png b/assets/reference-boulderer.png new file mode 100644 index 0000000..78272dd Binary files /dev/null and b/assets/reference-boulderer.png differ diff --git a/assets/reference-flowerman-ochre.png b/assets/reference-flowerman-ochre.png new file mode 100644 index 0000000..259dfc9 Binary files /dev/null and b/assets/reference-flowerman-ochre.png differ diff --git a/assets/reference-flowerman-white.png b/assets/reference-flowerman-white.png new file mode 100644 index 0000000..f633125 Binary files /dev/null and b/assets/reference-flowerman-white.png differ diff --git a/assets/reference-waves.png b/assets/reference-waves.png new file mode 100644 index 0000000..ce8bb8e Binary files /dev/null and b/assets/reference-waves.png differ diff --git a/board-posts.jsx b/board-posts.jsx new file mode 100644 index 0000000..5bf50fc --- /dev/null +++ b/board-posts.jsx @@ -0,0 +1,258 @@ +// board-posts.jsx — Four LinkedIn-ready layout variations for an 8-person +// board reveal post. Each variation is a self-contained, sized artboard +// rendered inside the design canvas so they can be compared side-by-side +// and any one can be opened fullscreen. +// +// Shared image-slot ids ("member-1"..."member-8") mean once you drop a +// portrait it appears in every variation. Edit the MEMBERS array below to +// fill in real names and role-at-company lines. + +const MEMBERS = [ + { id: 'member-1', name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]' }, + { id: 'member-2', name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]' }, + { id: 'member-3', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' }, + { id: 'member-4', name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]' }, + { id: 'member-5', name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]' }, + { id: 'member-6', name: '[ Full Name ]', title: 'CEO', company: '[ Company ]' }, + { id: 'member-7', name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]' }, + { id: 'member-8', name: '[ Full Name ]', title: 'Founder', company: '[ Company ]' }, +]; + +// Reusable portrait + caption block. `size` is the portrait square edge. +function Member({ m, size, captionAlign = 'left' }) { + return ( +
+ +
{m.name}
+
{m.title}
+
{m.company}
+
+ ); +} + +// Footer brand mark — used across variations +function Mark({ light = false }) { + return ( +
+ Fenja AI +
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// A — Editorial Square (1200 × 1200) — clean 4×2 grid +// ─────────────────────────────────────────────────────────────────────── +function PostA() { + return ( +
+
+

Meet the Fenja AI Advisory Board

+
Bridging Industry & Sovereign AI
+
+ +
+ {MEMBERS.map((m) => ( + + ))} +
+ +
+ +
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// B — Catalogue Index (1080 × 1350) — numbered vertical list +// ─────────────────────────────────────────────────────────────────────── +function PostB() { + return ( +
+
+
+

A note from leadership

+

Our board.

+

+ Eight quiet experts, each chosen for their depth and discretion. + We are grateful they said yes. +

+
+
+
+ + Fenja AI +
+
+ § 01 — MMXXV +
+
+
+ +
+ {MEMBERS.map((m, i) => ( +
+
{String(i + 1).padStart(2, '0')}
+ +
+
{m.name}
+
{m.title}
+
{m.company}
+
+
+ ))} +
+ +
+
+ "A board built the way a good archive is: slowly, with care, and with people you can trust at four in the morning." +
+
+ fenja.ai / board +
+
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// C — Editorial Landscape (1200 × 627) — left text · right micro grid +// ─────────────────────────────────────────────────────────────────────── +function PostC() { + return ( +
+
+
+

Announcement

+

Introducing our board.

+

+ Eight quiet experts, gathered to steward the work ahead — in research, + in product, in counsel. +

+
+ +
+
+ {MEMBERS.map((m) => ( + + ))} +
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// D — Quiet Cover + Strip (1080 × 1350) — text hero with portrait band +// ─────────────────────────────────────────────────────────────────────── +function PostD() { + return ( +
+
+ {/* Topographic currents accent — quiet, off-axis */} + + {[0,1,2,3,4,5,6,7].map((i) => ( + + ))} + + +
+

Introducing — board of directors · MMXXV

+

Eight people. One quiet table.

+

+ We are honored to introduce the board of Fenja AI. Together, they bring + decades of experience in research, scholarship, and stewardship — + and the patience to do this work well. +

+
+ "A study in stillness, and in counsel. We are grateful, every one of us, that they said yes." +
+
+ +
+ +
+ fenja.ai +
+
+
+ +
+

The board, in order of seating

+
+ {MEMBERS.map((m) => ( + + ))} +
+
+
+ ); +} + +// ─────────────────────────────────────────────────────────────────────── +// Canvas +// ─────────────────────────────────────────────────────────────────────── +function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/colors_and_type.css b/colors_and_type.css new file mode 100644 index 0000000..547e18b --- /dev/null +++ b/colors_and_type.css @@ -0,0 +1,346 @@ +/* ============================================================= + Fenja AI — Nordic Editorial Design System + "The Digital Archivist" + ============================================================= */ + +/* ---------- Fonts ------------------------------------------ */ +@font-face { + font-family: "Manrope"; + font-weight: 200; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-ExtraLight.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 300; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Light.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Regular.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 500; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Medium.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 600; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-SemiBold.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-Bold.ttf") format("truetype"); +} +@font-face { + font-family: "Manrope"; + font-weight: 800; + font-style: normal; + font-display: swap; + src: url("./fonts/Manrope-ExtraBold.ttf") format("truetype"); +} + +@font-face { + font-family: "Newsreader"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-Regular.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 400; + font-style: italic; + font-display: swap; + src: url("./fonts/Newsreader-Italic.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-Bold.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 700; + font-style: italic; + font-display: swap; + src: url("./fonts/Newsreader-BoldItalic.ttf") format("truetype"); +} +@font-face { + font-family: "Newsreader"; + font-weight: 800; + font-style: normal; + font-display: swap; + src: url("./fonts/Newsreader-ExtraBold.ttf") format("truetype"); +} + +/* ---------- Tokens ----------------------------------------- */ +:root { + /* --- Core neutrals (unbleached paper, clay, slate) --- */ + --background: #faf6ee; /* base canvas — warm paper */ + --surface: #faf6ee; + --surface-container-lowest: #fffcf7; /* most-elevated — unbleached paper, never pure white */ + --surface-container-low: #f6f2e8; + --surface-container: #efeadc; + --surface-container-high: #e7e1d0; + --surface-container-highest: #ddd6c3; + --surface-variant: #ddd6c3; + + --on-surface: #383831; /* charcoal slate */ + --on-surface-variant: #5f5e5e; + --on-surface-muted: #8a887f; + + --primary: #5f5e5e; + --on-primary: #fffcf7; + + --secondary: #785f53; /* hand-rubbed wood */ + --secondary-dim: #6b5348; + --on-secondary: #ffffff; + --secondary-fixed-dim: #9a8679; + + --outline: #babab0; + --outline-variant: #babab0; /* used at 15% for ghost borders */ + + /* --- Archival Pigment accent palette (flat, matte inks) --- */ + --pigment-terracotta: #b96b58; /* warnings, critical */ + --pigment-copper: #6d8c7c; /* success, growth */ + --pigment-ochre: #c29d59; /* cautions, tertiary */ + --pigment-indigo: #5a6d83; /* info, neutral data */ + --pigment-heather: #8d7a85; /* categorical, supportive */ + + /* --- Semantic state mappings --- */ + --color-success: var(--pigment-copper); + --color-warning: var(--pigment-ochre); + --color-danger: var(--pigment-terracotta); + --color-info: var(--pigment-indigo); + + /* --- Type families --- */ + --font-serif: "Newsreader", "Source Serif Pro", Georgia, "Times New Roman", serif; + --font-sans: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + /* --- Type scale (clamped for responsive) --- */ + --text-display-xl: clamp(3.5rem, 6vw, 5.5rem); /* 56–88 */ + --text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 48–72 */ + --text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 40–56 */ + --text-headline-lg: 2.25rem; /* 36 */ + --text-headline-md: 1.75rem; /* 28 */ + --text-headline-sm: 1.375rem; /* 22 */ + --text-title-lg: 1.125rem; /* 18 */ + --text-title-md: 1rem; /* 16 */ + --text-body-lg: 1.0625rem; /* 17 */ + --text-body-md: 1rem; /* 16 */ + --text-body-sm: 0.875rem; /* 14 */ + --text-label-md: 0.8125rem; /* 13 */ + --text-label-sm: 0.75rem; /* 12 */ + + /* Letter-spacing */ + --tracking-tight: -0.02em; + --tracking-snug: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.04em; + --tracking-wider: 0.08em; + + /* Line-heights */ + --leading-tight: 1.1; + --leading-snug: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.6; + --leading-loose: 1.75; + + /* --- Spacing scale (editorial, generous) --- */ + --space-1: 0.25rem; /* 4 */ + --space-2: 0.5rem; /* 8 */ + --space-3: 0.75rem; /* 12 */ + --space-4: 1rem; /* 16 */ + --space-5: 1.5rem; /* 24 */ + --space-6: 2rem; /* 32 — list separator default */ + --space-7: 2.5rem; /* 40 */ + --space-8: 2.75rem; /* 44 — hero-card padding */ + --space-10: 4rem; /* 64 */ + --space-12: 5rem; /* 80 */ + --space-16: 6rem; /* 96 */ + --space-20: 7rem; /* 112 — desktop lateral margin */ + --space-24: 8rem; /* 128 */ + + /* --- Radii --- */ + --radius-none: 0; + --radius-sm: 0.375rem; /* 6 */ + --radius-md: 0.75rem; /* 12 — primary */ + --radius-lg: 1.25rem; /* 20 */ + --radius-full: 9999px; + + /* --- Elevation (atmospheric, warm) --- */ + --shadow-none: none; + --shadow-ambient: 0 12px 32px -12px rgba(56, 56, 49, 0.06); + --shadow-float: 0 24px 48px -16px rgba(56, 56, 49, 0.05), 0 4px 12px -4px rgba(56, 56, 49, 0.04); + --shadow-modal: 0 40px 64px -24px rgba(56, 56, 49, 0.08), 0 8px 16px -6px rgba(56, 56, 49, 0.04); + + /* --- Ghost border (WCAG fallback only) --- */ + --ghost-border-color: rgba(186, 186, 176, 0.15); + --ghost-border: 1px solid var(--ghost-border-color); + + /* --- Glass --- */ + --glass-blur: blur(16px); + --glass-surface: rgba(255, 252, 247, 0.8); + + /* --- Motion --- */ + --ease-standard: cubic-bezier(0.2, 0.0, 0, 1); + --ease-entrance: cubic-bezier(0, 0, 0, 1); + --ease-exit: cubic-bezier(0.3, 0, 1, 1); + --duration-fast: 140ms; + --duration-med: 240ms; + --duration-slow: 420ms; + + /* --- Layout --- */ + --content-max: 72rem; /* 1152 */ + --reading-max: 42rem; /* 672 */ +} + +/* ---------- Base semantic styles --------------------------- */ +html { + font-family: var(--font-sans); + color: var(--on-surface); + background: var(--background); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + font-size: var(--text-body-md); + line-height: var(--leading-relaxed); + color: var(--on-surface); + background: var(--background); +} + +/* Display — serif, tight, left-aligned editorial intent */ +.display-xl, +.display-lg, +.display-md { + font-family: var(--font-serif); + font-weight: 400; + letter-spacing: var(--tracking-tight); + line-height: var(--leading-tight); + color: var(--on-surface); + margin: 0 0 var(--space-5) 0; +} +.display-xl { font-size: var(--text-display-xl); } +.display-lg { font-size: var(--text-display-lg); } +.display-md { font-size: var(--text-display-md); } + +/* Headlines — serif, authoritative */ +h1, .headline-lg, +h2, .headline-md, +h3, .headline-sm { + font-family: var(--font-serif); + font-weight: 400; + color: var(--on-surface); + letter-spacing: var(--tracking-snug); + line-height: var(--leading-snug); + margin: 0 0 var(--space-4) 0; +} +h1, .headline-lg { font-size: var(--text-headline-lg); } +h2, .headline-md { font-size: var(--text-headline-md); } +h3, .headline-sm { font-size: var(--text-headline-sm); } + +/* Titles — sans, precise structural labels */ +h4, .title-lg, +h5, .title-md { + font-family: var(--font-sans); + font-weight: 600; + color: var(--on-surface); + letter-spacing: var(--tracking-normal); + line-height: var(--leading-snug); + margin: 0 0 var(--space-3) 0; +} +h4, .title-lg { font-size: var(--text-title-lg); } +h5, .title-md { font-size: var(--text-title-md); } + +/* Body */ +p, .body-md { + font-family: var(--font-sans); + font-weight: 400; + font-size: var(--text-body-md); + line-height: var(--leading-relaxed); + color: var(--on-surface); + margin: 0 0 var(--space-4) 0; + text-wrap: pretty; +} +.body-lg { + font-size: var(--text-body-lg); + line-height: var(--leading-relaxed); +} +.body-sm { + font-size: var(--text-body-sm); + line-height: var(--leading-normal); + color: var(--on-surface-variant); +} + +/* Labels — muted, small caps optional */ +.label-md, +.label-sm { + font-family: var(--font-sans); + font-weight: 500; + color: var(--on-surface-variant); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; +} +.label-md { font-size: var(--text-label-md); } +.label-sm { font-size: var(--text-label-sm); } + +/* Editorial lead — serif italic, subtle */ +.lead { + font-family: var(--font-serif); + font-style: italic; + font-size: var(--text-body-lg); + color: var(--on-surface-variant); + line-height: var(--leading-relaxed); +} + +/* Inline code / mono */ +code, kbd, samp, pre, .mono { + font-family: var(--font-mono); + font-size: 0.92em; + color: var(--on-surface); +} + +/* Links — editorial, no underline until hover */ +a { + color: var(--secondary); + text-decoration: none; + border-bottom: 1px solid rgba(120, 95, 83, 0.3); + transition: border-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard); +} +a:hover { + color: var(--secondary-dim); + border-bottom-color: currentColor; +} + +/* Selection — warm, not blue */ +::selection { + background: rgba(120, 95, 83, 0.18); + color: var(--on-surface); +} + +/* Utility: ghost border fallback */ +.ghost-border { border: var(--ghost-border); } +.ghost-border-bottom { border-bottom: var(--ghost-border); } diff --git a/design-canvas.jsx b/design-canvas.jsx new file mode 100644 index 0000000..fa1f93e --- /dev/null +++ b/design-canvas.jsx @@ -0,0 +1,966 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// Recursively unwrap React.Fragment so <>… grouping doesn't hide +// DCSection/DCArtboard children from the type-based walks below. +function dcFlatten(children) { + const out = []; + React.Children.forEach(children, (c) => { + if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children)); + else out.push(c); + }); + return out; +} + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Fragments are flattened; wrapping in other + // elements still opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + dcFlatten(children).forEach((sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + dcFlatten(sec.props.children).forEach((ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's + // marginBottom) reflow on every scale change, vertically shifting the + // world layout — so a world point mathematically pinned under the cursor + // drifts as you zoom (content creeps up on zoom-in, down on zoom-out). + // Anchor the DOM element under the cursor instead: record its screen Y, + // apply the transform + --dc-inv-zoom, then cancel whatever vertical + // drift the reflow introduced so it stays put on screen. + let marker = null, markerY0 = 0; + if (k !== 1) { + const hit = document.elementFromPoint(cx, cy); + marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null; + if (marker) markerY0 = marker.getBoundingClientRect().top; + } + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + if (marker) { + // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any + // departure after the --dc-inv-zoom reflow is the layout drift. + const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k); + if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); } + } + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(dcFlatten(children)); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..46a0f3d --- /dev/null +++ b/editor.html @@ -0,0 +1,362 @@ + + + + + + Board post editor · Fenja AI + + + + + + + + + + +
+ + + diff --git a/editor.jsx b/editor.jsx new file mode 100644 index 0000000..3273654 --- /dev/null +++ b/editor.jsx @@ -0,0 +1,340 @@ +// editor.jsx — Board post editor. +// +// Two-column workspace: a form on the left to edit headline/subtitle and +// the 8 member entries (text + photo upload), and a sticky preview on the +// right that scales the live 1200×1200 post to 540×540 for display. A +// hidden, unscaled clone of the post sits off-screen and is what gets +// passed to html-to-image at download time, so the exported PNG is a +// pixel-clean 2400×2400 (pixelRatio 2 over the 1200 source). +// +// Everything persists to localStorage on every change — refresh-safe. +// Uploaded photos are downscaled to ~800px and re-encoded as JPEG before +// being stored, so eight portraits still fit comfortably under the 5MB +// localStorage cap. + +const { useState, useEffect, useRef, useCallback } = React; + +const STORAGE_KEY = 'fenja-board-data-v1'; +const MIGRATION_KEY = 'fenja-board-migrations'; + +// Apply one-time data migrations. Each entry runs once per browser. +function applyMigrations(parsed) { + let applied = []; + try { applied = JSON.parse(localStorage.getItem(MIGRATION_KEY) || '[]'); } catch {} + + // 2026-05-swap-34-67: user asked to swap positions 3↔6 and 4↔7. + if (!applied.includes('2026-05-swap-34-67') && parsed?.members?.length === 8) { + const m = parsed.members.slice(); + [m[2], m[5]] = [m[5], m[2]]; // index 2 ↔ 5 (positions 3 ↔ 6) + [m[3], m[6]] = [m[6], m[3]]; // index 3 ↔ 6 (positions 4 ↔ 7) + parsed = { ...parsed, members: m }; + applied.push('2026-05-swap-34-67'); + } + + try { localStorage.setItem(MIGRATION_KEY, JSON.stringify(applied)); } catch {} + return parsed; +} + +const DEFAULT_DATA = { + headlineBefore: 'Meet the Fenja AI', + headlineEm: 'Advisory Board', + subtitle: 'Bridging Industry & Sovereign AI', + members: [ + { name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]', photo: null }, + { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]', photo: null }, + { name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]', photo: null }, + { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]', photo: null }, + { name: '[ Full Name ]', title: 'Founder', company: '[ Company ]', photo: null }, + ], +}; + +// ──────────────────────────────────────────────────────────────────────── +// Image compression — JPEG at max 800px to keep localStorage happy +// ──────────────────────────────────────────────────────────────────────── +async function compressImage(file, maxDim = 800, quality = 0.88) { + const url = URL.createObjectURL(file); + try { + const img = new Image(); + await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; }); + const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); + const w = Math.round(img.width * scale); + const h = Math.round(img.height * scale); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL('image/jpeg', quality); + } finally { + URL.revokeObjectURL(url); + } +} + +// ──────────────────────────────────────────────────────────────────────── +// The post itself — mirrors Variation A in index.html. Renders into a +// 1200×1200 box; CSS lives in editor.html so it applies to both the +// visible scaled preview and the hidden capture host. +// ──────────────────────────────────────────────────────────────────────── +function BoardPost({ data }) { + return ( +
+
+
+

+ {data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''} + {data.headlineEm ? {data.headlineEm} : null} +

+ {data.subtitle ?
{data.subtitle}
: null} +
+ +
+ {data.members.map((m, i) => ( +
+ {m.photo + ? + :
Portrait
} +
{m.name}
+
{m.title}
+
{m.company}
+
+ ))} +
+ +
+
+ Fenja AI +
+
+
+
+ ); +} + +// ──────────────────────────────────────────────────────────────────────── +// Editor +// ──────────────────────────────────────────────────────────────────────── +function EditorApp() { + const [data, setData] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + let parsed = JSON.parse(saved); + parsed = applyMigrations(parsed); + // Merge with defaults to handle schema additions + return { + ...DEFAULT_DATA, + ...parsed, + members: parsed.members && parsed.members.length === 8 + ? parsed.members + : DEFAULT_DATA.members, + }; + } + } catch {} + return DEFAULT_DATA; + }); + + const [downloading, setDownloading] = useState(false); + const captureRef = useRef(null); + + // Persist on every change + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (err) { + console.warn('localStorage save failed', err); + } + }, [data]); + + const updateField = (key, value) => setData(d => ({ ...d, [key]: value })); + const updateMember = (i, key, value) => setData(d => ({ + ...d, + members: d.members.map((m, idx) => idx === i ? { ...m, [key]: value } : m), + })); + + const onPhoto = async (i, file) => { + if (!file) return; + try { + const dataUrl = await compressImage(file); + updateMember(i, 'photo', dataUrl); + } catch (err) { + console.error('Image processing failed', err); + alert('Could not read that image. Try another file?'); + } + }; + + const onDownload = async () => { + if (!captureRef.current) return; + setDownloading(true); + try { + // Make sure fonts are loaded before capture, otherwise the headline + // falls back to Times in the rendered PNG. + if (document.fonts && document.fonts.ready) { + await document.fonts.ready; + } + const dataUrl = await window.htmlToImage.toPng(captureRef.current, { + pixelRatio: 2, + width: 1200, + height: 1200, + cacheBust: true, + backgroundColor: '#faf6ee', + }); + const link = document.createElement('a'); + link.download = `fenja-advisory-board-${new Date().toISOString().slice(0,10)}.png`; + link.href = dataUrl; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (err) { + console.error('Export failed', err); + alert('Export failed. Check the console for details.'); + } finally { + setDownloading(false); + } + }; + + const onReset = () => { + if (confirm('Reset to defaults? Your edits and uploaded photos will be cleared.')) { + setData(DEFAULT_DATA); + } + }; + + return ( + <> +
+
+ Fenja AI +
Board post editor
+
+
+ Compare layouts + + +
+
+ +
+
+

Edit the post.

+

Fill in the headline, subtitle, and the eight members. The preview updates as you type.

+ +
+

Headline

+
+
+ + updateField('headlineBefore', e.target.value)} + placeholder="Meet the Fenja AI" + /> +
+
+ + updateField('headlineEm', e.target.value)} + placeholder="Advisory Board" + /> +
The terminal phrase, rendered in serif italic bold.
+
+
+
+ + updateField('subtitle', e.target.value)} + placeholder="Bridging Industry & Sovereign AI" + /> +
+
+ +
+

Members · 8 portraits

+ {data.members.map((m, i) => ( +
+
+
{String(i + 1).padStart(2, '0')}
+ +
+
+
+ + updateMember(i, 'name', e.target.value)} + /> +
+
+ + updateMember(i, 'title', e.target.value)} + /> +
+
+ + updateMember(i, 'company', e.target.value)} + /> +
+
+
+ ))} +
+
+ + +
+ + {/* Hidden full-size capture target — what html-to-image actually reads. */} + + + ); +} diff --git a/fonts/Manrope-Bold.ttf b/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000..62a6183 Binary files /dev/null and b/fonts/Manrope-Bold.ttf differ diff --git a/fonts/Manrope-ExtraBold.ttf b/fonts/Manrope-ExtraBold.ttf new file mode 100644 index 0000000..2fa671c Binary files /dev/null and b/fonts/Manrope-ExtraBold.ttf differ diff --git a/fonts/Manrope-ExtraLight.ttf b/fonts/Manrope-ExtraLight.ttf new file mode 100644 index 0000000..c55745a Binary files /dev/null and b/fonts/Manrope-ExtraLight.ttf differ diff --git a/fonts/Manrope-Light.ttf b/fonts/Manrope-Light.ttf new file mode 100644 index 0000000..8a771c2 Binary files /dev/null and b/fonts/Manrope-Light.ttf differ diff --git a/fonts/Manrope-Medium.ttf b/fonts/Manrope-Medium.ttf new file mode 100644 index 0000000..c6d28de Binary files /dev/null and b/fonts/Manrope-Medium.ttf differ diff --git a/fonts/Manrope-Regular.ttf b/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000..9a108f1 Binary files /dev/null and b/fonts/Manrope-Regular.ttf differ diff --git a/fonts/Manrope-SemiBold.ttf b/fonts/Manrope-SemiBold.ttf new file mode 100644 index 0000000..46a13d6 Binary files /dev/null and b/fonts/Manrope-SemiBold.ttf differ diff --git a/fonts/Newsreader-Bold.ttf b/fonts/Newsreader-Bold.ttf new file mode 100644 index 0000000..6d9e20a Binary files /dev/null and b/fonts/Newsreader-Bold.ttf differ diff --git a/fonts/Newsreader-BoldItalic.ttf b/fonts/Newsreader-BoldItalic.ttf new file mode 100644 index 0000000..bc57925 Binary files /dev/null and b/fonts/Newsreader-BoldItalic.ttf differ diff --git a/fonts/Newsreader-ExtraBold.ttf b/fonts/Newsreader-ExtraBold.ttf new file mode 100644 index 0000000..69a726d Binary files /dev/null and b/fonts/Newsreader-ExtraBold.ttf differ diff --git a/fonts/Newsreader-Italic.ttf b/fonts/Newsreader-Italic.ttf new file mode 100644 index 0000000..477facd Binary files /dev/null and b/fonts/Newsreader-Italic.ttf differ diff --git a/fonts/Newsreader-Regular.ttf b/fonts/Newsreader-Regular.ttf new file mode 100644 index 0000000..9fe7694 Binary files /dev/null and b/fonts/Newsreader-Regular.ttf differ diff --git a/image-slot.js b/image-slot.js new file mode 100644 index 0000000..d1eb01b --- /dev/null +++ b/image-slot.js @@ -0,0 +1,641 @@ +/** + * — user-fillable image placeholder. + * + * Drop this into a deck, mockup, or page wherever you want the user to + * supply an image. You control the slot's shape and size; the user fills it + * by dragging an image file onto it (or clicking to browse). The dropped + * image persists across reloads via a .image-slots.state.json sidecar — + * same read-via-fetch / write-via-window.omelette pattern as + * design_canvas.jsx, so the filled slot shows on share links, downloaded + * zips, and PPTX export. Outside the omelette runtime the slot is read-only. + * + * The host bridge only allows sidecar writes at the project root, so the + * HTML that uses this component is assumed to live at the project root too + * (same constraint as design_canvas.jsx). + * + * Attributes: + * id Persistence key. REQUIRED for the drop to survive reload — + * every slot on the page needs a distinct id. + * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') + * 'circle' applies 50% border-radius; on a non-square slot + * that's an ellipse — set equal width and height for a true + * circle. + * radius Corner radius in px for 'rounded'. (default 12) + * mask Any CSS clip-path value. Overrides `shape` — use this for + * hexagons, blobs, arbitrary polygons. + * fit object-fit: cover | contain | fill. (default 'cover') + * With cover (the default) double-clicking the filled slot + * enters a reframe mode: the whole image spills past the mask + * (translucent outside, opaque inside), drag to reposition, + * corner-drag to scale. The crop persists alongside the image + * in the sidecar. contain/fill stay static. + * position object-position for fit=contain|fill. (default '50% 50%') + * placeholder Empty-state caption. (default 'Drop an image') + * src Optional initial/fallback image URL. A user drop overrides + * it; clearing the drop reveals src again. + * + * Size and layout come from ordinary CSS on the element — width/height + * inline or from a parent grid — so it composes with any layout. + * + * Usage: + * + * + * + * + */ + +(() => { + const STATE_FILE = '.image-slots.state.json'; + // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the + // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. + const MAX_DIM = 1200; + // Raster formats only. SVG is excluded (can carry script; createImageBitmap + // on SVG blobs is inconsistent). GIF is excluded because the canvas + // re-encode keeps only the first frame, so an animated GIF would silently + // go still — better to reject than surprise. + const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; + + // ── Shared sidecar store ──────────────────────────────────────────────── + // One fetch + immediate write-on-change for every on the + // page. Reads via fetch() so viewing works anywhere the HTML and sidecar + // are served together; writes go through window.omelette.writeFile, which + // the host allowlists to *.state.json basenames only. + const subs = new Set(); + let slots = {}; + // ids explicitly cleared before the sidecar fetch resolved — otherwise + // the merge below can't tell "never set" from "just deleted" and would + // resurrect the sidecar's stale value. + const tombstones = new Set(); + let loaded = false; + let loadP = null; + + function load() { + if (loadP) return loadP; + loadP = fetch(STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + // Merge: sidecar loses to any in-memory change that raced ahead of + // the fetch (drop or clear) so neither is clobbered by hydration. + if (j && typeof j === 'object') { + const merged = Object.assign({}, j, slots); + // A framing-only write that raced ahead of hydration must not + // drop a user image that's only on disk — inherit u from the + // sidecar for any in-memory entry that lacks one. + for (const k in slots) { + if (merged[k] && !merged[k].u && j[k]) { + merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; + } + } + for (const id of tombstones) delete merged[id]; + slots = merged; + } + tombstones.clear(); + }) + .catch(() => {}) + .then(() => { loaded = true; subs.forEach((fn) => fn()); }); + return loadP; + } + + // Serialize writes so two near-simultaneous drops on different slots + // can't reorder at the backend and leave the sidecar with only the + // first. A save requested mid-flight just marks dirty and re-fires on + // completion with the then-current slots. + let saving = false; + let saveDirty = false; + function save() { + if (saving) { saveDirty = true; return; } + const w = window.omelette && window.omelette.writeFile; + if (!w) return; + saving = true; + Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) + .catch(() => {}) + .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); + } + + const S_MAX = 5; + const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); + + // Normalize a stored slot value. Pre-reframe sidecars stored a bare + // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. + function getSlot(id) { + const v = slots[id]; + if (!v) return null; + return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; + } + + function setSlot(id, val) { + if (!id) return; + if (val) { slots[id] = val; tombstones.delete(id); } + else { delete slots[id]; if (!loaded) tombstones.add(id); } + subs.forEach((fn) => fn()); + // A drop is rare + high-value — write immediately so nav-away can't lose + // it. Gate on the initial read so we don't overwrite a sidecar we haven't + // merged yet; the merge in load() keeps this change once the read lands. + if (loaded) save(); else load().then(save); + } + + // ── Image downscale ───────────────────────────────────────────────────── + // Encode through a canvas so the sidecar carries resized bytes, not the + // raw upload. Longest side is capped at 2× the slot's rendered width + // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG + // for photos, so there's no need for per-image format picking. + async function toDataUrl(file, targetW) { + const bitmap = await createImageBitmap(file); + try { + const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); + const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); + const w = Math.max(1, Math.round(bitmap.width * scale)); + const h = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); + return canvas.toDataURL('image/webp', 0.85); + } finally { + bitmap.close && bitmap.close(); + } + } + + // ── Custom element ────────────────────────────────────────────────────── + const stylesheet = + ':host{display:inline-block;position:relative;vertical-align:top;' + + ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' + + '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' + + // .frame img (clipped) and .spill (unclipped ghost + handles) share the + // same left/top/width/height in frame-%, computed by _applyView(), so the + // inside-mask crop and the outside-mask spill stay pixel-aligned. + '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + + ' -webkit-user-drag:none;user-select:none;touch-action:none}' + + // Reframe mode (double-click): the full image spills past the mask. The + // spill layer is sized to the IMAGE bounds so its corners are where the + // resize handles belong. The ghost inside is translucent; the real + // clipped underneath shows the opaque in-mask crop. + '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' + + ' cursor:grab;touch-action:none}' + + ':host([data-panning]) .spill{cursor:grabbing}' + + '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + + ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + + ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + + '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + + ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + + ' transform:translate(-50%,-50%)}' + + '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + + '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + + '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + + '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + + ':host([data-reframe]){z-index:10}' + + ':host([data-reframe]) .spill{display:block}' + + ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + + '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + + ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + + ' cursor:pointer;user-select:none}' + + '.empty svg{opacity:.45}' + + '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + + '.empty .sub{font-size:11px}' + + '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' + + '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' + + ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + + ' background:rgba(201,100,66,.10)}' + + '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' + + ' transition:border-color .12s}' + + ':host([data-over]) .ring{border-color:#c96442}' + + ':host([data-filled]) .ring{display:none}' + + // Controls sit BELOW the mask (top:100%), absolutely positioned so the + // author-declared slot height is unaffected. The gap is padding, not a + // top offset, so the hover target stays contiguous with the frame. + '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' + + ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + + ' white-space:nowrap}' + + ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + + ' {opacity:1;pointer-events:auto}' + + '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + + ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + + ' backdrop-filter:blur(6px)}' + + '.ctl button:hover{background:rgba(0,0,0,.8)}' + + '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + + ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}'; + + const icon = + '' + + '' + + ''; + + class ImageSlot extends HTMLElement { + static get observedAttributes() { + return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id']; + } + + constructor() { + super(); + const root = this.attachShadow({ mode: 'open' }); + // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius + // on the frame (circle, pill, rounded) can't clip them. + root.innerHTML = + '' + + '
' + + ' ' + + '
' + icon + + '
' + + '
or browse files
' + + '
' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (act === 'replace') { this._exitReframe(true); this._input.click(); } + if (act === 'clear') { + this._exitReframe(false); + this._gen++; + this._local = null; + if (this.id) setSlot(this.id, null); else this._render(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + this._img.addEventListener('load', () => this._applyView()); + // Gated on editable + fit=cover so share links and contain/fill slots + // stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const base = Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (commit) this._commitView(); + } + + attributeChangedCallback() { if (this.shadowRoot) this._render(); } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill + // keep the old object-fit path and double-click is a no-op. + _reframes() { + return this.hasAttribute('data-filled') && + (this.getAttribute('fit') || 'cover') === 'cover'; + } + + // Cover-baseline geometry, shared by clamp/apply/resize. Null until the + // img has loaded (naturalWidth is 0 before that) or when the slot has no + // layout box — ResizeObserver fires with a 0×0 rect under display:none, + // and clamping against a degenerate 1×1 frame would silently pull the + // stored pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + const fit = this.getAttribute('fit') || 'cover'; + if (fit !== 'cover' || !g) { + // Non-cover, or dimensions not known yet (before img load). + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = fit; + this._img.style.objectPosition = this.getAttribute('position') || '50% 50%'; + return; + } + // Cover baseline: img fills the frame on its tighter axis at s=1, so + // pan works immediately on the overflowing axis without zooming first. + // Width/height and left/top are all frame-% — depends only on the + // frame aspect ratio, so a responsive resize keeps the same crop. The + // spill layer mirrors the same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + this._spill.style.width = w; this._spill.style.height = h; + this._spill.style.left = l; this._spill.style.top = t; + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + if (url) { + if (this._img.getAttribute('src') !== url) { + this._img.src = url; + this._ghost.src = url; + } + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + this._empty.style.display = 'flex'; + this.removeAttribute('data-filled'); + } + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..77cb9a9 --- /dev/null +++ b/index.html @@ -0,0 +1,251 @@ + + + + + + Board post · Fenja AI + + + + + + + + + + + +
+ + + diff --git a/screenshots/01-diag-overview.png b/screenshots/01-diag-overview.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/screenshots/01-diag-overview.png differ diff --git a/screenshots/02-diag-overview.png b/screenshots/02-diag-overview.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/screenshots/02-diag-overview.png differ diff --git a/screenshots/diag-a.png b/screenshots/diag-a.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/screenshots/diag-a.png differ diff --git a/screenshots/diag-canvas.png b/screenshots/diag-canvas.png new file mode 100644 index 0000000..3eae68a Binary files /dev/null and b/screenshots/diag-canvas.png differ diff --git a/uploads/fenja-logo-1000x1000.png b/uploads/fenja-logo-1000x1000.png new file mode 100644 index 0000000..7982b9b Binary files /dev/null and b/uploads/fenja-logo-1000x1000.png differ