Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
cde7e56b15 rework 2026-06-11 14:19:02 +02:00
cf55ab5fcf board: add advisory-board reveal JS + 8 portrait images
The #board-reveal section HTML was already committed, but the GSAP
reveal timeline (members start opacity:0) lived only in the working
tree and the 8 portraits under protected/fenja/board/ were untracked.
Result: the whole "Meet the Fenja AI Advisory Board" section never
appeared in the deployed build. Commit both so it renders in the cloud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:06:56 +02:00
87 changed files with 6536 additions and 3 deletions

View file

@ -3,7 +3,20 @@
"allow": [ "allow": [
"Bash(awk NR==26 *)", "Bash(awk NR==26 *)",
"Bash(perl *)", "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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

File diff suppressed because one or more lines are too long

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

View 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 &amp; 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>
);
}

View 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); /* 5688 */
--text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 4872 */
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 4056 */
--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); }

View 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 foreignObjectcanvas 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 });

View 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>

View 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 36 and 47.
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>
</>
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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);
}
})();

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

BIN
assets/reference-waves.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

258
board-posts.jsx Normal file
View 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 &amp; 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
View 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); /* 5688 */
--text-display-lg: clamp(3rem, 5vw, 4.5rem); /* 4872 */
--text-display-md: clamp(2.5rem, 4vw, 3.5rem); /* 4056 */
--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
View 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 foreignObjectcanvas 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
View 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
View 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 36 and 47.
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

Binary file not shown.

BIN
fonts/Manrope-ExtraBold.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Manrope-Light.ttf Normal file

Binary file not shown.

BIN
fonts/Manrope-Medium.ttf Normal file

Binary file not shown.

BIN
fonts/Manrope-Regular.ttf Normal file

Binary file not shown.

BIN
fonts/Manrope-SemiBold.ttf Normal file

Binary file not shown.

BIN
fonts/Newsreader-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
fonts/Newsreader-Italic.ttf Normal file

Binary file not shown.

Binary file not shown.

641
image-slot.js Normal file
View 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
View 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>

View file

@ -199,7 +199,7 @@
// through them. // through them.
const sceneOrder = [ const sceneOrder = [
'hero', 'hero',
'bifrost', 'bifrost-meaning', 'bifrost', 'bifrost-meaning', 'board-reveal',
'platform-question', 'platform-layers', 'platform-question', 'platform-layers',
'wiki-deepdive', 'wiki-deepdive',
'platform-cards', 'platform-cards',
@ -216,6 +216,7 @@
'hero': 'hero', 'hero': 'hero',
'bifrost': 'bifrost', 'bifrost': 'bifrost',
'bifrost-meaning': 'bifrost', 'bifrost-meaning': 'bifrost',
'board-reveal': 'bifrost',
'platform-question': 'platform-layers', 'platform-question': 'platform-layers',
'platform-layers': 'platform-layers', 'platform-layers': 'platform-layers',
'wiki-deepdive': 'wiki-deepdive', 'wiki-deepdive': 'wiki-deepdive',
@ -666,6 +667,39 @@
} }
}); });
// ─── Advisory board reveal ───────────────────────────────────
// The #board-reveal section (8-member grid) sits right after the
// final treasure-map stop. Header bits rise/fade in, then the
// portraits stagger up. Mirrors the .map-stop reveal pattern;
// reduced-motion users never reach this block (early return above)
// and get the static CSS fallback instead.
const boardSection = document.getElementById('board-reveal');
if (boardSection) {
const headBits = boardSection.querySelectorAll('.board-head > *');
const members = boardSection.querySelectorAll('.board-member');
const boardTl = gsap.timeline({
scrollTrigger: {
trigger: boardSection,
start: 'top 72%',
toggleActions: 'play none none reverse',
}
});
if (headBits.length) {
boardTl.to(headBits, {
opacity: 1, y: 0,
duration: 0.7, stagger: 0.1,
ease: 'power3.out',
});
}
if (members.length) {
boardTl.to(members, {
opacity: 1, y: 0,
duration: 0.7, stagger: 0.06,
ease: 'power3.out',
}, '-=0.3');
}
}
/* SCENE 6 Join CTA + Innovationsfonden footer: REMOVED 2026-05-19 /* SCENE 6 Join CTA + Innovationsfonden footer: REMOVED 2026-05-19
in the customer-presentation conversion. The CTA, confirmation in the customer-presentation conversion. The CTA, confirmation
panel, click handler, and three-mark footer all went away with panel, click handler, and three-mark footer all went away with
@ -679,7 +713,7 @@
document.fonts.ready.then(() => ScrollTrigger.refresh()); document.fonts.ready.then(() => ScrollTrigger.refresh());
} }
// Refresh once illustrations have laid out // Refresh once illustrations have laid out
const illustrations = document.querySelectorAll('#bifrost-meaning img'); const illustrations = document.querySelectorAll('#bifrost-meaning img, #board-reveal img');
let pending = illustrations.length; let pending = illustrations.length;
if (pending === 0) ScrollTrigger.refresh(); if (pending === 0) ScrollTrigger.refresh();
illustrations.forEach((img) => { illustrations.forEach((img) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
screenshots/diag-a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
screenshots/diag-canvas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB