feat: iphone app
This commit is contained in:
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
--ios) exec npx expo run:ios --port 8082 ;;
|
||||
--android) exec npx expo run:android --port 8082 ;;
|
||||
--help) echo 'Usage: ./scripts/build-and-run.sh [--ios|--android] (default: development server)' ;;
|
||||
'') exec npx expo start --dev-client --port 8082 ;;
|
||||
*) echo 'Unknown option' >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Capture each implemented mobile frame in its fixed reference state."""
|
||||
import json, pathlib, subprocess, time, sys
|
||||
root=pathlib.Path(__file__).resolve().parents[1]
|
||||
scenes=json.loads((root/'src/design/scenes.json').read_text())
|
||||
udid='85638E91-52BB-440F-91F9-35604A771A28'
|
||||
selection=set(json.loads(pathlib.Path(sys.argv[1]).read_text())) if len(sys.argv)>1 else None
|
||||
frames=[s for s in scenes if s['w']==402 and (selection is None or s['id'] in selection)]
|
||||
for i,scene in enumerate(frames):
|
||||
slug=scene['id'].replace(':','-')
|
||||
log=pathlib.Path('/tmp/haptika-mobile-metro.log')
|
||||
offset=log.stat().st_size
|
||||
subprocess.run(['xcrun','simctl','openurl',udid,'haptika-mobile://screen/'+slug+'?preview=1'],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
deadline=time.time()+12
|
||||
while time.time()<deadline:
|
||||
with log.open() as stream:
|
||||
stream.seek(offset)
|
||||
ready='HAPTIKA_SCREEN_READY:'+scene['id'] in stream.read()
|
||||
if ready: break
|
||||
time.sleep(.15)
|
||||
if not ready: raise RuntimeError('Frame did not render: '+scene['id'])
|
||||
time.sleep(.85)
|
||||
file=root/'qa/screens'/f'{slug}.png'
|
||||
subprocess.run(['xcrun','simctl','io',udid,'screenshot',str(file)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
subprocess.run(['sips','-Z','874',str(file)],check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
if (i+1)%10==0:print(f'{i+1}/{len(frames)} captures',flush=True)
|
||||
print('Complete',flush=True)
|
||||
@@ -0,0 +1,109 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const scenes = JSON.parse(fs.readFileSync(root + '/design/scenes.json'));
|
||||
const assetFiles = require('../design/asset-files.json');
|
||||
const infoFiles = new Set(scenes.flatMap(s=>s.layers.filter(l=>l.asset&&/info/i.test(l.name)).map(l=>assetFiles[l.asset])));
|
||||
const normalize = value => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[’']/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const onboarding = ['2:2', '2:30', '677:64', '2:268', '2:334', '2:213', '2:431'];
|
||||
function compile(scene) {
|
||||
const watch = scene.section.endsWith('+ WATCH');
|
||||
const section = watch ? 'ABONNEMENT IPHONE + WATCH' : 'ABONNEMENT IPHONE';
|
||||
const available = scenes.filter(s => s.section === section && s.w === 402);
|
||||
const find = name => available.find(s => normalize(s.name) === normalize(name))?.id;
|
||||
const go = (name, label) => {const target = find(name);return target ? {type:'navigate', target, label} : undefined;};
|
||||
const panels = scenes.filter(s => s.section === scene.section && s.w === 362 && Math.abs((s.canvasX + 181) - (scene.canvasX + 201)) < 35);
|
||||
const panel = panels.sort((a,b)=>Math.abs(a.canvasY-scene.canvasY)-Math.abs(b.canvasY-scene.canvasY))[0];
|
||||
const family = normalize(scene.name).split(' - ')[0];
|
||||
const result = () => {
|
||||
if (/mesure|hrv/.test(family)) return find('Résultat Mesure VFC');
|
||||
if (family === 'ma coherence') return find('Ma Cohérence - Restitution');
|
||||
if (family === 'coherence cardiaque') return find('Cohérence Cardiaque - Fin');
|
||||
if (family.includes('4·4·4·4')) return find('4·4·4·4 - Respiration carré - Restitution (simple)');
|
||||
if (family.includes('calme express')) return find('Calme express - Restitution (simple)');
|
||||
if (/4.?7.?8|sommeil/.test(family)) return find('Respirer - Restitution (simple)');
|
||||
if (/micro|apaiser|booster|endormissement/.test(family)) return find('Ancrer - Restitution');
|
||||
if (/focus|concentrer|miroir/.test(family)) return find('Recentrer - Restitution');
|
||||
if (/decouverte|presence|sensation|atterrissage|libre|meditation/.test(family)) return find('Méditation haptique - Restitution');
|
||||
if (/sos/.test(family)) return find('SOS Stress - Fin');
|
||||
};
|
||||
const start = () => {
|
||||
if (/mesure/.test(family)) return find('Mesure HRV en cours');
|
||||
if (family === 'ma coherence') return find('Ma Cohérence - En cours');
|
||||
if (family.includes('4·7·8')) return find('4-7-8 - Séance');
|
||||
if (family.includes('4·4·4·4')) return find('4·4·4·4 - Respiration carré - Séance');
|
||||
return available.find(s => normalize(s.name).startsWith(family + ' - ') && /séance/i.test(s.name))?.id;
|
||||
};
|
||||
const categories = {
|
||||
'respirer':'Vos outils - respirez', 'respirez':'Vos outils - respirez', 'ancrer':'Vos outils - Ancrer', 'recentrer':'Vos outils - Recentrer',
|
||||
'meditation haptique':'Vos outils - Méditation haptique', 'reveil intelligent':'Vos outils - Réveil intelligent',
|
||||
'ma coherence':'Ma Cohérence - Préparation', 'coherence cardiaque':'Cohérence Cardiaque - Réglages',
|
||||
'calme express':'Calme express - Préparation', '4·4·4·4 - equilibre':'4·4·4·4 - Équilibre - Préparation',
|
||||
'4·7·8 - sommeil':'4·7·8 - Sommeil - Préparation', 'micro-ancrage':'Micro-Ancrage - Préparation',
|
||||
'apaiser':'Apaiser - Préparation', 'booster':'Booster - Préparation', 'endormissement':'Endormissement - Préparation',
|
||||
'focus':'Focus - Préparation', 'concentrer':'Concentrer - Préparation', 'miroir cardiaque':'Miroir Cardiaque - Préparation',
|
||||
'decouverte':'Découverte - Préparation', 'presence':'Présence - Préparation', 'sensation':'Sensation - Préparation',
|
||||
'atterrissage':'Atterrissage - Préparation', 'libre':'Libre - Préparation', 'creer mon protocole':'Créer mon protocole',
|
||||
'references scientifiques':'Références scientifiques', 'diagnostic matinal':'Accueil - Diagnostic matinal',
|
||||
};
|
||||
function action(label, layer) {
|
||||
const l = normalize(label); const name = normalize(layer.name);
|
||||
if (panel && layer.asset && (/info/i.test(layer.name)||infoFiles.has(assetFiles[layer.asset]))) return {type:'overlay',target:panel.id,label:'En savoir plus'};
|
||||
if (!l && /button/i.test(layer.name) && layer.y > 65 && layer.w <= 40 && panel) return {type:'overlay',target:panel.id,label:'En savoir plus'};
|
||||
if (/informations personnelles/.test(l)) return {type:'navigate',target:'2:268',label};
|
||||
if (/^equipement/.test(l)) return {type:'navigate',target:'2:334',label};
|
||||
if (/guide des exercices/.test(l)) return go('Vos Outils',label);
|
||||
if (/^voir tout$|mes sessions/.test(l)) return go('Évolution - V1',label);
|
||||
if (!l && layer.asset && layer.y < 65) return layer.x > 300 ? go('Profil', 'Profil') : layer.x < 45 ? go('Vos Outils', 'Outils') : undefined;
|
||||
if (scene.section === 'ONBOARDING') {
|
||||
if (/^continuer$|^plus tard$|^connecter apple sante$/.test(l)) return {type:'navigate', target:onboarding[onboarding.indexOf(scene.id)+1], label};
|
||||
if (l==='commencer') return {type:'finish-onboarding',label};
|
||||
if (l==='iphone' || l==='iphone + apple watch') return {type:'variant',value:l==='iphone'?'phone':'watch',label};
|
||||
if (/entrez votre prenom/.test(l)||name.includes('input')) return {type:'input',key:'firstName',label};
|
||||
if (/selectionnez une annee/.test(l)) return {type:'input',key:'birthYear',label};
|
||||
if (l==='homme'||l==='femme') return {type:'select',key:'gender',value:label,label};
|
||||
}
|
||||
const tabs={'mon etat':'Accueil - Mon état actuel',outils:'Vos Outils','sos stress':'SOS Stress - Entrée',evolution:'Évolution - V1',profil:'Profil'};
|
||||
if(tabs[l]) return go(tabs[l],label);
|
||||
if(/^annuler$|^retour$|^fermer$/.test(l))return {type:'back',label};
|
||||
if (/mesurer mon etat|nouvelle mesure|mesurer a nouveau|refaire.*mesure/.test(l)) return go('Préparation Mesure mon etat',label);
|
||||
if (/mesurer.*vfc/.test(l)) return go('Préparation Mesure VFC',label);
|
||||
if (l==='commencer la session'&&/respirez/.test(scene.name)) return go('Ma Cohérence - Préparation',label);
|
||||
if (/^(commencer|demarrer|lancer|tester maintenant)/.test(l)) {const target=start();if(target)return {type:'navigate',target,label}; if(/sos/.test(l))return go('SOS Stress - Séance',label);}
|
||||
if (/^(arreter|terminer|fin de seance)/.test(l)){const target=result();return target?{type:'navigate',target,label}:go('Vos Outils',label);}
|
||||
if (/^pause$|^reprendre$/.test(l)) return {type:'pause',label};
|
||||
if (/^(retour a|revenir a)/.test(l)) return go(/outil/.test(l)?'Vos Outils':'Accueil - Mon état actuel',label);
|
||||
if (/enregistrer/.test(l))return {type:'back',label};
|
||||
if (/tester maintenant/.test(l)&&family==='creer mon protocole')return go('Cohérence Cardiaque - Séance',label);
|
||||
if (/mode ecran noir|escalade sonore|activer le reveil|vibration/.test(l) && l.length<50) return {type:'toggle',key:l,label};
|
||||
if (/^\d+\s?min$|^autre$|^focus$|^guide$|^libre$|^semaine$|^mois$|^jour$|^7j$|^30j$|^90j$/.test(l) && (layer.t==='TEXT'||name.includes('button'))) {
|
||||
if(categories[l]&&/vos outils/.test(normalize(scene.name)))return go(categories[l],label);
|
||||
return {type:'select',key:/min|autre/.test(l)?'duration':scene.id+':selection',value:label,label};
|
||||
}
|
||||
if (/^\d+s$|^\d+%$|^07$|^00$/.test(l) && /protocole|reveil/.test(family))return {type:'input',key:scene.id+':'+layer.id,label};
|
||||
if (categories[l]) return go(categories[l],label);
|
||||
// Protocol cards have both a title and a description. Match the title prefix.
|
||||
if(name.includes('button')||name.includes('card')) for(const [title,target] of Object.entries(categories)) if(l.startsWith(title+' ')||l===title)return go(target,label);
|
||||
}
|
||||
for (const layer of scene.layers) {
|
||||
const children = scene.layers.filter(l => l.text!==undefined&&l.path.includes(layer.id));
|
||||
const label = layer.text ?? children.map(l=>l.text).join(' ');
|
||||
if (layer.t==='TEXT'||/button|card|input|toggle|switch/i.test(layer.name)||layer.asset&&(layer.y<65||panel)) layer.action = action(label,layer);
|
||||
}
|
||||
// Resolve icon-only controls and broad card taps from the text they contain.
|
||||
for(const layer of scene.layers) if(/button/i.test(layer.name)&&!layer.action){const child=scene.layers.find(l=>l.path.includes(layer.id)&&l.action);if(child)layer.action=child.action;}
|
||||
for(const layer of scene.layers) if(layer.name==='Input'&&layer.h<=12&&layer.w>100){
|
||||
const value=scene.layers.filter(l=>l.text&&/^\d+(s|%| min)$/.test(l.text.trim())&&l.y<layer.y&&layer.y-l.y<55).sort((a,b)=>b.y-a.y)[0];
|
||||
const label=scene.layers.filter(l=>l.text&&l.x<layer.x+100&&l.y<layer.y&&layer.y-l.y<55).sort((a,b)=>b.y-a.y)[0];
|
||||
if(value){const suffix=value.text.trim().replace(/^\d+/,'');layer.action={type:'slider',key:scene.id+':'+layer.id,value:parseInt(value.text,10).toString(),valueNode:value.id,maximum:suffix==='%'?100:suffix===' min'?60:20,suffix,label:label?.text||value.text};value.action=undefined;}
|
||||
}
|
||||
for(const layer of scene.layers) if(layer.r>=100&&layer.w>=30&&layer.w<=60&&layer.h>=18&&layer.h<=32){
|
||||
const label=scene.layers.find(l=>l.action?.type==='toggle'&&l.text&&Math.abs(l.y-layer.y)<15&&l.x<layer.x);
|
||||
if(label)layer.action={...label.action};
|
||||
}
|
||||
return {...scene, resultTarget:result(), startTarget:start()};
|
||||
}
|
||||
const output=scenes.map(compile);
|
||||
fs.mkdirSync(root+'/src/design',{recursive:true});fs.writeFileSync(root+'/src/design/scenes.json',JSON.stringify(output));
|
||||
console.log({scenes:output.length,actions:output.reduce((n,s)=>n+s.layers.filter(l=>l.action).length,0)});
|
||||
module.exports={compile,normalize,onboarding};
|
||||
@@ -0,0 +1,14 @@
|
||||
import AppKit
|
||||
let root=CommandLine.arguments[1]
|
||||
let ids=["2-2","2-30","2-268","2-334","431-2","617-952","564-904","564-853","565-958","565-1148","565-1624","565-1584","565-2383","565-3079","565-3566","565-3818","566-7582","566-7702","566-9809","580-286"]
|
||||
let cellW=220,cellH=505,columns=4,rows=(ids.count+3)/4
|
||||
let image=NSImage(size:NSSize(width:cellW*columns,height:cellH*rows))
|
||||
image.lockFocus()
|
||||
NSColor(calibratedRed:0.035,green:0.055,blue:0.085,alpha:1).setFill();NSRect(x:0,y:0,width:cellW*columns,height:cellH*rows).fill()
|
||||
for (i,id) in ids.enumerated(){let x=(i%columns)*cellW+9,y=(rows-1-i/columns)*cellH+28
|
||||
if let shot=NSImage(contentsOfFile:root+"/screens/"+id+".png"){shot.draw(in:NSRect(x:x,y:y,width:202,height:439))}
|
||||
(id as NSString).draw(at:NSPoint(x:x,y:y-19),withAttributes:[.font:NSFont.monospacedSystemFont(ofSize:11,weight:.regular),.foregroundColor:NSColor.white])
|
||||
}
|
||||
image.unlockFocus()
|
||||
let rep=NSBitmapImageRep(data:image.tiffRepresentation!)!
|
||||
try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:root+"/overview.png"))
|
||||
@@ -0,0 +1,3 @@
|
||||
const fs=require('fs');const path=require('path');
|
||||
function decode(s){const b=Buffer.from(s.trim(),'base64');let codes=[];for(let i=0;i<b.length;i+=2)codes.push(b.readUInt16BE(i));let dict=Array.from({length:256},(_,i)=>String.fromCharCode(i));let w=String.fromCharCode(codes[0]),out=w;for(const code of codes.slice(1)){const e=dict[code]??(w+w[0]);out+=e;if(dict.length<65535)dict.push(w+e[0]);w=e;}return JSON.parse(Buffer.from(out,'latin1').toString('utf8'));}
|
||||
const dir=process.argv[2];let scenes=[];for(const f of fs.readdirSync(dir)){if(f.endsWith('.lzw'))scenes.push(...decode(fs.readFileSync(path.join(dir,f),'utf8')));}fs.writeFileSync(path.join(dir,'../native-properties.json'),JSON.stringify(scenes));console.log(JSON.stringify({frames:scenes.length,assets:scenes.flatMap(s=>s.layers.filter(l=>l.asset)).length,fonts:[...new Set(scenes.flatMap(s=>s.layers.flatMap(l=>l.runs?.map(r=>r.font)||[])))]}));
|
||||
@@ -0,0 +1,58 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const scenes = require('../src/design/scenes.json');
|
||||
const source = require('../design/scenes.json');
|
||||
const files = require('../design/asset-files.json');
|
||||
const frames = require('../../qa/mobile-frame-inventory.json').filter(f=>f.width===402||f.width===362);
|
||||
test('Every mobile frame and explanatory panel is implemented, without Watch screens',()=>{
|
||||
assert.equal(scenes.length,146);
|
||||
assert.equal(scenes.filter(s=>s.w===402).length,110);
|
||||
assert.deepEqual(new Set(scenes.map(s=>s.id)),new Set(frames.map(f=>f.id)));
|
||||
assert.ok(scenes.every(s=>s.w!==198));
|
||||
});
|
||||
test('All referenced illustrations and icons are bundled, nonempty original exports',()=>{
|
||||
for(const scene of scenes)for(const layer of scene.layers)if(layer.asset){assert.ok(files[layer.asset],layer.asset);assert.ok(fs.statSync(path.resolve(__dirname,'../assets/figma',files[layer.asset])).size>0);}
|
||||
});
|
||||
test('All navigation and overlay destinations exist',()=>{
|
||||
const ids=new Set(scenes.map(s=>s.id));
|
||||
for(const scene of scenes)for(const layer of scene.layers)if(layer.action?.target)assert.ok(ids.has(layer.action.target),`${scene.id} ${layer.action.label} -> ${layer.action.target}`);
|
||||
});
|
||||
test('The navigation compiler does not change any Figma text or styled text segment',()=>{
|
||||
const original=new Map(source.map(s=>[s.id,s]));
|
||||
for(const scene of scenes){const copy=original.get(scene.id);assert.deepEqual(scene.layers.filter(l=>l.text!==undefined).map(l=>[l.id,l.text,l.runs]),copy.layers.filter(l=>l.text!==undefined).map(l=>[l.id,l.text,l.runs]));}
|
||||
});
|
||||
test('Onboarding reaches the seven correct source frames in order',()=>{
|
||||
const order=['2:2','2:30','677:64','2:268','2:334','2:213','2:431'];
|
||||
for(let i=0;i<order.length-1;i++){const scene=scenes.find(s=>s.id===order[i]);assert.ok(scene.layers.some(l=>l.action?.target===order[i+1]),scene.id);}
|
||||
assert.ok(scenes.find(s=>s.id==='2:431').layers.some(l=>l.action?.type==='finish-onboarding'));
|
||||
});
|
||||
test('Both mobile variants expose working primary navigation and breathing flows',()=>{
|
||||
for(const [home,tools,breath,prepare,session]of [['431:2','565:958','565:1148','565:1624','565:1584'],['560:251','566:4392','566:4582','566:5207','566:5167']]){
|
||||
for(const [from,to]of [[home,tools],[tools,breath],[breath,prepare],[prepare,session]])assert.ok(scenes.find(s=>s.id===from).layers.some(l=>l.action?.target===to),`${from} -> ${to}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Scene layer keys are unique, including multipart Figma illustrations',()=>{
|
||||
for(const scene of scenes)assert.equal(new Set(scene.layers.map(l=>l.id)).size,scene.layers.length,scene.id);
|
||||
});
|
||||
test('Short Figma text boxes retain their no-wrap layout instruction',()=>{
|
||||
const layer=scenes.find(s=>s.id==='2:268').layers.find(l=>l.id==='2:275');
|
||||
assert.equal(layer.nowrap,true);
|
||||
assert.equal(layer.text,'Personnalisons votre\nexpérience');
|
||||
});
|
||||
|
||||
test('Every explanatory panel is reachable from a source information icon',()=>{
|
||||
const targets=new Set(scenes.flatMap(s=>s.layers.filter(l=>l.action?.type==='overlay').map(l=>l.action.target)));
|
||||
assert.deepEqual(targets,new Set(scenes.filter(s=>s.w===362).map(s=>s.id)));
|
||||
});
|
||||
|
||||
test('Reference typography distinguishes text colors from text sizes',()=>{
|
||||
const profile=scenes.find(s=>s.id==='565:3818');
|
||||
assert.equal(profile.layers.find(l=>l.id==='565:3824').runs[0].size,28);
|
||||
assert.equal(profile.layers.find(l=>l.id==='565:3836').runs[0].size,10);
|
||||
});
|
||||
test('Exported vector icons are not painted over rectangular placeholder fills',()=>{
|
||||
for(const scene of scenes)for(const layer of scene.layers)if(layer.t==='VECTOR'&&scene.layers.some(c=>c.asset&&c.path.includes(layer.id)))assert.equal(layer.f?.length??0,0,layer.id);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const {execFile} = require('child_process');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const manifest = JSON.parse(fs.readFileSync(root + '/design/asset-manifest.json'));
|
||||
const extraPath = root + '/design/screenshot-manifest.json';
|
||||
if (fs.existsSync(extraPath)) Object.assign(manifest, JSON.parse(fs.readFileSync(extraPath)));
|
||||
const dir = root + '/assets/figma';
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
const assets = Object.entries(manifest); let cursor = 0;
|
||||
const registry = fs.existsSync(root + "/design/asset-files.json") ? JSON.parse(fs.readFileSync(root + "/design/asset-files.json")) : {}; const failures = [];
|
||||
function download(url, file) {return new Promise((resolve, reject) => execFile('/usr/bin/curl', ['--fail', '--location', '--retry', '3', '--silent', '--show-error', '--output', file, url], {maxBuffer: 1024 * 1024}, error => error ? reject(error) : resolve()));}
|
||||
async function worker() {while (cursor < assets.length) {const [id, asset] = assets[cursor++]; if (registry[id] && fs.existsSync(dir + '/' + registry[id])) continue; const tmp = dir + '/' + id.replace(':', '-') + '.tmp';try {await download(asset.url, tmp); const bytes = fs.readFileSync(tmp); if (!bytes.length) throw Error('Empty asset'); const filename = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 20) + '.' + asset.format; fs.renameSync(tmp, dir + '/' + filename); registry[id] = filename;}catch (error) {failures.push({id, error: String(error)});}}}
|
||||
Promise.all(Array.from({length: 4}, worker)).then(() => {fs.mkdirSync(root + '/src/design', {recursive: true});fs.writeFileSync(root + '/src/design/assets.ts', '// Exact Figma exports, bundled for offline use.\nexport const assets: Record<string, number> = {\n' + Object.entries(registry).map(([id, file]) => JSON.stringify(id) + ': require("../../assets/figma/' + file + '"),').join('\n') + '\n};\n');fs.writeFileSync(root + '/design/asset-files.json', JSON.stringify(registry, null, 2));console.log({downloaded: Object.keys(registry).length, unique: new Set(Object.values(registry)).size, failures});if (failures.length) process.exitCode = 1;});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Convert the saved Figma reference into React Native scene data. No screenshot
|
||||
// is used as a screen: text, fills and controls remain individual native layers.
|
||||
const fs=require('fs'),path=require('path'),parser=require('@babel/parser');
|
||||
const root=path.resolve(__dirname,'..');
|
||||
const contexts=JSON.parse(fs.readFileSync(root+'/design/contexts.json'));
|
||||
const native=JSON.parse(fs.readFileSync(root+'/design/native-properties.json'));
|
||||
const api=new Map(native.flatMap(s=>s.layers.map(l=>[l.id,l])));
|
||||
const xml=fs.readFileSync(root+'/design/metadata.xml','utf8');
|
||||
const decode=s=>s.replace(/"/g,'"').replace(/'|'/g,"'").replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
|
||||
const nodes={},stack=[];
|
||||
for(const line of xml.split('\n')){if(/^\s*<\//.test(line)){stack.pop();continue;}const m=line.match(/^\s*<([\w-]+)\s+(.*?)(\/?)>/);if(!m)continue;const a={};for(const [,k,v]of m[2].matchAll(/([\w-]+)="([^"]*)"/g))a[k]=decode(v);const parent=stack.at(-1);const n={id:a.id,name:a.name,t:m[1].toUpperCase(),x:+a.x||0,y:+a.y||0,w:+a.width||0,h:+a.height||0,parent,section:m[1]==='section'?a.name:parent?.section};n.gx=n.x+(parent?.gx||0);n.gy=n.y+(parent?.gy||0);nodes[n.id]=n;if(!m[3])stack.push(n);}
|
||||
function attr(n,key){const a=n.openingElement.attributes.find(a=>a.name?.name===key);return a?.value?.type==='StringLiteral'?a.value.value:a?.value?.expression;}
|
||||
function visit(n,fn){if(!n||typeof n!=='object')return;fn(n);for(const[k,v]of Object.entries(n)){if(['loc','extra'].includes(k))continue;if(Array.isArray(v))v.forEach(a=>visit(a,fn));else if(v&&typeof v==='object')visit(v,fn);}}
|
||||
function value(n){if(!n)return '';if(n.type==='StringLiteral'||n.type==='NumericLiteral')return String(n.value);if(n.type==='TemplateLiteral')return n.quasis.map(q=>q.value.cooked).join('');return '';}
|
||||
function textContent(n){if(n.type==='JSXText'){if(!n.value.trim())return '';return n.value.replace(/\s+/g,' ');}if(n.type==='JSXExpressionContainer')return value(n.expression);if(n.type==='JSXElement'){const tag=n.openingElement.name.name;return (n.children||[]).map(textContent).join('')+(tag==='p'?'\n':tag==='br'?'\n':'');}return '';}
|
||||
function color(s){if(!s)return null;s=s.replace(/_/g,' ');if(s==='white')s='#ffffff';if(s==='black')s='#000000';if(s==='transparent')return {r:0,g:0,b:0,a:0};if(s.startsWith('#')){let h=s.slice(1);if(h.length===3)h=h.split('').map(c=>c+c).join('');return {r:parseInt(h.slice(0,2),16)/255,g:parseInt(h.slice(2,4),16)/255,b:parseInt(h.slice(4,6),16)/255,a:h.length===8?parseInt(h.slice(6),16)/255:1};}const v=s.match(/rgba?\(([^)]+)\)/);if(v){const a=v[1].split(/[,\s]+/).map(Number);return {r:a[0]/255,g:a[1]/255,b:a[2]/255,a:a[3]??1};}return null;}
|
||||
const bracket=(c,key)=>c.match(new RegExp('(?:^| )'+key+'-\\[([^\\]]+)\\]'))?.[1];
|
||||
const px=(c,key,fallback)=>{const matches=[...c.matchAll(new RegExp('(?:^| )'+key+'-\\[([^\\]]+)\\]','g'))];const v=matches.map(m=>m[1]).find(v=>/^-?[\d.]+px$/.test(v));return v?parseFloat(v):fallback;};
|
||||
function paint(c,key){const s=bracket(c,key)||c.match(new RegExp('(?:^| )'+key+'-(white|black|transparent)(?: |$)'))?.[1];const col=color(s);return col?[{type:'SOLID',color:col,opacity:1}]:[];}
|
||||
function fillsFromStyle(n){const s=attr(n,'style');if(s?.type!=='ObjectExpression')return [];const bg=s.properties.find(p=>p.key?.name==='backgroundImage')?.value;const str=value(bg);if(!str)return [];const colors=[...str.matchAll(/rgba?\([^)]+\)/g)].map(m=>color(m[0])).filter(Boolean);if(!colors.length)return [];if(colors.every(c=>JSON.stringify(c)===JSON.stringify(colors[0])))return [{type:'SOLID',color:colors[0],opacity:1}];if(str.startsWith('linear-gradient(90deg')&&colors.length===4&&JSON.stringify(colors[0])===JSON.stringify(colors[1]))return [{type:'SOLID',color:colors[0],opacity:1}];return [{type:'GRADIENT_LINEAR',gradientStops:colors.map((color,i)=>({position:i/(colors.length-1),color}))}];}
|
||||
const manifest={},out=[];const errors=[];
|
||||
for(const [id,code]of Object.entries(contexts)){
|
||||
const frame=nodes[id];if(!frame){errors.push(id);continue;}const ast=parser.parse(code,{sourceType:'module',plugins:['jsx','typescript']});const urls={};let jsx;
|
||||
visit(ast,n=>{if(n.type==='VariableDeclarator'&&n.init?.type==='StringLiteral')urls[n.id.name]=n.init.value;if(n.type==='JSXElement'&&attr(n,'data-node-id')===id)jsx=n;});if(!jsx){errors.push(id);continue;}
|
||||
const imageCounts={};const scene={id,name:frame.name,w:frame.w,h:frame.h,section:frame.section,canvasX:frame.x,canvasY:frame.y,layers:[]};
|
||||
function walk(n,nearest=null,ancestors=[],clips=[],opacity=1){if(n.type!=='JSXElement')return;const nid=attr(n,'data-node-id');const cls=attr(n,'className')||'';const meta=nodes[nid];let current=nearest,nextAnc=ancestors,nextClips=clips,nextOpacity=opacity;
|
||||
if(meta){current={id:nid,name:meta.name,t:meta.t,x:meta.gx-frame.gx,y:meta.gy-frame.gy,w:meta.w,h:meta.h,path:ancestors};const original=api.get(nid);const opacityClass=cls.match(/(?:^| )opacity-(\d+)(?: |$)/)?.[1];nextOpacity=opacity*(original?.o??(opacityClass?Number(opacityClass)/100:1));current.o=nextOpacity;const ownDataChildren=[];for(const c of n.children)visit(c,v=>{if(v.type==='JSXElement'&&attr(v,'data-node-id'))ownDataChildren.push(v);});const rawText=textContent(n).replace(/\n$/,'');const isText=meta.t==='TEXT'||(!ownDataChildren.length&&rawText.trim()&&!/<img/.test(''));
|
||||
if(isText){current.nowrap=cls.includes('whitespace-nowrap');current.text=original?.text??rawText;current.t='TEXT';current.align=original?.align??(cls.includes('text-center')?'CENTER':cls.includes('text-right')?'RIGHT':'LEFT');current.valign=original?.valign??(cls.includes('justify-center')?'CENTER':'TOP');
|
||||
const size=px(cls,'text',16);const font=[...cls.matchAll(/font-\[([\d.]+)\]/g)].map(m=>m[1])[0]||bracket(cls,'font');const weight=font&&/^\d/.test(font)?+font:cls.includes('font-bold')?700:cls.includes('font-semibold')?600:cls.includes('font-medium')?500:cls.includes('font-light')?300:400;
|
||||
let line;visit(n,v=>{if(v.type==='JSXElement'){const c=attr(v,'className');if(typeof c==='string'&&px(c,'leading',0))line=px(c,'leading');}});
|
||||
current.runs=original?.runs??[{text:current.text,font:cls.includes('Inter')?'Inter':'SF Pro',size,weight,fill:paint(cls,'text'),line:{unit:'PIXELS',value:line||size*1.2},spacing:{unit:'PIXELS',value:px(cls,'tracking',0)},case:cls.includes('uppercase')?'UPPER':cls.includes('lowercase')?'LOWER':'ORIGINAL',decoration:cls.includes('underline')?'UNDERLINE':'NONE'}];}
|
||||
else{current.f=original?.f??[...paint(cls,'bg'),...fillsFromStyle(n)];current.r=original?.r??px(cls,'rounded',cls.includes('rounded-full')?9999:cls.includes('rounded-')?8:0);current.s=original?.s??paint(cls,'border');current.sw=original?.sw??(current.s.length?1:0);if(original?.e)current.e=original.e;}
|
||||
if(clips.length)current.clips=clips;
|
||||
scene.layers.push(current);nextAnc=[...ancestors,nid];if(/overflow-(clip|hidden)/.test(cls))nextClips=[...clips,{x:current.x,y:current.y,w:current.w,h:current.h,r:current.r||0}];}
|
||||
if(n.openingElement.name.name==='img'&¤t){const src=attr(n,'src');const url=typeof src==='string'?src:urls[src?.name];if(url){const count=imageCounts[current.id]=(imageCounts[current.id]||0)+1;const imageId=current.id+'-image'+(count>1?'-'+count:'');manifest[imageId]={url,format:url.endsWith('.svg')?'svg':'png'};let box={x:current.x,y:current.y,w:current.w,h:current.h};const dimension=(key,total,base)=>{const v=bracket(cls,key);return v?.endsWith('%')?parseFloat(v)*total/100:v?.endsWith('px')?parseFloat(v):base;};box.x+=dimension('left',box.w,0);box.y+=dimension('top',box.h,0);box.w=dimension('w',current.w,box.w);box.h=dimension('h',current.h,box.h);scene.layers.push({id:imageId,name:current.name,t:'IMAGE',...box,path:nextAnc,o:nextOpacity,asset:imageId,clips:url.endsWith('.png')?[...nextClips,{x:current.x,y:current.y,w:current.w,h:current.h,r:current.r||0}]:nextClips});}}
|
||||
for(const c of n.children||[])if(c.type==='JSXElement')walk(c,current,nextAnc,nextClips,nextOpacity);
|
||||
}
|
||||
walk(jsx);for(const layer of scene.layers)if(['VECTOR','BOOLEAN_OPERATION','STAR','POLYGON'].includes(layer.t)&&scene.layers.some(c=>c.asset&&c.path.includes(layer.id))){layer.f=[];layer.s=[];layer.e=[];}out.push(scene);
|
||||
}
|
||||
fs.writeFileSync(root+'/design/scenes.json',JSON.stringify(out));fs.writeFileSync(root+'/design/asset-manifest.json',JSON.stringify(manifest,null,2));console.log({frames:out.length,layers:out.reduce((n,s)=>n+s.layers.length,0),assets:Object.keys(manifest).length,errors});
|
||||
@@ -0,0 +1,9 @@
|
||||
const fs=require('fs');const path=require('path');const parser=require('@babel/parser');
|
||||
const root=path.resolve(__dirname,'..');const scenes=JSON.parse(fs.readFileSync(root+'/design/scenes.json'));const contexts=JSON.parse(fs.readFileSync(root+'/design/contexts.json'));
|
||||
const manifests={},screenshots=[];
|
||||
function attr(n,name){const a=n.openingElement.attributes.find(a=>a.name?.name===name);return a?.value?.value;}
|
||||
function walk(n,cb){if(!n||typeof n!=='object')return;cb(n);for(const [k,v] of Object.entries(n)){if(k==='loc'||k==='extra')continue;if(Array.isArray(v))v.forEach(x=>walk(x,cb));else if(v&&typeof v==='object')walk(v,cb);}}
|
||||
for(const scene of scenes){const code=contexts[scene.id];if(!code)continue;let ast;try{ast=parser.parse(code,{sourceType:'module',plugins:['jsx','typescript']});}catch(e){continue;}const urls={},nodes={};walk(ast,n=>{if(n.type==='VariableDeclarator'&&n.init?.type==='StringLiteral')urls[n.id.name]=n.init.value;if(n.type==='JSXElement'){const id=attr(n,'data-node-id');if(id)nodes[id]=n;}});
|
||||
for(const l of scene.layers.filter(l=>l.asset)){const node=nodes[l.id];const imgs=[];if(node)walk(node,n=>{if(n.type==='JSXElement'&&n.openingElement.name.name==='img'){const a=n.openingElement.attributes.find(a=>a.name?.name==='src');const url=urls[a?.value?.expression?.name]||a?.value?.value;if(url)imgs.push(url);}});
|
||||
if(imgs.length===1&&imgs[0].endsWith('.svg'))manifests[l.id]={url:imgs[0],format:'svg'};else screenshots.push({id:l.id,frame:scene.id,name:l.name,w:l.w,h:l.h,urls:imgs});}}
|
||||
fs.writeFileSync(root+'/design/asset-manifest.json',JSON.stringify(manifests,null,2));fs.writeFileSync(root+'/design/screenshot-assets.json',JSON.stringify(screenshots,null,2));console.log({mapped:Object.keys(manifests).length,screenshots:screenshots.length});
|
||||
Reference in New Issue
Block a user