rework
|
|
@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
.image-slots.state.json
Normal file
BIN
advisory board roster/Anna.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
advisory board roster/Mads(2).png
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
advisory board roster/Mathies.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
advisory board roster/Torben(2).png
Normal file
|
After Width: | Height: | Size: 438 KiB |
BIN
advisory board roster/Ulla(1).png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
advisory board roster/Untitled design(6).png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
advisory board roster/Williamv3.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
advisory board roster/fenja-advisory-board-2026-05-28(2).png
Normal file
|
After Width: | Height: | Size: 2 MiB |
BIN
advisory board roster/sørenv2.png
Normal file
|
After Width: | Height: | Size: 370 KiB |
1
advisory-board-post/.image-slots.state.json
Normal file
16
advisory-board-post/assets/fenja-icon-black.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0"?>
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="67.281601mm" height="101.04877mm" viewBox="0 0 67.281601 101.04877">
|
||||
|
||||
<defs id="defs1"></defs>
|
||||
<g id="g15" transform="translate(-13.97477,83.473269)">
|
||||
<a id="a13" transform="matrix(0.46160656,0,0,0.46160656,54.848979,-49.979453)" style="fill:#383831;fill-opacity:1">
|
||||
<path id="path11" style="fill:#383831;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.30066;stroke-opacity:1" d="m -18.338285,-72.536938 c -13.74121,-0.46332 -27.67429,6.23915 -30.62927,23.99965 -3.78543,22.75175 -2.02308,27.28708 -8.49358,33.40113 -0.33172,0.31344 -0.68442,0.6305 -1.06128,0.95492 -1.45347,1.25122 -3.25669,2.60442 -5.51573,4.2115298 -0.21828,0.14166 -1.46889,0.95375 -2.77858,1.80461 -1.30968,0.85087 -3.09547,2.00147 -3.96859,2.55692 -9.71464,6.18014098 -15.73026,14.105601 -17.17523,22.6293912 -1.02392,4.39944 -0.58622,8.85898 0.37503,13.22902 1.54872,6.59143 5.53649,12.05697 9.86047,17.09239 2.10267,2.45313 2.9744,3.01949 3.52864,2.28936 0.089,-0.1173 2.43368,-7.4904 2.6364,-8.32454 0.90846,-3.73797 6.03327,-17.41091 6.72254,-17.93536 0.0214,-0.0163 4.38159,-8.60415 9.79779,-13.18984 12.53397,-9.45828022 27.77089,-18.9996112 33.33396,-34.567641 1.70768,-3.98815 2.58233,-8.2783 3.4637,-12.5215 1.47952,-7.35274 1.14634,-6.88747 7.684191,-10.7292 3.3339299,-1.64872 11.7042296,-6.72938 14.3328396,-17.16852 -5.69305,-4.59707 -13.8685796,-7.45433 -22.1133006,-7.73232 z m 26.2106406,12.16775 c -3.51268,9.34765 -11.83195,14.63899 -19.9974706,19.49145 -3.54056,28.19346 -19.23094,40.45143078 -30.31805,48.2108808 -13.25397,9.2758802 -16.49234,12.7560002 -23.19025,33.7649602 -3.72715,11.69072 -4.08522,12.85871 -5.15862,16.80132 -1.52273,4.79107 -2.39504,9.73205 -3.84209,14.55226 -3.97772,13.24999 10.48988,14.09194 36.47077,21.58154 1.74902,0.5042 4.35169,0.92086 4.02345,3.1704 -0.41262,2.827837 -3.00384,1.83581 -4.71193,1.46542 -10.43263,-2.26228 -5.67223,-1.15531 -8.49246,-1.80574 -4.00394,-0.92343 -11.86327,-2.93816 -11.96288,-2.97561 l -17.4092,51.835787 c -0.43014,1.28076 100.90249,0.22691 100.84821,0.0179 L 10.256865,108.20597 C 8.1236656,102.43489 5.2017556,97.549383 2.6767956,96.060613 c -2.75173002,-1.62248 -3.67789,-5.99164 -4.13428,-8.36147 -1.89414,-12.73531 1.04806998,-26.48308 4.6067,-38.79484 1.38342,-4.44117 1.10419,-13.58033 -0.54407,-17.79319 -4.2184,-10.76178 -11.4085797,-16.37966 -12.3244697,-21.3654802 -0.6082799,-3.3112 -2.5646909,-15.328081 0.33584,-18.973131 4.31882,-2.9199098 12.4757697,2.32522 16.8572997,0.49369 1.6069297,-0.67171 2.0929297,-2.3048398 3.1144194,-10.4661198 0.56384,-6.48024 2.34196,-4.45706 7.26214,-7.03824 2.14367,-1.12459 -2.14355,-4.95226 -4.3649,-10.84786 -1.16393,-2.1724 -1.1814,-4.35053 -1.32324,-6.74605 -0.29538,-4.98862 -1.24145,-10.24241 -2.14382,-11.90355 -0.1195197,-0.22003 -0.6508697,-1.35245 -1.1810597,-2.51662 z m 41.0235494,102.804361 -0.0843,22.31145 c 0,0 6.63439,-5.88778 6.70402,-11.44383 0.0579,-4.61641 0.1215,-9.12217 -6.61974,-10.86762 z m -11.93626,6.25328 c -3.27055,3.98088 -10.63179,15.5967 -14.10067,15.89769 -3.04292,-0.0427 -8.77591,-7.53888 -12.15207,-10.91504 -2.5200694,-2.52007 -2.5659994,-3.02163 -3.0181494,-0.60564 -0.1361,0.72723 -3.74905,14.80249 -3.46483,16.10835 0.31925,1.4668 15.8249894,14.70054 18.0540294,14.7325 1.73011,0.0248 9.12832,-7.22389 14.32985,-11.99196 z"></path>
|
||||
<g id="g13" transform="matrix(2.1663471,0,0,2.1663471,-1753.1362,-1353.9929)" style="fill:#383831;fill-opacity:1">
|
||||
<path d="m 878.69555,672.48093 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.43837,3.6537 3.06886,5.41425 1.4106,3.93889 2.92103,7.93859 3.80654,12.39384 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 5.56183,-80.2446 c -0.0412,-0.84401 -3.88414,-15.01284 -4.55238,-15.02374 -0.66824,-0.0109 -4.16166,13.94629 -4.24679,15.07702 -0.0851,1.13073 2.81161,3.011 4.40097,2.98866 1.58936,-0.0223 4.43941,-2.19793 4.3982,-3.04194 z" style="display:none;fill:#383831;fill-opacity:1;stroke-width:0.461607" id="path12"></path>
|
||||
<path d="m 827.63002,617.21307 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.3964,3.19205 3.02689,4.9526 1.4106,3.93889 2.963,8.40024 3.84851,12.85549 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 4.45265,-80.44426 c -0.0412,-0.84401 -2.77496,-12.90896 -3.4432,-12.91986 -0.66824,-0.0109 -2.72753,11.69192 -2.81266,12.82265 -0.0851,1.13073 1.50339,1.72438 3.09275,1.70204 1.58936,-0.0223 3.20432,-0.76082 3.16311,-1.60483 z" style="fill:#383831;fill-opacity:1;stroke-width:0.461607" id="path13"></path>
|
||||
</g>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
20
advisory-board-post/assets/fenja-icon-white.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0"?>
|
||||
|
||||
|
||||
<svg width="67.281601mm" height="101.04877mm" viewBox="13 0 67.281601 101.04877" version="1.1" id="svg1" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi-0.dtd="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<namedview id="namedview1" pagecolor="#ffffff" bordercolor="#eeeeee" borderopacity="1" labelstyle="default" showgrid="true">
|
||||
<grid id="grid1" units="mm" originx="-11.288788" originy="-408.06888" spacingx="1" spacingy="0.99999998" empcolor="#0099e5" empopacity="0.30196078" color="#0099e5" opacity="0.14901961" empspacing="5" enabled="true" visible="false"></grid>
|
||||
<grid id="grid2" units="mm" originx="-11.288788" originy="-408.06888" spacingx="1" spacingy="0.99999998" empcolor="#0099e5" empopacity="0.30196078" color="#0099e5" opacity="0.14901961" empspacing="5" enabled="false" visible="true"></grid>
|
||||
<page x="0" y="-5.2362184e-13" width="67.281601" height="101.04877" id="page1" margin="0" bleed="0"></page>
|
||||
</namedview>
|
||||
<defs id="defs1"></defs>
|
||||
<g id="g11" transform="translate(-13.97477,83.473269)">
|
||||
<a id="a9" transform="matrix(0.46160656,0,0,0.46160656,54.848979,-49.979453)" style="fill:#fffcf7;fill-opacity:1">
|
||||
<path id="path7" style="fill:#fffcf7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.30066;stroke-opacity:1" d="m -18.338285,-72.536938 c -13.74121,-0.46332 -27.67429,6.23915 -30.62927,23.99965 -3.78543,22.75175 -2.02308,27.28708 -8.49358,33.40113 -0.33172,0.31344 -0.68442,0.6305 -1.06128,0.95492 -1.45347,1.25122 -3.25669,2.60442 -5.51573,4.2115298 -0.21828,0.14166 -1.46889,0.95375 -2.77858,1.80461 -1.30968,0.85087 -3.09547,2.00147 -3.96859,2.55692 -9.71464,6.18014098 -15.73026,14.105601 -17.17523,22.6293912 -1.02392,4.39944 -0.58622,8.85898 0.37503,13.22902 1.54872,6.59143 5.53649,12.05697 9.86047,17.09239 2.10267,2.45313 2.9744,3.01949 3.52864,2.28936 0.089,-0.1173 2.43368,-7.4904 2.6364,-8.32454 0.90846,-3.73797 6.03327,-17.41091 6.72254,-17.93536 0.0214,-0.0163 4.38159,-8.60415 9.79779,-13.18984 12.53397,-9.45828022 27.77089,-18.9996112 33.33396,-34.567641 1.70768,-3.98815 2.58233,-8.2783 3.4637,-12.5215 1.47952,-7.35274 1.14634,-6.88747 7.684191,-10.7292 3.3339299,-1.64872 11.7042296,-6.72938 14.3328396,-17.16852 -5.69305,-4.59707 -13.8685796,-7.45433 -22.1133006,-7.73232 z m 26.2106406,12.16775 c -3.51268,9.34765 -11.83195,14.63899 -19.9974706,19.49145 -3.54056,28.19346 -19.23094,40.45143078 -30.31805,48.2108808 -13.25397,9.2758802 -16.49234,12.7560002 -23.19025,33.7649602 -3.72715,11.69072 -4.08522,12.85871 -5.15862,16.80132 -1.52273,4.79107 -2.39504,9.73205 -3.84209,14.55226 -3.97772,13.24999 10.48988,14.09194 36.47077,21.58154 1.74902,0.5042 4.35169,0.92086 4.02345,3.1704 -0.41262,2.827837 -3.00384,1.83581 -4.71193,1.46542 -10.43263,-2.26228 -5.67223,-1.15531 -8.49246,-1.80574 -4.00394,-0.92343 -11.86327,-2.93816 -11.96288,-2.97561 l -17.4092,51.835787 c -0.43014,1.28076 100.90249,0.22691 100.84821,0.0179 L 10.256865,108.20597 C 8.1236656,102.43489 5.2017556,97.549383 2.6767956,96.060613 c -2.75173002,-1.62248 -3.67789,-5.99164 -4.13428,-8.36147 -1.89414,-12.73531 1.04806998,-26.48308 4.6067,-38.79484 1.38342,-4.44117 1.10419,-13.58033 -0.54407,-17.79319 -4.2184,-10.76178 -11.4085797,-16.37966 -12.3244697,-21.3654802 -0.6082799,-3.3112 -2.5646909,-15.328081 0.33584,-18.973131 4.31882,-2.9199098 12.4757697,2.32522 16.8572997,0.49369 1.6069297,-0.67171 2.0929297,-2.3048398 3.1144194,-10.4661198 0.56384,-6.48024 2.34196,-4.45706 7.26214,-7.03824 2.14367,-1.12459 -2.14355,-4.95226 -4.3649,-10.84786 -1.16393,-2.1724 -1.1814,-4.35053 -1.32324,-6.74605 -0.29538,-4.98862 -1.24145,-10.24241 -2.14382,-11.90355 -0.1195197,-0.22003 -0.6508697,-1.35245 -1.1810597,-2.51662 z m 41.0235494,102.804361 -0.0843,22.31145 c 0,0 6.63439,-5.88778 6.70402,-11.44383 0.0579,-4.61641 0.1215,-9.12217 -6.61974,-10.86762 z m -11.93626,6.25328 c -3.27055,3.98088 -10.63179,15.5967 -14.10067,15.89769 -3.04292,-0.0427 -8.77591,-7.53888 -12.15207,-10.91504 -2.5200694,-2.52007 -2.5659994,-3.02163 -3.0181494,-0.60564 -0.1361,0.72723 -3.74905,14.80249 -3.46483,16.10835 0.31925,1.4668 15.8249894,14.70054 18.0540294,14.7325 1.73011,0.0248 9.12832,-7.22389 14.32985,-11.99196 z"></path>
|
||||
<g id="g9" transform="matrix(2.1663471,0,0,2.1663471,-1753.1362,-1353.9929)" style="fill:#fffcf7;fill-opacity:1">
|
||||
<path d="m 878.69555,672.48093 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.43837,3.6537 3.06886,5.41425 1.4106,3.93889 2.92103,7.93859 3.80654,12.39384 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 5.56183,-80.2446 c -0.0412,-0.84401 -3.88414,-15.01284 -4.55238,-15.02374 -0.66824,-0.0109 -4.16166,13.94629 -4.24679,15.07702 -0.0851,1.13073 2.81161,3.011 4.40097,2.98866 1.58936,-0.0223 4.43941,-2.19793 4.3982,-3.04194 z" style="display:none;fill:#fffcf7;fill-opacity:1;stroke-width:0.461607" id="path8"></path>
|
||||
<path d="m 827.63002,617.21307 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.3964,3.19205 3.02689,4.9526 1.4106,3.93889 2.963,8.40024 3.84851,12.85549 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 4.45265,-80.44426 c -0.0412,-0.84401 -2.77496,-12.90896 -3.4432,-12.91986 -0.66824,-0.0109 -2.72753,11.69192 -2.81266,12.82265 -0.0851,1.13073 1.50339,1.72438 3.09275,1.70204 1.58936,-0.0223 3.20432,-0.76082 3.16311,-1.60483 z" style="fill:#fffcf7;fill-opacity:1;stroke-width:0.461607" id="path9"></path>
|
||||
</g>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
advisory-board-post/assets/fenja-logo-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
19
advisory-board-post/assets/fenja-wordmark-black.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
23
advisory-board-post/assets/fenja-wordmark-white.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
advisory-board-post/assets/reference-boulderer.png
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
advisory-board-post/assets/reference-flowerman-ochre.png
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
BIN
advisory-board-post/assets/reference-flowerman-white.png
Normal file
|
After Width: | Height: | Size: 958 KiB |
BIN
advisory-board-post/assets/reference-waves.png
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
258
advisory-board-post/board-posts.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="member" style={{ textAlign: captionAlign }}>
|
||||
<image-slot
|
||||
id={m.id}
|
||||
shape="rounded"
|
||||
radius="4"
|
||||
placeholder="Drop portrait"
|
||||
style={{ width: '100%', height: size + 'px', display: 'block' }}
|
||||
/>
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Footer brand mark — used across variations
|
||||
function Mark({ light = false }) {
|
||||
return (
|
||||
<div className="mark">
|
||||
<img
|
||||
src="assets/fenja-logo-full.png"
|
||||
alt="Fenja AI"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// A — Editorial Square (1200 × 1200) — clean 4×2 grid
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostA() {
|
||||
return (
|
||||
<div className="post a-root">
|
||||
<div className="a-head">
|
||||
<h1>Meet the Fenja AI <em>Advisory Board</em></h1>
|
||||
<div className="subtitle">Bridging Industry & Sovereign AI</div>
|
||||
</div>
|
||||
|
||||
<div className="a-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={210} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="a-foot">
|
||||
<Mark />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// B — Catalogue Index (1080 × 1350) — numbered vertical list
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostB() {
|
||||
return (
|
||||
<div className="post b-root">
|
||||
<div className="b-head">
|
||||
<div className="left">
|
||||
<p className="eyebrow"><span className="rule" />A note from leadership</p>
|
||||
<h1 style={{ marginTop: 26 }}>Our <em>board.</em></h1>
|
||||
<p className="lede b-lede">
|
||||
Eight quiet experts, each chosen for their depth and discretion.
|
||||
We are grateful they said yes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="right">
|
||||
<div className="mark">
|
||||
<img src="assets/fenja-icon-black.svg" alt="" />
|
||||
<span>Fenja AI</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 18, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
|
||||
color: 'var(--on-surface-muted)', fontSize: 14, letterSpacing: 0,
|
||||
}}>
|
||||
§ 01 — MMXXV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="b-list">
|
||||
{MEMBERS.map((m, i) => (
|
||||
<div className="b-row" key={m.id}>
|
||||
<div className="idx">{String(i + 1).padStart(2, '0')}</div>
|
||||
<image-slot
|
||||
id={m.id}
|
||||
shape="rounded"
|
||||
radius="4"
|
||||
placeholder=""
|
||||
style={{ width: 72, height: 72, display: 'block' }}
|
||||
/>
|
||||
<div className="role">
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="b-foot">
|
||||
<div className="quiet">
|
||||
"A board built the way a good archive is: slowly, with care, and with people you can trust at four in the morning."
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--on-surface-muted)',
|
||||
letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||
}}>
|
||||
fenja.ai / board
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// C — Editorial Landscape (1200 × 627) — left text · right micro grid
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostC() {
|
||||
return (
|
||||
<div className="post c-root">
|
||||
<div className="c-left">
|
||||
<div>
|
||||
<p className="eyebrow"><span className="rule" />Announcement</p>
|
||||
<h1>Introducing our <em>board.</em></h1>
|
||||
<p className="lede">
|
||||
Eight quiet experts, gathered to steward the work ahead — in research,
|
||||
in product, in counsel.
|
||||
</p>
|
||||
</div>
|
||||
<Mark />
|
||||
</div>
|
||||
<div className="c-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={104} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// D — Quiet Cover + Strip (1080 × 1350) — text hero with portrait band
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostD() {
|
||||
return (
|
||||
<div className="post d-root">
|
||||
<div className="d-hero" style={{ position: 'relative' }}>
|
||||
{/* Topographic currents accent — quiet, off-axis */}
|
||||
<svg className="currents" style={{ right: -40, top: 80, width: 360, height: 360 }} viewBox="0 0 360 360" fill="none">
|
||||
{[0,1,2,3,4,5,6,7].map((i) => (
|
||||
<path key={i}
|
||||
d={`M ${20 + i*8} ${180 + i*4} C ${100} ${120 - i*6}, ${260} ${240 + i*4}, ${340 - i*8} ${180 - i*6}`}
|
||||
stroke="#8a887f" strokeWidth="0.8" fill="none" opacity={0.55 - i*0.04}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow"><span className="rule" />Introducing — board of directors · MMXXV</p>
|
||||
<h1>Eight people. <em>One quiet table.</em></h1>
|
||||
<p className="lede">
|
||||
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.
|
||||
</p>
|
||||
<div className="signoff">
|
||||
"A study in stillness, and in counsel. We are grateful, every one of us, that they said yes."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<Mark />
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--on-surface-muted)',
|
||||
letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||
}}>
|
||||
fenja.ai
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-strip">
|
||||
<p className="eyebrow d-strip-label"><span className="rule" />The board, in order of seating</p>
|
||||
<div className="d-strip-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={112} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Canvas
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas title="Board reveal · LinkedIn" subtitle="Drag portraits onto the slots · double-click any text to edit · click ⤢ on an artboard to view fullscreen.">
|
||||
<DCSection
|
||||
id="square"
|
||||
title="Square — 1200 × 1200"
|
||||
subtitle="Standard LinkedIn single-image post. The full grid at a glance."
|
||||
>
|
||||
<DCArtboard id="a" label="A · Editorial grid" width={1200} height={1200}>
|
||||
<PostA />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="portrait"
|
||||
title="Portrait — 1080 × 1350"
|
||||
subtitle="Vertical post. Maximizes feed real estate; best for text-forward variants."
|
||||
>
|
||||
<DCArtboard id="b" label="B · Catalogue index" width={1080} height={1350}>
|
||||
<PostB />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="d" label="D · Quiet cover + strip" width={1080} height={1350}>
|
||||
<PostD />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="landscape"
|
||||
title="Landscape — 1200 × 627"
|
||||
subtitle="Link-preview aspect ratio. Compact, scannable, lives well on desktop feed."
|
||||
>
|
||||
<DCArtboard id="c" label="C · Editorial landscape" width={1200} height={627}>
|
||||
<PostC />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
346
advisory-board-post/colors_and_type.css
Normal file
|
|
@ -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); }
|
||||
966
advisory-board-post/design-canvas.jsx
Normal file
|
|
@ -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:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
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 (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => 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}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 / <img> /
|
||||
// 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 = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-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 = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
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 (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<div onClick={() => 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) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
362
advisory-board-post/editor.html
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Board post editor · Fenja AI</title>
|
||||
<link rel="stylesheet" href="colors_and_type.css" />
|
||||
<style>
|
||||
/* ────────── Editor chrome ────────── */
|
||||
html, body { background: var(--surface-container-low); }
|
||||
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: var(--on-surface); }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: rgba(246, 242, 232, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
padding: 20px 48px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 14px; }
|
||||
.topbar .brand img { height: 28px; }
|
||||
.topbar .brand .title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.topbar .brand .title em { font-style: italic; font-weight: 700; }
|
||||
.topbar .actions { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.btn {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.btn-primary { background: var(--secondary); color: var(--on-secondary); }
|
||||
.btn-primary:hover { background: var(--secondary-dim); }
|
||||
.btn-primary:active { transform: translateY(1px); }
|
||||
.btn-primary[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--on-surface-variant);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--on-surface); }
|
||||
|
||||
/* ────────── Workspace ────────── */
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 56px;
|
||||
padding: 48px;
|
||||
max-width: 1640px;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-col h2 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.form-col .col-sub {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 16px;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section h3 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.field input, .field textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
color: var(--on-surface);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(186, 186, 176, 0.4);
|
||||
padding: 8px 0 10px;
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.field input:focus, .field textarea:focus {
|
||||
border-bottom-color: var(--secondary);
|
||||
}
|
||||
.field .hint {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 22px; }
|
||||
|
||||
/* Member row in the form */
|
||||
.member-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 22px;
|
||||
padding: 22px 0;
|
||||
align-items: start;
|
||||
}
|
||||
.member-row + .member-row {
|
||||
border-top: 1px solid rgba(186, 186, 176, 0.25);
|
||||
}
|
||||
.member-row .number {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Photo dropzone */
|
||||
.dropzone {
|
||||
position: relative;
|
||||
width: 110px; height: 110px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.dropzone:hover { background: var(--surface-container-high); }
|
||||
.dropzone img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.dropzone .ph {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dropzone input[type=file] {
|
||||
position: absolute; inset: 0; opacity: 0; cursor: pointer;
|
||||
}
|
||||
.dropzone.has-image .ph { display: none; }
|
||||
.dropzone .clear {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(56,56,49,0.7); color: #fffcf7;
|
||||
border: 0; border-radius: 999px;
|
||||
width: 22px; height: 22px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
.dropzone.has-image:hover .clear { display: flex; }
|
||||
|
||||
.member-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 22px; }
|
||||
.member-fields .field { margin-bottom: 0; }
|
||||
.member-fields .field.full { grid-column: 1 / -1; }
|
||||
|
||||
/* ────────── Preview column ────────── */
|
||||
.preview-col {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
width: 540px;
|
||||
}
|
||||
.preview-col h3 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-col .pixel-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.preview-frame {
|
||||
width: 540px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-float);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.preview-frame .stage {
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
transform: scale(0.45);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.preview-foot {
|
||||
margin-top: 18px;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--on-surface-muted);
|
||||
line-height: 1.5;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
/* Hidden off-screen capture target — full size, untransformed. */
|
||||
.capture-host {
|
||||
position: fixed;
|
||||
left: -100000px;
|
||||
top: 0;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ────────── Board post styles — mirrors Variation A in index.html ────────── */
|
||||
.post, .post *, .post *::before, .post *::after { box-sizing: border-box; }
|
||||
.post {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.post h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.04;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.post h1 em { font-style: italic; font-weight: 700; }
|
||||
|
||||
.post .subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.post .member { display: flex; flex-direction: column; }
|
||||
.post .member .name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
font-size: 22px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.post .member .title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.post .member .company {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
font-size: 17px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.post .member .portrait {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
.post .member .portrait-empty {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-container-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--on-surface-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.post .a-root { padding: 96px 96px 80px; display: flex; flex-direction: column; height: 100%; }
|
||||
.post .a-head { max-width: 980px; }
|
||||
.post .a-head h1 { font-size: 64px; }
|
||||
.post .a-grid {
|
||||
margin-top: 64px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 36px 28px;
|
||||
}
|
||||
.post .a-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post .mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post .mark img { height: 160px; opacity: 1; display: block; margin-left: -6px; }
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/html-to-image@1.11.13/dist/html-to-image.js"></script>
|
||||
<script type="text/babel" src="editor.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { createRoot } = ReactDOM;
|
||||
createRoot(document.getElementById('root')).render(<EditorApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
340
advisory-board-post/editor.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="post">
|
||||
<div className="a-root">
|
||||
<div className="a-head">
|
||||
<h1>
|
||||
{data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''}
|
||||
{data.headlineEm ? <em>{data.headlineEm}</em> : null}
|
||||
</h1>
|
||||
{data.subtitle ? <div className="subtitle">{data.subtitle}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="a-grid">
|
||||
{data.members.map((m, i) => (
|
||||
<div key={i} className="member">
|
||||
{m.photo
|
||||
? <img className="portrait" src={m.photo} alt="" />
|
||||
: <div className="portrait-empty">Portrait</div>}
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="a-foot">
|
||||
<div className="mark">
|
||||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" style={{ height: 56, marginLeft: -14 }} />
|
||||
<div className="title">Board post <em>editor</em></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<a className="btn btn-ghost" href="index.html">Compare layouts</a>
|
||||
<button className="btn btn-ghost" onClick={onReset}>Reset</button>
|
||||
<button className="btn btn-primary" onClick={onDownload} disabled={downloading}>
|
||||
{downloading ? 'Rendering…' : 'Download PNG'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="workspace">
|
||||
<section className="form-col">
|
||||
<h2>Edit the post.</h2>
|
||||
<p className="col-sub">Fill in the headline, subtitle, and the eight members. The preview updates as you type.</p>
|
||||
|
||||
<div className="section">
|
||||
<h3>Headline</h3>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Opening</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headlineBefore}
|
||||
onChange={e => updateField('headlineBefore', e.target.value)}
|
||||
placeholder="Meet the Fenja AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Italicized closer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headlineEm}
|
||||
onChange={e => updateField('headlineEm', e.target.value)}
|
||||
placeholder="Advisory Board"
|
||||
/>
|
||||
<div className="hint">The terminal phrase, rendered in serif italic bold.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 18 }}>
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.subtitle}
|
||||
onChange={e => updateField('subtitle', e.target.value)}
|
||||
placeholder="Bridging Industry & Sovereign AI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>Members · 8 portraits</h3>
|
||||
{data.members.map((m, i) => (
|
||||
<div key={i} className="member-row">
|
||||
<div>
|
||||
<div className="number">{String(i + 1).padStart(2, '0')}</div>
|
||||
<label className={`dropzone ${m.photo ? 'has-image' : ''}`}>
|
||||
{m.photo
|
||||
? <img src={m.photo} alt="" />
|
||||
: <span className="ph">Drop or click<br/>to add photo</span>}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={e => onPhoto(i, e.target.files?.[0])}
|
||||
/>
|
||||
{m.photo && (
|
||||
<button
|
||||
className="clear"
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); updateMember(i, 'photo', null); }}
|
||||
aria-label="Remove photo"
|
||||
>×</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="member-fields">
|
||||
<div className="field full">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.name}
|
||||
onChange={e => updateMember(i, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.title}
|
||||
onChange={e => updateMember(i, 'title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.company}
|
||||
onChange={e => updateMember(i, 'company', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="preview-col">
|
||||
<h3>
|
||||
Preview
|
||||
<span className="pixel-tag">1200 × 1200 · PNG</span>
|
||||
</h3>
|
||||
<div className="preview-frame">
|
||||
<div className="stage">
|
||||
<BoardPost data={data} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="preview-foot">
|
||||
The exported PNG renders at 2× pixel density (2400 × 2400) so the type stays crisp after LinkedIn re-encodes.
|
||||
</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
{/* Hidden full-size capture target — what html-to-image actually reads. */}
|
||||
<div className="capture-host" aria-hidden="true">
|
||||
<div ref={captureRef}>
|
||||
<BoardPost data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
advisory-board-post/fonts/Manrope-Bold.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-ExtraBold.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-ExtraLight.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-Light.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-Medium.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-Regular.ttf
Normal file
BIN
advisory-board-post/fonts/Manrope-SemiBold.ttf
Normal file
BIN
advisory-board-post/fonts/Newsreader-Bold.ttf
Normal file
BIN
advisory-board-post/fonts/Newsreader-BoldItalic.ttf
Normal file
BIN
advisory-board-post/fonts/Newsreader-ExtraBold.ttf
Normal file
BIN
advisory-board-post/fonts/Newsreader-Italic.ttf
Normal file
BIN
advisory-board-post/fonts/Newsreader-Regular.ttf
Normal file
641
advisory-board-post/image-slot.js
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
/**
|
||||
* <image-slot> — 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:
|
||||
* <script src="image-slot.js"></script>
|
||||
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
|
||||
* placeholder="Drop a hero image"></image-slot>
|
||||
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
|
||||
* <image-slot id="kite" style="width:300px;height:300px"
|
||||
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
|
||||
*/
|
||||
|
||||
(() => {
|
||||
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 <image-slot> 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 <img> inside is translucent; the real
|
||||
// clipped <img> 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 =
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
|
||||
'<path d="m21 15-5-5L5 21"/></svg>';
|
||||
|
||||
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 =
|
||||
'<style>' + stylesheet + '</style>' +
|
||||
'<div class="frame" part="frame">' +
|
||||
' <img part="image" alt="" draggable="false" style="display:none">' +
|
||||
' <div class="empty" part="empty">' + icon +
|
||||
' <div class="cap"></div>' +
|
||||
' <div class="sub">or <u>browse files</u></div></div>' +
|
||||
' <div class="ring" part="ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="spill">' +
|
||||
' <img class="ghost" alt="" draggable="false">' +
|
||||
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
|
||||
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
|
||||
'</div>' +
|
||||
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
|
||||
' <button data-act="clear" title="Remove image">Remove</button></div>' +
|
||||
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
|
||||
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('<image-slot> 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('<image-slot> 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);
|
||||
}
|
||||
})();
|
||||
251
advisory-board-post/index.html
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Board post · Fenja AI</title>
|
||||
<link rel="stylesheet" href="colors_and_type.css" />
|
||||
<style>
|
||||
html, body { background: #f0eee9; }
|
||||
body { margin: 0; }
|
||||
|
||||
/* Each artboard is a finished, exportable surface.
|
||||
The post chrome is intentionally Fenja-quiet: no borders,
|
||||
no shadows on the card itself — just paper tones. */
|
||||
.post, .post *, .post *::before, .post *::after { box-sizing: border-box; }
|
||||
.post {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Editorial label — all caps, tracked, muted */
|
||||
.post .eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.post .eyebrow .rule {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--on-surface-muted);
|
||||
vertical-align: middle;
|
||||
margin-right: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.post h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.04;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.post h1 em {
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
.post .lede {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Portrait cards */
|
||||
.member { display: flex; flex-direction: column; }
|
||||
.member .name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
.member .title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
.member .company {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
color: var(--on-surface-muted);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Image-slot styling: make the empty placeholder feel like part of the
|
||||
Fenja system rather than the bright drop-zone default. */
|
||||
image-slot {
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Brand mark line in the footer */
|
||||
.mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mark img { height: 18px; opacity: 0.9; }
|
||||
|
||||
/* Tonal section nested inside a darker tier — the no-line rule */
|
||||
.nest { background: var(--surface-container-low); }
|
||||
|
||||
/* =========== Layout A — Editorial Square 1200×1200 =========== */
|
||||
.a-root { padding: 96px 96px 80px; display: flex; flex-direction: column; }
|
||||
.a-head { max-width: 980px; }
|
||||
.a-head h1 { font-size: 64px; }
|
||||
.a-head .subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.a-grid {
|
||||
margin-top: 64px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 36px 28px;
|
||||
}
|
||||
.a-grid .member .name { font-size: 22px; margin-top: 18px; }
|
||||
.a-grid .member .title { font-size: 16px; font-weight: 600; margin-top: 6px; }
|
||||
.a-grid .member .company { font-size: 17px; font-weight: 500; color: var(--on-surface-variant); margin-top: 2px; }
|
||||
.a-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.a-foot .mark img { height: 160px; opacity: 1; display: block; margin-left: -6px; }
|
||||
|
||||
/* =========== Layout B — Catalogue / Index 1080×1350 =========== */
|
||||
.b-root { padding: 64px 72px; display: flex; flex-direction: column; }
|
||||
.b-head { display: flex; align-items: baseline; justify-content: space-between; gap: 40px; margin-bottom: 32px; }
|
||||
.b-head h1 { font-size: 64px; line-height: 1; }
|
||||
.b-head .right { text-align: right; }
|
||||
.b-lede {
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.b-list { margin-top: 32px; display: flex; flex-direction: column; }
|
||||
.b-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.b-row .idx {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
.b-row .name { font-size: 22px; }
|
||||
.b-row .role { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.b-row .role .title { font-size: 16px; font-weight: 600; margin-top: 4px; }
|
||||
.b-row .role .company { font-size: 15px; font-weight: 500; color: var(--on-surface-variant); }
|
||||
.b-row:nth-child(odd) { background: var(--surface-container-low); }
|
||||
|
||||
.b-foot { margin-top: auto; padding-top: 32px; display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; }
|
||||
.b-foot .quiet { font-family: var(--font-serif); font-style: italic; font-size: 16px; color: var(--on-surface-variant); max-width: 480px; line-height: 1.45; }
|
||||
|
||||
/* =========== Layout C — Editorial Landscape 1200×627 =========== */
|
||||
.c-root { padding: 56px 64px; display: grid; grid-template-columns: 1fr 1.2fr; gap: 56px; }
|
||||
.c-left { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-left .eyebrow { margin-bottom: 22px; }
|
||||
.c-left h1 { font-size: 44px; }
|
||||
.c-left .lede { font-size: 16px; margin-top: 18px; max-width: 360px; }
|
||||
.c-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px 16px;
|
||||
align-content: center;
|
||||
}
|
||||
.c-grid .member .name { font-size: 14px; margin-top: 10px; }
|
||||
.c-grid .member .title { font-size: 11px; margin-top: 2px; line-height: 1.25; }
|
||||
.c-grid .member .company { font-size: 11px; line-height: 1.25; }
|
||||
|
||||
/* =========== Layout D — Quiet Cover + Strip 1080×1350 =========== */
|
||||
.d-root { display: flex; flex-direction: column; }
|
||||
.d-hero { flex: 1; padding: 100px 80px 60px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.d-hero .eyebrow { margin-bottom: 36px; }
|
||||
.d-hero h1 { font-size: 88px; line-height: 0.98; max-width: 880px; }
|
||||
.d-hero .lede { font-size: 22px; margin-top: 32px; max-width: 560px; }
|
||||
.d-hero .signoff {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 17px;
|
||||
margin-top: 40px;
|
||||
max-width: 460px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.d-strip {
|
||||
background: var(--surface-container-low);
|
||||
padding: 36px 80px 44px;
|
||||
}
|
||||
.d-strip-label { margin-bottom: 22px; }
|
||||
.d-strip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.d-strip .member .name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
margin-top: 10px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
.d-strip .member .title { font-size: 10px; margin-top: 3px; line-height: 1.25; }
|
||||
.d-strip .member .company { font-size: 10px; line-height: 1.25; }
|
||||
|
||||
/* Topographic currents — a quiet accent for layout D */
|
||||
.currents {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="image-slot.js"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="board-posts.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { createRoot } = ReactDOM;
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
advisory-board-post/screenshots/01-diag-overview.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
advisory-board-post/screenshots/02-diag-overview.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
advisory-board-post/screenshots/diag-a.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
advisory-board-post/screenshots/diag-canvas.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
advisory-board-post/uploads/fenja-logo-1000x1000.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
16
assets/fenja-icon-black.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0"?>
|
||||
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="67.281601mm" height="101.04877mm" viewBox="0 0 67.281601 101.04877">
|
||||
|
||||
<defs id="defs1"></defs>
|
||||
<g id="g15" transform="translate(-13.97477,83.473269)">
|
||||
<a id="a13" transform="matrix(0.46160656,0,0,0.46160656,54.848979,-49.979453)" style="fill:#383831;fill-opacity:1">
|
||||
<path id="path11" style="fill:#383831;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.30066;stroke-opacity:1" d="m -18.338285,-72.536938 c -13.74121,-0.46332 -27.67429,6.23915 -30.62927,23.99965 -3.78543,22.75175 -2.02308,27.28708 -8.49358,33.40113 -0.33172,0.31344 -0.68442,0.6305 -1.06128,0.95492 -1.45347,1.25122 -3.25669,2.60442 -5.51573,4.2115298 -0.21828,0.14166 -1.46889,0.95375 -2.77858,1.80461 -1.30968,0.85087 -3.09547,2.00147 -3.96859,2.55692 -9.71464,6.18014098 -15.73026,14.105601 -17.17523,22.6293912 -1.02392,4.39944 -0.58622,8.85898 0.37503,13.22902 1.54872,6.59143 5.53649,12.05697 9.86047,17.09239 2.10267,2.45313 2.9744,3.01949 3.52864,2.28936 0.089,-0.1173 2.43368,-7.4904 2.6364,-8.32454 0.90846,-3.73797 6.03327,-17.41091 6.72254,-17.93536 0.0214,-0.0163 4.38159,-8.60415 9.79779,-13.18984 12.53397,-9.45828022 27.77089,-18.9996112 33.33396,-34.567641 1.70768,-3.98815 2.58233,-8.2783 3.4637,-12.5215 1.47952,-7.35274 1.14634,-6.88747 7.684191,-10.7292 3.3339299,-1.64872 11.7042296,-6.72938 14.3328396,-17.16852 -5.69305,-4.59707 -13.8685796,-7.45433 -22.1133006,-7.73232 z m 26.2106406,12.16775 c -3.51268,9.34765 -11.83195,14.63899 -19.9974706,19.49145 -3.54056,28.19346 -19.23094,40.45143078 -30.31805,48.2108808 -13.25397,9.2758802 -16.49234,12.7560002 -23.19025,33.7649602 -3.72715,11.69072 -4.08522,12.85871 -5.15862,16.80132 -1.52273,4.79107 -2.39504,9.73205 -3.84209,14.55226 -3.97772,13.24999 10.48988,14.09194 36.47077,21.58154 1.74902,0.5042 4.35169,0.92086 4.02345,3.1704 -0.41262,2.827837 -3.00384,1.83581 -4.71193,1.46542 -10.43263,-2.26228 -5.67223,-1.15531 -8.49246,-1.80574 -4.00394,-0.92343 -11.86327,-2.93816 -11.96288,-2.97561 l -17.4092,51.835787 c -0.43014,1.28076 100.90249,0.22691 100.84821,0.0179 L 10.256865,108.20597 C 8.1236656,102.43489 5.2017556,97.549383 2.6767956,96.060613 c -2.75173002,-1.62248 -3.67789,-5.99164 -4.13428,-8.36147 -1.89414,-12.73531 1.04806998,-26.48308 4.6067,-38.79484 1.38342,-4.44117 1.10419,-13.58033 -0.54407,-17.79319 -4.2184,-10.76178 -11.4085797,-16.37966 -12.3244697,-21.3654802 -0.6082799,-3.3112 -2.5646909,-15.328081 0.33584,-18.973131 4.31882,-2.9199098 12.4757697,2.32522 16.8572997,0.49369 1.6069297,-0.67171 2.0929297,-2.3048398 3.1144194,-10.4661198 0.56384,-6.48024 2.34196,-4.45706 7.26214,-7.03824 2.14367,-1.12459 -2.14355,-4.95226 -4.3649,-10.84786 -1.16393,-2.1724 -1.1814,-4.35053 -1.32324,-6.74605 -0.29538,-4.98862 -1.24145,-10.24241 -2.14382,-11.90355 -0.1195197,-0.22003 -0.6508697,-1.35245 -1.1810597,-2.51662 z m 41.0235494,102.804361 -0.0843,22.31145 c 0,0 6.63439,-5.88778 6.70402,-11.44383 0.0579,-4.61641 0.1215,-9.12217 -6.61974,-10.86762 z m -11.93626,6.25328 c -3.27055,3.98088 -10.63179,15.5967 -14.10067,15.89769 -3.04292,-0.0427 -8.77591,-7.53888 -12.15207,-10.91504 -2.5200694,-2.52007 -2.5659994,-3.02163 -3.0181494,-0.60564 -0.1361,0.72723 -3.74905,14.80249 -3.46483,16.10835 0.31925,1.4668 15.8249894,14.70054 18.0540294,14.7325 1.73011,0.0248 9.12832,-7.22389 14.32985,-11.99196 z"></path>
|
||||
<g id="g13" transform="matrix(2.1663471,0,0,2.1663471,-1753.1362,-1353.9929)" style="fill:#383831;fill-opacity:1">
|
||||
<path d="m 878.69555,672.48093 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.43837,3.6537 3.06886,5.41425 1.4106,3.93889 2.92103,7.93859 3.80654,12.39384 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 5.56183,-80.2446 c -0.0412,-0.84401 -3.88414,-15.01284 -4.55238,-15.02374 -0.66824,-0.0109 -4.16166,13.94629 -4.24679,15.07702 -0.0851,1.13073 2.81161,3.011 4.40097,2.98866 1.58936,-0.0223 4.43941,-2.19793 4.3982,-3.04194 z" style="display:none;fill:#383831;fill-opacity:1;stroke-width:0.461607" id="path12"></path>
|
||||
<path d="m 827.63002,617.21307 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.3964,3.19205 3.02689,4.9526 1.4106,3.93889 2.963,8.40024 3.84851,12.85549 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 4.45265,-80.44426 c -0.0412,-0.84401 -2.77496,-12.90896 -3.4432,-12.91986 -0.66824,-0.0109 -2.72753,11.69192 -2.81266,12.82265 -0.0851,1.13073 1.50339,1.72438 3.09275,1.70204 1.58936,-0.0223 3.20432,-0.76082 3.16311,-1.60483 z" style="fill:#383831;fill-opacity:1;stroke-width:0.461607" id="path13"></path>
|
||||
</g>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
20
assets/fenja-icon-white.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0"?>
|
||||
|
||||
|
||||
<svg width="67.281601mm" height="101.04877mm" viewBox="13 0 67.281601 101.04877" version="1.1" id="svg1" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi-0.dtd="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<namedview id="namedview1" pagecolor="#ffffff" bordercolor="#eeeeee" borderopacity="1" labelstyle="default" showgrid="true">
|
||||
<grid id="grid1" units="mm" originx="-11.288788" originy="-408.06888" spacingx="1" spacingy="0.99999998" empcolor="#0099e5" empopacity="0.30196078" color="#0099e5" opacity="0.14901961" empspacing="5" enabled="true" visible="false"></grid>
|
||||
<grid id="grid2" units="mm" originx="-11.288788" originy="-408.06888" spacingx="1" spacingy="0.99999998" empcolor="#0099e5" empopacity="0.30196078" color="#0099e5" opacity="0.14901961" empspacing="5" enabled="false" visible="true"></grid>
|
||||
<page x="0" y="-5.2362184e-13" width="67.281601" height="101.04877" id="page1" margin="0" bleed="0"></page>
|
||||
</namedview>
|
||||
<defs id="defs1"></defs>
|
||||
<g id="g11" transform="translate(-13.97477,83.473269)">
|
||||
<a id="a9" transform="matrix(0.46160656,0,0,0.46160656,54.848979,-49.979453)" style="fill:#fffcf7;fill-opacity:1">
|
||||
<path id="path7" style="fill:#fffcf7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.30066;stroke-opacity:1" d="m -18.338285,-72.536938 c -13.74121,-0.46332 -27.67429,6.23915 -30.62927,23.99965 -3.78543,22.75175 -2.02308,27.28708 -8.49358,33.40113 -0.33172,0.31344 -0.68442,0.6305 -1.06128,0.95492 -1.45347,1.25122 -3.25669,2.60442 -5.51573,4.2115298 -0.21828,0.14166 -1.46889,0.95375 -2.77858,1.80461 -1.30968,0.85087 -3.09547,2.00147 -3.96859,2.55692 -9.71464,6.18014098 -15.73026,14.105601 -17.17523,22.6293912 -1.02392,4.39944 -0.58622,8.85898 0.37503,13.22902 1.54872,6.59143 5.53649,12.05697 9.86047,17.09239 2.10267,2.45313 2.9744,3.01949 3.52864,2.28936 0.089,-0.1173 2.43368,-7.4904 2.6364,-8.32454 0.90846,-3.73797 6.03327,-17.41091 6.72254,-17.93536 0.0214,-0.0163 4.38159,-8.60415 9.79779,-13.18984 12.53397,-9.45828022 27.77089,-18.9996112 33.33396,-34.567641 1.70768,-3.98815 2.58233,-8.2783 3.4637,-12.5215 1.47952,-7.35274 1.14634,-6.88747 7.684191,-10.7292 3.3339299,-1.64872 11.7042296,-6.72938 14.3328396,-17.16852 -5.69305,-4.59707 -13.8685796,-7.45433 -22.1133006,-7.73232 z m 26.2106406,12.16775 c -3.51268,9.34765 -11.83195,14.63899 -19.9974706,19.49145 -3.54056,28.19346 -19.23094,40.45143078 -30.31805,48.2108808 -13.25397,9.2758802 -16.49234,12.7560002 -23.19025,33.7649602 -3.72715,11.69072 -4.08522,12.85871 -5.15862,16.80132 -1.52273,4.79107 -2.39504,9.73205 -3.84209,14.55226 -3.97772,13.24999 10.48988,14.09194 36.47077,21.58154 1.74902,0.5042 4.35169,0.92086 4.02345,3.1704 -0.41262,2.827837 -3.00384,1.83581 -4.71193,1.46542 -10.43263,-2.26228 -5.67223,-1.15531 -8.49246,-1.80574 -4.00394,-0.92343 -11.86327,-2.93816 -11.96288,-2.97561 l -17.4092,51.835787 c -0.43014,1.28076 100.90249,0.22691 100.84821,0.0179 L 10.256865,108.20597 C 8.1236656,102.43489 5.2017556,97.549383 2.6767956,96.060613 c -2.75173002,-1.62248 -3.67789,-5.99164 -4.13428,-8.36147 -1.89414,-12.73531 1.04806998,-26.48308 4.6067,-38.79484 1.38342,-4.44117 1.10419,-13.58033 -0.54407,-17.79319 -4.2184,-10.76178 -11.4085797,-16.37966 -12.3244697,-21.3654802 -0.6082799,-3.3112 -2.5646909,-15.328081 0.33584,-18.973131 4.31882,-2.9199098 12.4757697,2.32522 16.8572997,0.49369 1.6069297,-0.67171 2.0929297,-2.3048398 3.1144194,-10.4661198 0.56384,-6.48024 2.34196,-4.45706 7.26214,-7.03824 2.14367,-1.12459 -2.14355,-4.95226 -4.3649,-10.84786 -1.16393,-2.1724 -1.1814,-4.35053 -1.32324,-6.74605 -0.29538,-4.98862 -1.24145,-10.24241 -2.14382,-11.90355 -0.1195197,-0.22003 -0.6508697,-1.35245 -1.1810597,-2.51662 z m 41.0235494,102.804361 -0.0843,22.31145 c 0,0 6.63439,-5.88778 6.70402,-11.44383 0.0579,-4.61641 0.1215,-9.12217 -6.61974,-10.86762 z m -11.93626,6.25328 c -3.27055,3.98088 -10.63179,15.5967 -14.10067,15.89769 -3.04292,-0.0427 -8.77591,-7.53888 -12.15207,-10.91504 -2.5200694,-2.52007 -2.5659994,-3.02163 -3.0181494,-0.60564 -0.1361,0.72723 -3.74905,14.80249 -3.46483,16.10835 0.31925,1.4668 15.8249894,14.70054 18.0540294,14.7325 1.73011,0.0248 9.12832,-7.22389 14.32985,-11.99196 z"></path>
|
||||
<g id="g9" transform="matrix(2.1663471,0,0,2.1663471,-1753.1362,-1353.9929)" style="fill:#fffcf7;fill-opacity:1">
|
||||
<path d="m 878.69555,672.48093 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.43837,3.6537 3.06886,5.41425 1.4106,3.93889 2.92103,7.93859 3.80654,12.39384 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 5.56183,-80.2446 c -0.0412,-0.84401 -3.88414,-15.01284 -4.55238,-15.02374 -0.66824,-0.0109 -4.16166,13.94629 -4.24679,15.07702 -0.0851,1.13073 2.81161,3.011 4.40097,2.98866 1.58936,-0.0223 4.43941,-2.19793 4.3982,-3.04194 z" style="display:none;fill:#fffcf7;fill-opacity:1;stroke-width:0.461607" id="path8"></path>
|
||||
<path d="m 827.63002,617.21307 -0.50264,-0.48525 c -0.9759,-0.94214 -1.43866,-1.11173 -2.84526,-2.35436 -0.98765,-0.87251 -2.03242,-2.18616 -1.94339,-3.31021 0.21599,-2.72696 1.99587,-8.58662 3.40965,-12.85411 0.64768,-1.49033 1.438,-4.90622 3.0135,-5.00304 1.52844,0.12061 2.3964,3.19205 3.02689,4.9526 1.4106,3.93889 2.963,8.40024 3.84851,12.85549 0.12059,0.60673 -0.0405,1.40758 -2.26699,3.34846 -1.17554,1.02474 -2.34726,2.07263 -2.60385,2.32864 l -0.46652,0.46547 0.0336,74.17255 c 9.2e-4,1.3728 -2.53188,1.41685 -2.52374,-0.0225 m 4.45265,-80.44426 c -0.0412,-0.84401 -2.77496,-12.90896 -3.4432,-12.91986 -0.66824,-0.0109 -2.72753,11.69192 -2.81266,12.82265 -0.0851,1.13073 1.50339,1.72438 3.09275,1.70204 1.58936,-0.0223 3.20432,-0.76082 3.16311,-1.60483 z" style="fill:#fffcf7;fill-opacity:1;stroke-width:0.461607" id="path9"></path>
|
||||
</g>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
assets/fenja-logo-full.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
19
assets/fenja-wordmark-black.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
23
assets/fenja-wordmark-white.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/reference-boulderer.png
Normal file
|
After Width: | Height: | Size: 788 KiB |
BIN
assets/reference-flowerman-ochre.png
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
BIN
assets/reference-flowerman-white.png
Normal file
|
After Width: | Height: | Size: 958 KiB |
BIN
assets/reference-waves.png
Normal file
|
After Width: | Height: | Size: 7.1 MiB |
258
board-posts.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="member" style={{ textAlign: captionAlign }}>
|
||||
<image-slot
|
||||
id={m.id}
|
||||
shape="rounded"
|
||||
radius="4"
|
||||
placeholder="Drop portrait"
|
||||
style={{ width: '100%', height: size + 'px', display: 'block' }}
|
||||
/>
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Footer brand mark — used across variations
|
||||
function Mark({ light = false }) {
|
||||
return (
|
||||
<div className="mark">
|
||||
<img
|
||||
src="assets/fenja-logo-full.png"
|
||||
alt="Fenja AI"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// A — Editorial Square (1200 × 1200) — clean 4×2 grid
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostA() {
|
||||
return (
|
||||
<div className="post a-root">
|
||||
<div className="a-head">
|
||||
<h1>Meet the Fenja AI <em>Advisory Board</em></h1>
|
||||
<div className="subtitle">Bridging Industry & Sovereign AI</div>
|
||||
</div>
|
||||
|
||||
<div className="a-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={210} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="a-foot">
|
||||
<Mark />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// B — Catalogue Index (1080 × 1350) — numbered vertical list
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostB() {
|
||||
return (
|
||||
<div className="post b-root">
|
||||
<div className="b-head">
|
||||
<div className="left">
|
||||
<p className="eyebrow"><span className="rule" />A note from leadership</p>
|
||||
<h1 style={{ marginTop: 26 }}>Our <em>board.</em></h1>
|
||||
<p className="lede b-lede">
|
||||
Eight quiet experts, each chosen for their depth and discretion.
|
||||
We are grateful they said yes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="right">
|
||||
<div className="mark">
|
||||
<img src="assets/fenja-icon-black.svg" alt="" />
|
||||
<span>Fenja AI</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 18, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
|
||||
color: 'var(--on-surface-muted)', fontSize: 14, letterSpacing: 0,
|
||||
}}>
|
||||
§ 01 — MMXXV
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="b-list">
|
||||
{MEMBERS.map((m, i) => (
|
||||
<div className="b-row" key={m.id}>
|
||||
<div className="idx">{String(i + 1).padStart(2, '0')}</div>
|
||||
<image-slot
|
||||
id={m.id}
|
||||
shape="rounded"
|
||||
radius="4"
|
||||
placeholder=""
|
||||
style={{ width: 72, height: 72, display: 'block' }}
|
||||
/>
|
||||
<div className="role">
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="b-foot">
|
||||
<div className="quiet">
|
||||
"A board built the way a good archive is: slowly, with care, and with people you can trust at four in the morning."
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--on-surface-muted)',
|
||||
letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||
}}>
|
||||
fenja.ai / board
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// C — Editorial Landscape (1200 × 627) — left text · right micro grid
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostC() {
|
||||
return (
|
||||
<div className="post c-root">
|
||||
<div className="c-left">
|
||||
<div>
|
||||
<p className="eyebrow"><span className="rule" />Announcement</p>
|
||||
<h1>Introducing our <em>board.</em></h1>
|
||||
<p className="lede">
|
||||
Eight quiet experts, gathered to steward the work ahead — in research,
|
||||
in product, in counsel.
|
||||
</p>
|
||||
</div>
|
||||
<Mark />
|
||||
</div>
|
||||
<div className="c-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={104} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// D — Quiet Cover + Strip (1080 × 1350) — text hero with portrait band
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function PostD() {
|
||||
return (
|
||||
<div className="post d-root">
|
||||
<div className="d-hero" style={{ position: 'relative' }}>
|
||||
{/* Topographic currents accent — quiet, off-axis */}
|
||||
<svg className="currents" style={{ right: -40, top: 80, width: 360, height: 360 }} viewBox="0 0 360 360" fill="none">
|
||||
{[0,1,2,3,4,5,6,7].map((i) => (
|
||||
<path key={i}
|
||||
d={`M ${20 + i*8} ${180 + i*4} C ${100} ${120 - i*6}, ${260} ${240 + i*4}, ${340 - i*8} ${180 - i*6}`}
|
||||
stroke="#8a887f" strokeWidth="0.8" fill="none" opacity={0.55 - i*0.04}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<p className="eyebrow"><span className="rule" />Introducing — board of directors · MMXXV</p>
|
||||
<h1>Eight people. <em>One quiet table.</em></h1>
|
||||
<p className="lede">
|
||||
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.
|
||||
</p>
|
||||
<div className="signoff">
|
||||
"A study in stillness, and in counsel. We are grateful, every one of us, that they said yes."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<Mark />
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--on-surface-muted)',
|
||||
letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||
}}>
|
||||
fenja.ai
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-strip">
|
||||
<p className="eyebrow d-strip-label"><span className="rule" />The board, in order of seating</p>
|
||||
<div className="d-strip-grid">
|
||||
{MEMBERS.map((m) => (
|
||||
<Member key={m.id} m={m} size={112} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// Canvas
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<DesignCanvas title="Board reveal · LinkedIn" subtitle="Drag portraits onto the slots · double-click any text to edit · click ⤢ on an artboard to view fullscreen.">
|
||||
<DCSection
|
||||
id="square"
|
||||
title="Square — 1200 × 1200"
|
||||
subtitle="Standard LinkedIn single-image post. The full grid at a glance."
|
||||
>
|
||||
<DCArtboard id="a" label="A · Editorial grid" width={1200} height={1200}>
|
||||
<PostA />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="portrait"
|
||||
title="Portrait — 1080 × 1350"
|
||||
subtitle="Vertical post. Maximizes feed real estate; best for text-forward variants."
|
||||
>
|
||||
<DCArtboard id="b" label="B · Catalogue index" width={1080} height={1350}>
|
||||
<PostB />
|
||||
</DCArtboard>
|
||||
<DCArtboard id="d" label="D · Quiet cover + strip" width={1080} height={1350}>
|
||||
<PostD />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
|
||||
<DCSection
|
||||
id="landscape"
|
||||
title="Landscape — 1200 × 627"
|
||||
subtitle="Link-preview aspect ratio. Compact, scannable, lives well on desktop feed."
|
||||
>
|
||||
<DCArtboard id="c" label="C · Editorial landscape" width={1200} height={627}>
|
||||
<PostC />
|
||||
</DCArtboard>
|
||||
</DCSection>
|
||||
</DesignCanvas>
|
||||
);
|
||||
}
|
||||
346
colors_and_type.css
Normal file
|
|
@ -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); }
|
||||
966
design-canvas.jsx
Normal file
|
|
@ -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:
|
||||
// <DesignCanvas>
|
||||
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||
// </DCSection>
|
||||
// </DesignCanvas>
|
||||
|
||||
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 (
|
||||
<DCCtx.Provider value={api}>
|
||||
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||
{state.focus && registry[state.focus] && (
|
||||
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||
)}
|
||||
</DCCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div
|
||||
ref={vpRef}
|
||||
className="design-canvas"
|
||||
style={{
|
||||
height: '100vh', width: '100vw',
|
||||
background: DC.bg,
|
||||
overflow: 'hidden',
|
||||
overscrollBehavior: 'none',
|
||||
touchAction: 'none',
|
||||
position: 'relative',
|
||||
fontFamily: DC.font,
|
||||
boxSizing: 'border-box',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0,
|
||||
transformOrigin: '0 0',
|
||||
willChange: 'transform',
|
||||
width: 'max-content', minWidth: '100%',
|
||||
minHeight: '100%',
|
||||
padding: '60px 0 80px',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<div data-dc-section={sid}
|
||||
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
|
||||
<div style={{ padding: '0 60px' }}>
|
||||
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
|
||||
<DCEditable tag="div" value={sec.title ?? title}
|
||||
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||
{order.map((k) => (
|
||||
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||
onRename={(v) => 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}`)} />
|
||||
))}
|
||||
</div>
|
||||
{rest}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 / <img> /
|
||||
// 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 = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
|
||||
(fontCss ? '<style>' + fontCss + '</style>' : '') +
|
||||
'</head><body style="margin:0">' + xml + '</body></html>';
|
||||
return save(new Blob([html], { type: 'text/html' }), 'html');
|
||||
}
|
||||
|
||||
// PNG: the SVG's own width/height must be the output resolution — an
|
||||
// <img>-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 = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
|
||||
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
|
||||
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
|
||||
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 (
|
||||
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div className="dc-header" data-omelette-chrome="" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="dc-labelrow">
|
||||
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||
</div>
|
||||
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="dc-btns">
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => doExport('png')}>Download PNG</button>
|
||||
<button onClick={() => doExport('html')}>Download HTML</button>
|
||||
<hr />
|
||||
<button className="dc-danger"
|
||||
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
|
||||
{confirming ? 'Click again to delete' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="dc-expand" onClick={onFocus} title="Focus">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={cardRef} className="dc-card"
|
||||
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline rename — commits on blur or Enter.
|
||||
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||
const T = tag;
|
||||
return (
|
||||
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||
onClick={onClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||
style={style}>{value}</T>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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 }) => (
|
||||
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<div onClick={() => 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) */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setDd((o) => !o)}
|
||||
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||
</span>
|
||||
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||
</button>
|
||||
{ddOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
|
||||
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||
{sectionMeta[sid].title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={() => ctx.setFocus(null)}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||
</div>
|
||||
|
||||
{/* card centered, label + index below — only the card itself stops
|
||||
propagation so any backdrop click (including the margins around
|
||||
the card) exits focus */}
|
||||
<div
|
||||
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Arrow dir="left" onClick={() => go(-1)} />
|
||||
<Arrow dir="right" onClick={() => go(1)} />
|
||||
|
||||
{/* dots */}
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||
{peers.map((p, i) => (
|
||||
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Post-it — absolute-positioned sticky note
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', top, left, right, bottom, width,
|
||||
background: DC.postitBg, padding: '14px 16px',
|
||||
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||
transform: `rotate(${rotate}deg)`,
|
||||
zIndex: 5,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||
|
||||
362
editor.html
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Board post editor · Fenja AI</title>
|
||||
<link rel="stylesheet" href="colors_and_type.css" />
|
||||
<style>
|
||||
/* ────────── Editor chrome ────────── */
|
||||
html, body { background: var(--surface-container-low); }
|
||||
body { margin: 0; min-height: 100vh; font-family: var(--font-sans); color: var(--on-surface); }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: rgba(246, 242, 232, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
padding: 20px 48px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
}
|
||||
.topbar .brand { display: flex; align-items: center; gap: 14px; }
|
||||
.topbar .brand img { height: 28px; }
|
||||
.topbar .brand .title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.topbar .brand .title em { font-style: italic; font-weight: 700; }
|
||||
.topbar .actions { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.btn {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease-standard),
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.btn-primary { background: var(--secondary); color: var(--on-secondary); }
|
||||
.btn-primary:hover { background: var(--secondary-dim); }
|
||||
.btn-primary:active { transform: translateY(1px); }
|
||||
.btn-primary[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--on-surface-variant);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--on-surface); }
|
||||
|
||||
/* ────────── Workspace ────────── */
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 56px;
|
||||
padding: 48px;
|
||||
max-width: 1640px;
|
||||
margin: 0 auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-col h2 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.form-col .col-sub {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 16px;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section h3 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.field input, .field textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
color: var(--on-surface);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(186, 186, 176, 0.4);
|
||||
padding: 8px 0 10px;
|
||||
outline: none;
|
||||
transition: border-color var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.field input:focus, .field textarea:focus {
|
||||
border-bottom-color: var(--secondary);
|
||||
}
|
||||
.field .hint {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 22px; }
|
||||
|
||||
/* Member row in the form */
|
||||
.member-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 22px;
|
||||
padding: 22px 0;
|
||||
align-items: start;
|
||||
}
|
||||
.member-row + .member-row {
|
||||
border-top: 1px solid rgba(186, 186, 176, 0.25);
|
||||
}
|
||||
.member-row .number {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Photo dropzone */
|
||||
.dropzone {
|
||||
position: relative;
|
||||
width: 110px; height: 110px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
.dropzone:hover { background: var(--surface-container-high); }
|
||||
.dropzone img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.dropzone .ph {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dropzone input[type=file] {
|
||||
position: absolute; inset: 0; opacity: 0; cursor: pointer;
|
||||
}
|
||||
.dropzone.has-image .ph { display: none; }
|
||||
.dropzone .clear {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: rgba(56,56,49,0.7); color: #fffcf7;
|
||||
border: 0; border-radius: 999px;
|
||||
width: 22px; height: 22px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
.dropzone.has-image:hover .clear { display: flex; }
|
||||
|
||||
.member-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 22px; }
|
||||
.member-fields .field { margin-bottom: 0; }
|
||||
.member-fields .field.full { grid-column: 1 / -1; }
|
||||
|
||||
/* ────────── Preview column ────────── */
|
||||
.preview-col {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
width: 540px;
|
||||
}
|
||||
.preview-col h3 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0 0 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-col .pixel-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--on-surface-muted);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.preview-frame {
|
||||
width: 540px;
|
||||
height: 540px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-float);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.preview-frame .stage {
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
transform: scale(0.45);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.preview-foot {
|
||||
margin-top: 18px;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--on-surface-muted);
|
||||
line-height: 1.5;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
/* Hidden off-screen capture target — full size, untransformed. */
|
||||
.capture-host {
|
||||
position: fixed;
|
||||
left: -100000px;
|
||||
top: 0;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ────────── Board post styles — mirrors Variation A in index.html ────────── */
|
||||
.post, .post *, .post *::before, .post *::after { box-sizing: border-box; }
|
||||
.post {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 1200px;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.post h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.04;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.post h1 em { font-style: italic; font-weight: 700; }
|
||||
|
||||
.post .subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.post .member { display: flex; flex-direction: column; }
|
||||
.post .member .name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
font-size: 22px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.post .member .title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.35;
|
||||
font-size: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.post .member .company {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
font-size: 17px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.post .member .portrait {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
.post .member .portrait-empty {
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-container-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--on-surface-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.post .a-root { padding: 96px 96px 80px; display: flex; flex-direction: column; height: 100%; }
|
||||
.post .a-head { max-width: 980px; }
|
||||
.post .a-head h1 { font-size: 64px; }
|
||||
.post .a-grid {
|
||||
margin-top: 64px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 36px 28px;
|
||||
}
|
||||
.post .a-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post .mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.post .mark img { height: 160px; opacity: 1; display: block; margin-left: -6px; }
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/html-to-image@1.11.13/dist/html-to-image.js"></script>
|
||||
<script type="text/babel" src="editor.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { createRoot } = ReactDOM;
|
||||
createRoot(document.getElementById('root')).render(<EditorApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
340
editor.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="post">
|
||||
<div className="a-root">
|
||||
<div className="a-head">
|
||||
<h1>
|
||||
{data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''}
|
||||
{data.headlineEm ? <em>{data.headlineEm}</em> : null}
|
||||
</h1>
|
||||
{data.subtitle ? <div className="subtitle">{data.subtitle}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="a-grid">
|
||||
{data.members.map((m, i) => (
|
||||
<div key={i} className="member">
|
||||
{m.photo
|
||||
? <img className="portrait" src={m.photo} alt="" />
|
||||
: <div className="portrait-empty">Portrait</div>}
|
||||
<div className="name">{m.name}</div>
|
||||
<div className="title">{m.title}</div>
|
||||
<div className="company">{m.company}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="a-foot">
|
||||
<div className="mark">
|
||||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" style={{ height: 56, marginLeft: -14 }} />
|
||||
<div className="title">Board post <em>editor</em></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<a className="btn btn-ghost" href="index.html">Compare layouts</a>
|
||||
<button className="btn btn-ghost" onClick={onReset}>Reset</button>
|
||||
<button className="btn btn-primary" onClick={onDownload} disabled={downloading}>
|
||||
{downloading ? 'Rendering…' : 'Download PNG'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="workspace">
|
||||
<section className="form-col">
|
||||
<h2>Edit the post.</h2>
|
||||
<p className="col-sub">Fill in the headline, subtitle, and the eight members. The preview updates as you type.</p>
|
||||
|
||||
<div className="section">
|
||||
<h3>Headline</h3>
|
||||
<div className="grid-2">
|
||||
<div className="field">
|
||||
<label>Opening</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headlineBefore}
|
||||
onChange={e => updateField('headlineBefore', e.target.value)}
|
||||
placeholder="Meet the Fenja AI"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Italicized closer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headlineEm}
|
||||
onChange={e => updateField('headlineEm', e.target.value)}
|
||||
placeholder="Advisory Board"
|
||||
/>
|
||||
<div className="hint">The terminal phrase, rendered in serif italic bold.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 18 }}>
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.subtitle}
|
||||
onChange={e => updateField('subtitle', e.target.value)}
|
||||
placeholder="Bridging Industry & Sovereign AI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3>Members · 8 portraits</h3>
|
||||
{data.members.map((m, i) => (
|
||||
<div key={i} className="member-row">
|
||||
<div>
|
||||
<div className="number">{String(i + 1).padStart(2, '0')}</div>
|
||||
<label className={`dropzone ${m.photo ? 'has-image' : ''}`}>
|
||||
{m.photo
|
||||
? <img src={m.photo} alt="" />
|
||||
: <span className="ph">Drop or click<br/>to add photo</span>}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={e => onPhoto(i, e.target.files?.[0])}
|
||||
/>
|
||||
{m.photo && (
|
||||
<button
|
||||
className="clear"
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); updateMember(i, 'photo', null); }}
|
||||
aria-label="Remove photo"
|
||||
>×</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="member-fields">
|
||||
<div className="field full">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.name}
|
||||
onChange={e => updateMember(i, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.title}
|
||||
onChange={e => updateMember(i, 'title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={m.company}
|
||||
onChange={e => updateMember(i, 'company', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="preview-col">
|
||||
<h3>
|
||||
Preview
|
||||
<span className="pixel-tag">1200 × 1200 · PNG</span>
|
||||
</h3>
|
||||
<div className="preview-frame">
|
||||
<div className="stage">
|
||||
<BoardPost data={data} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="preview-foot">
|
||||
The exported PNG renders at 2× pixel density (2400 × 2400) so the type stays crisp after LinkedIn re-encodes.
|
||||
</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
{/* Hidden full-size capture target — what html-to-image actually reads. */}
|
||||
<div className="capture-host" aria-hidden="true">
|
||||
<div ref={captureRef}>
|
||||
<BoardPost data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
fonts/Manrope-Bold.ttf
Normal file
BIN
fonts/Manrope-ExtraBold.ttf
Normal file
BIN
fonts/Manrope-ExtraLight.ttf
Normal file
BIN
fonts/Manrope-Light.ttf
Normal file
BIN
fonts/Manrope-Medium.ttf
Normal file
BIN
fonts/Manrope-Regular.ttf
Normal file
BIN
fonts/Manrope-SemiBold.ttf
Normal file
BIN
fonts/Newsreader-Bold.ttf
Normal file
BIN
fonts/Newsreader-BoldItalic.ttf
Normal file
BIN
fonts/Newsreader-ExtraBold.ttf
Normal file
BIN
fonts/Newsreader-Italic.ttf
Normal file
BIN
fonts/Newsreader-Regular.ttf
Normal file
641
image-slot.js
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
/**
|
||||
* <image-slot> — 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:
|
||||
* <script src="image-slot.js"></script>
|
||||
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
|
||||
* placeholder="Drop a hero image"></image-slot>
|
||||
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
|
||||
* <image-slot id="kite" style="width:300px;height:300px"
|
||||
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
|
||||
*/
|
||||
|
||||
(() => {
|
||||
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 <image-slot> 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 <img> inside is translucent; the real
|
||||
// clipped <img> 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 =
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
|
||||
'<path d="m21 15-5-5L5 21"/></svg>';
|
||||
|
||||
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 =
|
||||
'<style>' + stylesheet + '</style>' +
|
||||
'<div class="frame" part="frame">' +
|
||||
' <img part="image" alt="" draggable="false" style="display:none">' +
|
||||
' <div class="empty" part="empty">' + icon +
|
||||
' <div class="cap"></div>' +
|
||||
' <div class="sub">or <u>browse files</u></div></div>' +
|
||||
' <div class="ring" part="ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="spill">' +
|
||||
' <img class="ghost" alt="" draggable="false">' +
|
||||
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
|
||||
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
|
||||
'</div>' +
|
||||
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
|
||||
' <button data-act="clear" title="Remove image">Remove</button></div>' +
|
||||
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
|
||||
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('<image-slot> 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('<image-slot> 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);
|
||||
}
|
||||
})();
|
||||
251
index.html
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Board post · Fenja AI</title>
|
||||
<link rel="stylesheet" href="colors_and_type.css" />
|
||||
<style>
|
||||
html, body { background: #f0eee9; }
|
||||
body { margin: 0; }
|
||||
|
||||
/* Each artboard is a finished, exportable surface.
|
||||
The post chrome is intentionally Fenja-quiet: no borders,
|
||||
no shadows on the card itself — just paper tones. */
|
||||
.post, .post *, .post *::before, .post *::after { box-sizing: border-box; }
|
||||
.post {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Editorial label — all caps, tracked, muted */
|
||||
.post .eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.post .eyebrow .rule {
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--on-surface-muted);
|
||||
vertical-align: middle;
|
||||
margin-right: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.post h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.04;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.post h1 em {
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
.post .lede {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Portrait cards */
|
||||
.member { display: flex; flex-direction: column; }
|
||||
.member .name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
.member .title {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
.member .company {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
color: var(--on-surface-muted);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Image-slot styling: make the empty placeholder feel like part of the
|
||||
Fenja system rather than the bright drop-zone default. */
|
||||
image-slot {
|
||||
background: var(--surface-container-high);
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Brand mark line in the footer */
|
||||
.mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--on-surface-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mark img { height: 18px; opacity: 0.9; }
|
||||
|
||||
/* Tonal section nested inside a darker tier — the no-line rule */
|
||||
.nest { background: var(--surface-container-low); }
|
||||
|
||||
/* =========== Layout A — Editorial Square 1200×1200 =========== */
|
||||
.a-root { padding: 96px 96px 80px; display: flex; flex-direction: column; }
|
||||
.a-head { max-width: 980px; }
|
||||
.a-head h1 { font-size: 64px; }
|
||||
.a-head .subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 26px;
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.35;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.a-grid {
|
||||
margin-top: 64px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 36px 28px;
|
||||
}
|
||||
.a-grid .member .name { font-size: 22px; margin-top: 18px; }
|
||||
.a-grid .member .title { font-size: 16px; font-weight: 600; margin-top: 6px; }
|
||||
.a-grid .member .company { font-size: 17px; font-weight: 500; color: var(--on-surface-variant); margin-top: 2px; }
|
||||
.a-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.a-foot .mark img { height: 160px; opacity: 1; display: block; margin-left: -6px; }
|
||||
|
||||
/* =========== Layout B — Catalogue / Index 1080×1350 =========== */
|
||||
.b-root { padding: 64px 72px; display: flex; flex-direction: column; }
|
||||
.b-head { display: flex; align-items: baseline; justify-content: space-between; gap: 40px; margin-bottom: 32px; }
|
||||
.b-head h1 { font-size: 64px; line-height: 1; }
|
||||
.b-head .right { text-align: right; }
|
||||
.b-lede {
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.b-list { margin-top: 32px; display: flex; flex-direction: column; }
|
||||
.b-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 72px 1fr;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.b-row .idx {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
color: var(--on-surface-muted);
|
||||
}
|
||||
.b-row .name { font-size: 22px; }
|
||||
.b-row .role { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.b-row .role .title { font-size: 16px; font-weight: 600; margin-top: 4px; }
|
||||
.b-row .role .company { font-size: 15px; font-weight: 500; color: var(--on-surface-variant); }
|
||||
.b-row:nth-child(odd) { background: var(--surface-container-low); }
|
||||
|
||||
.b-foot { margin-top: auto; padding-top: 32px; display: flex; align-items: flex-end; justify-content: space-between; gap: 32px; }
|
||||
.b-foot .quiet { font-family: var(--font-serif); font-style: italic; font-size: 16px; color: var(--on-surface-variant); max-width: 480px; line-height: 1.45; }
|
||||
|
||||
/* =========== Layout C — Editorial Landscape 1200×627 =========== */
|
||||
.c-root { padding: 56px 64px; display: grid; grid-template-columns: 1fr 1.2fr; gap: 56px; }
|
||||
.c-left { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.c-left .eyebrow { margin-bottom: 22px; }
|
||||
.c-left h1 { font-size: 44px; }
|
||||
.c-left .lede { font-size: 16px; margin-top: 18px; max-width: 360px; }
|
||||
.c-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px 16px;
|
||||
align-content: center;
|
||||
}
|
||||
.c-grid .member .name { font-size: 14px; margin-top: 10px; }
|
||||
.c-grid .member .title { font-size: 11px; margin-top: 2px; line-height: 1.25; }
|
||||
.c-grid .member .company { font-size: 11px; line-height: 1.25; }
|
||||
|
||||
/* =========== Layout D — Quiet Cover + Strip 1080×1350 =========== */
|
||||
.d-root { display: flex; flex-direction: column; }
|
||||
.d-hero { flex: 1; padding: 100px 80px 60px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.d-hero .eyebrow { margin-bottom: 36px; }
|
||||
.d-hero h1 { font-size: 88px; line-height: 0.98; max-width: 880px; }
|
||||
.d-hero .lede { font-size: 22px; margin-top: 32px; max-width: 560px; }
|
||||
.d-hero .signoff {
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 17px;
|
||||
margin-top: 40px;
|
||||
max-width: 460px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.d-strip {
|
||||
background: var(--surface-container-low);
|
||||
padding: 36px 80px 44px;
|
||||
}
|
||||
.d-strip-label { margin-bottom: 22px; }
|
||||
.d-strip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.d-strip .member .name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
margin-top: 10px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
.d-strip .member .title { font-size: 10px; margin-top: 3px; line-height: 1.25; }
|
||||
.d-strip .member .company { font-size: 10px; line-height: 1.25; }
|
||||
|
||||
/* Topographic currents — a quiet accent for layout D */
|
||||
.currents {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="image-slot.js"></script>
|
||||
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||
<script type="text/babel" src="board-posts.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { createRoot } = ReactDOM;
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
screenshots/01-diag-overview.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
screenshots/02-diag-overview.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
screenshots/diag-a.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
screenshots/diag-canvas.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
uploads/fenja-logo-1000x1000.png
Normal file
|
After Width: | Height: | Size: 34 KiB |