@appaflytech/zeyrek-ai-mcp 0.0.49 → 0.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/knowledge/componentEvents.js +1 -1
- package/dist/knowledge/mobileComponentCatalog.js +6 -3
- package/dist/knowledge/mobileComponentEvents.js +9 -0
- package/dist/knowledge/mobileStore.js +14 -0
- package/dist/knowledge/platform/lint.js +36 -3
- package/dist/knowledge/platform/mobileRules.js +6 -0
- package/dist/platform/capture/actions.js +67 -0
- package/dist/platform/capture/capture.js +26 -21
- package/dist/platform/storeArt/compose.js +114 -0
- package/dist/platform/storeArt/deck.js +133 -0
- package/dist/platform/storeArt/gallery.js +331 -0
- package/dist/platform/storeArt/layouts.js +121 -0
- package/dist/platform/storeArt/styles.js +54 -0
- package/dist/resources/examples/mobile-reorder-persist.json +491 -0
- package/dist/resources/guides/microservice.md +7 -0
- package/dist/resources/guides/mobile-publish.md +13 -0
- package/dist/resources/guides/mobile.md +4 -0
- package/dist/resources/guides/page.md +62 -0
- package/dist/resources/prompts/mobile-publish-prepare.md +3 -1
- package/dist/resources/schema-fingerprint.json +2 -2
- package/dist/resources/store-art/editor.css +389 -0
- package/dist/resources/store-art/editor.html +123 -0
- package/dist/resources/store-art/editor.js +990 -0
- package/dist/tools/mobileStore.js +56 -19
- package/dist/tools/pages.js +21 -5
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ export const componentEventContracts = {
|
|
|
5
5
|
mount: { payload: {}, reads: 'self.filters · self.sortField · self.sortType · self.offset · self.limit' },
|
|
6
6
|
save: {
|
|
7
7
|
payload: {},
|
|
8
|
-
reads: "self.changeset = { inserted:[{ rowId, values }], updated:[{ rowId, id, patch }], deleted:[{ rowId, id }] } (komponent olaydan ÖNCE yazar). Bağlı tabloda (rows connectData) yazmayı runtime bağlantısı yapar, akış ardından koşar; hatalar self.rowErrors [{ rowId, field?, message }]. İşlemin bağlamı da olaydan ÖNCE yazılır (2026-09-21): self.operation (rowAdd|rowEdit|cellEdit|rowDelete|bulkEdit|bulkDelete|batchSave), self.editedRows (tam satırlar), self.deletedRows (silinen tam satırlar), self.editedCell ({ rowId, field, value, previousValue }, yalnız cellEdit), self.bulkPatch (yalnız bulkEdit). Her işlem için ayrı olay yoktur; tek save akışı self.operation ile ayırır"
|
|
8
|
+
reads: "self.changeset = { inserted:[{ rowId, values }], updated:[{ rowId, id, patch }], deleted:[{ rowId, id }] } (komponent olaydan ÖNCE yazar). Bağlı tabloda (rows connectData) yazmayı runtime bağlantısı yapar, akış ardından koşar; hatalar self.rowErrors [{ rowId, field?, message }]. İşlemin bağlamı da olaydan ÖNCE yazılır (2026-09-21): self.operation (rowAdd|rowEdit|cellEdit|rowDelete|bulkEdit|bulkDelete|batchSave|rowReorder), self.editedRows (tam satırlar), self.deletedRows (silinen tam satırlar), self.editedCell ({ rowId, field, value, previousValue }, yalnız cellEdit), self.bulkPatch (yalnız bulkEdit). Her işlem için ayrı olay yoktur; tek save akışı self.operation ile ayırır"
|
|
9
9
|
},
|
|
10
10
|
discard: { payload: {}, reads: 'self.discardedRowIds (vazgeçilen taslak satır kimlikleri)' },
|
|
11
11
|
rowClick: { payload: {}, reads: 'self.selectedRow (tıklanan satırın sunucu satırı; kaydedilmemiş yeni satırda taslak değerleri). Veri hücresine tıklamada yayılır — onay kutusu, ✏/🗑 ve aksiyon düğmeleri tetiklemez; çift tık önce iki rowClick üretir' },
|
|
@@ -350,8 +350,9 @@ export const mobileComponentKnowledge = {
|
|
|
350
350
|
'GRUPLAMA bu bileşende YOKTUR: başlıklar altında liste (türe/zamana göre görevler) `grouped-list`tir — sayaç başlık şablonunda `Section.count`, toplu eylem başlıktaki bir `button` (akışı `Section.items` okur).',
|
|
351
351
|
'Düşenler: `cardFill` (web ızgarasında kartı en uzun kartın boyuna uzatır; mobil satır içeriğinin boyundadır).',
|
|
352
352
|
'Çoklu seçim (MOBİLE ÖZGÜ): `selectionMode: multiple` satır başına onay kutusu çizer, dokunuş seçimi değiştirir ve `itemPress`/`change`/`select` YAYILMAZ; `selectionChange { selectedIndices, count }` yayılır. Seçim düğüme yazılır: `{{ <liste>.selectedItems.length }} seçildi`, `<liste>.selectedIndices`. `longPressSelects: true` uzun basışla kipi açar (basılan satır seçili). Kipi kapatmak: setter ile `selectionMode: \'single\'` + `selectedIndices: []` + `selectedItems: []`. Seçim başlığı ayrı bir `app-bar`dır, iki çubuk `isHidden` bağıyla yer değiştirir.',
|
|
353
|
-
'`swipeActions` (eylemin `icon`u etiketin üstünde glif olarak çizilir) ve `itemPressable` MOBİLE ÖZGÜDÜR (webde yok). Jest RN `Animated` + `PanResponder` ile kurulur
|
|
353
|
+
'`swipeActions` (eylemin `icon`u etiketin üstünde glif olarak çizilir) ve `itemPressable` MOBİLE ÖZGÜDÜR (webde yok). Jest RN `Animated` + `PanResponder` ile kurulur (`gesture-handler` kullanılmaz) ve yalnız YATAY harekette yakalanır, böylece dikey kaydırma kaba kalır. Tasarım kipinde satırlar şablon olarak çizildiği için jest hiç bağlanmaz.',
|
|
354
354
|
'`swipe` ve `itemPress` yükleri satırı `row` alanında TAŞIR; akış ifadesindeki `Row`/`RowIndex` bağı ayrı bir konudur (ARC-121).',
|
|
355
|
+
'SÜRÜKLE-SIRALA (MOBİLE ÖZGÜ, 2026-10-27): `reorderable: true` satır sonuna bir tutamaç (⠿) çizer; tutamaçtan sürüklenen satırı kardeşler kayarak yer açar, bırakınca `reorder { fromIndex, toIndex, row, patches }` yayılır. YALNIZ dikey ve tek sütunlu listede (yatay şerit ya da `columnCount` > 1 iken tutamaç çizilmez — MOB-035). `orderField` verilirse satırlar o sayı alanına göre çizilir (boşlar sonda) ve `patches` o alana yazılacak 1 tabanlı yeni sıraları taşır. Liste bırakılan sırayı FARKLI satırlar gelene kadar tutar: aynı satırlar yeniden okunursa (yenileme, aynı yanıt) sıra bozulmaz; kalıcılık akışın işidir ve servis hatasında liste eski sıraya kendiliğinden DÖNMEZ (`platform_guide(\'page\')` §Kural 1g-5). Kenar bandında sayfa kendiliğinden kayar; modal/alt sayfa içinde kaymaz. Tasarım kipinde tutamaç çizilir ama sürüklenmez. Tutamaç gövdeden ayrıdır: gövdedeki dokunuş `itemPress`, yatay kaydırma `swipe` olarak kalır. Erişilebilirlik: tutamaçta "yukarı taşı / aşağı taşı" eylemleri. Tutamacın rol adresi `<elementId>__row-<i>__handle` — yalnız çalışma anında; tasarım ve Düzenle kipinde kimlik basılmaz.',
|
|
355
356
|
'Düşenler: `autoRowSize` (mobil satır her zaman içeriğinin boyundadır), `size` (mobil kart hücresi çocuklarını büyütmez; 2026-09-30), `cardFill` (kart hücre boyuna gerilmez, içeriğinin boyundadır; 2026-10-14).'
|
|
356
357
|
],
|
|
357
358
|
propNotes: {
|
|
@@ -362,7 +363,9 @@ export const mobileComponentKnowledge = {
|
|
|
362
363
|
snap: '`none` · `item` — kaydırma kart + aralık adımına oturur.',
|
|
363
364
|
views: "Sabit iki görünüm: 'item' (satır şablonu) ve 'empty' (boş görünüm). Değer YAZILMAZ; görünümler children'daki iki view düğümüdür, başka şekil 422 `fixed-views`.",
|
|
364
365
|
selectionMode: '`single` (varsayılan, bugünkü `selectedIndex`) · `multiple` (onay kutulu çoklu seçim, `selectionChange`) · `none` (seçim izlenmez).',
|
|
365
|
-
longPressSelects: 'Uzun basış çoklu seçim kipini açar ve basılan satırı seçer.'
|
|
366
|
+
longPressSelects: 'Uzun basış çoklu seçim kipini açar ve basılan satırı seçer.',
|
|
367
|
+
reorderable: 'Tutamaçtan sürükleyerek sıralama (yalnız dikey, tek sütun); bırakınca `reorder` yayılır.',
|
|
368
|
+
orderField: "Sırayı tutan sayı alanının adı (ör. 'sira'); satırlar buna göre çizilir, `reorder.patches[].value` buraya yazılacak sıradır. Boşsa `patches` boş gelir."
|
|
366
369
|
}
|
|
367
370
|
},
|
|
368
371
|
modal: {
|
|
@@ -997,7 +1000,7 @@ export const mobileComponentKnowledge = {
|
|
|
997
1000
|
'`variant: table` (varsayılan) yatay kaydırılan tablodur: başlık satırı, sıralanabilir kolonda başlığa dokunuş (`<elementId>__sort-<kolon>`, yön oku başlıkta), eylem sütunu satırın sonunda; dikey kaydırma sayfanındır, tablonun üstünden başlayan dikey kaydırma sayfayı kaydırır; kolonların toplamı çerçeveden darsa kolonlar web gibi çerçeveye yayılır (`table-layout: fixed`). `variant: cards` satır başına kart çizer: ilk görünür görsel kolon kartın başına, ilk görsel olmayan kolon başlığa, kalanlar etiket-değer satırlarına; kart kipinde sıralama başlığı yoktur (sıralama akışla yazılır).',
|
|
998
1001
|
'Eylemler web paletiyle (`solid` birincil, `information` kenarlı, `transparent` hayalet, `error`/`danger` yıkıcı, `warning`, `dark`): `actionType: button` satırda düğmeler (`<elementId>__action-<satır>-<sıra>`), `dropdown` ⋯ düğmesi (`<elementId>__more-<satır>`) alttan açılan eylem sayfası (`<elementId>__sheet-action-<sıra>`; cihazda `modal` ile aynı katman). Satırın `_actions` dizisi web gibi eylemi ezer, `isHidden` gizler, `isDisabled` kilitler.',
|
|
999
1002
|
'Sayfalama (`isPaginated`): ‹ `1–10 / 57` › çubuğu (`<elementId>__page-prev`, `__page-next`); metin dil bağımsızdır. Web`in sayfa numarası kutusu ve sayfa başına satır seçimi yok (`limit` panelden ya da akıştan; düğümde hiç yazılmamışsa web gibi 10). Başlık doluyken yanındaki yenile düğmesi (`<elementId>__refresh`) `mount`u koşturur; `showRefresh: false` düğmeyi kaldırır.',
|
|
1000
|
-
'Düşenler: `style`, `actionsPinned`, `advancedFilter`, `filterPosition`, `showFilterSummary`, `showExport`, `showColumnManager`, `toolsPosition`, `footerPosition`, `stickyHeader`, `stickyFooter`, `paginationPosition`, `addRowShortcut`, `canReorderRows`, `orderField`, `revealEntity`, `rowDoubleClick`, `cellSelectionChange`; kolonların `relationTypeField` ve `relationTargets` alanı. Gelişmiş filtre oluşturucu, dışa aktarma, kolon menüsü, sürükle-sırala ve korumalı alanı gösterme telefonda yok; uzun veri için yol sayfalama ya da sonsuz kaydırmadır (satır sanallaştırma yok).',
|
|
1003
|
+
'Düşenler: `style`, `actionsPinned`, `advancedFilter`, `filterPosition`, `showFilterSummary`, `showExport`, `showColumnManager`, `toolsPosition`, `footerPosition`, `stickyHeader`, `stickyFooter`, `paginationPosition`, `addRowShortcut`, `canReorderRows`, `orderField`, `revealEntity`, `rowDoubleClick`, `cellSelectionChange`; kolonların `relationTypeField` ve `relationTargets` alanı. Gelişmiş filtre oluşturucu, dışa aktarma, kolon menüsü, sürükle-sırala ve korumalı alanı gösterme telefonda yok (sıralanabilir liste: `repeater` `reorderable`); uzun veri için yol sayfalama ya da sonsuz kaydırmadır (satır sanallaştırma yok).',
|
|
1001
1004
|
'Telefonda filtre (plan mobil-tasarimci-kabuk-ekranlari 34): `isFilterable` kolonlar (bağlı tabloda sunucunun kabiliyeti son sözü söyler) üstte çip şerididir (`<elementId>__chip-<kolon>`, başta rozetli `__filters`: etkin çip sayısı, ✕ hepsini temizler). Çipe dokunmak alt sayfa açar: metin kolonunda işleç (sunucunun açtıkları) + değer, seçenekli kolonda liste (`in` açıksa çoklu), anahtarda Evet/Hayır, ilişki kolonunda seçim bileşenlerinin lookup alt sayfası. Uygula `filters`a kök `and` tekil kural yazar (web filtre çubuğuyla aynı sözleşme) ve `offset: 0` ile `mount` koşturur; etkin çip mavi. Bağlı tabloda çipler bağlamanın `behavior.mobile.filterChips`ıyla açılır; `filterMode: none` her durumda kapatır.',
|
|
1002
1005
|
'Telefonda düzenleme (34): bağlantının yazdığı `editMode`/`canEditRow`/`canAddRow`/`canDeleteRow`/`bulkActions` okunur. Satıra dokunmak `rowClick`i yayar (`selectedRow` sessiz yazılır) ve düzenlenebilir kolon varsa "Satırı düzenle" alt sayfasını açar (alan türüne göre denetim: metin/sayı/tarih girişi, anahtar, seçenek çipleri, ilişkide lookup). `canAddRow` + `addRowButton` ≠ `none` "Yeni satır" düğmesi (`__add-row`). `bulkActions` açıksa uzun basış seçim kipidir (`rowSelectionChange`, `selectedRows`): çubukta Toplu düzenle (tek alan, bütün seçililere) ve Sil. Bağlamanın `behavior.mobile.swipeDelete`ı kartı kaydırınca Sil (onaylı) açar. Kayıt `save` olayıyla gider (changeset + `operation`, web f-22 ile aynı); `row`/`cell` kipi anında, `batch` alt çubuktaki Kaydet ile (`discard` Vazgeç). Kısmi başarıda hatalı satır kırmızı çerçeve + satır hatası, özet şeridi "2 satır yazıldı, 1 düşmedi" (`__save-outcome`); düşen satırlar seçili kalır.',
|
|
1003
1006
|
'Kart alanları ve sayfa (34): `variant: cards` iken kart bağlamanın `behavior.mobileCard`ından çizilir (başlık, sıralı etiket-değer alanları, sağ değer); eşleme yoksa ilk görsel/ilk metin kuralı. `paginationMode: infinite` ya da bağlamanın `behavior.mobile.infiniteScroll`ı (yazılmamışsa açık) sayfaları birleştirir: sayfa kaydırıcısı sona yaklaşınca sonraki `offset` kendiliğinden istenir, yeni sayfa sona eklenir, altta "Yükleniyor…"; sayfa çubuğu çizilmez (sayfa kaydırıcısı olmayan kapta "Daha fazla" `__load-more`). `behavior.mobile.pullToRefresh` sayfayı aşağı çekince listeyi yeniler (web önizlemede çekme yok: başlıkta yenile düğmesi). `showRefresh: false` yenile düğmesini kaldırır. `showRecordSummary: false` kayıt aralığı metnini kaldırır. Bunların ve yukarıdaki çip/düzenleme davranışlarının hepsi yalnız `variant: cards` kipindedir: `variant: table` salt okunur ızgaradır (dokunuş, düzenleme, seçim, çip yok).',
|
|
@@ -295,6 +295,15 @@ export const mobileComponentEventContracts = {
|
|
|
295
295
|
count: 'number — seçili satır sayısı'
|
|
296
296
|
},
|
|
297
297
|
reads: 'yalnız selectionMode: multiple (ya da longPressSelects ile açılınca); akış event.data.selectedIndices / event.data.count okur, düğümün selectedIndices/selectedItems değeri de yazılır'
|
|
298
|
+
},
|
|
299
|
+
reorder: {
|
|
300
|
+
payload: {
|
|
301
|
+
fromIndex: 'number — satırın bırakılmadan önceki sırası (ekrandaki, 0 tabanlı)',
|
|
302
|
+
toIndex: 'number — bırakıldığı sıra',
|
|
303
|
+
row: 'object — taşınan satırın verisi',
|
|
304
|
+
patches: "Array<{ id, value }> — sırası değişen satırlar: id satırın _id'si (yoksa id'si), value orderField'a yazılacak yeni 1 tabanlı adım; eş değerli satırlar tek adımdır (aynı value), iki eş satırın arasına bırakılan satır o adıma katılır. Yalnız değeri değişenler gelir"
|
|
305
|
+
},
|
|
306
|
+
reads: "self'e yazılmaz — event.data.patches ile oku. Yalnız reorderable açık, dikey ve tek sütunlu listede; satır kendi yerine bırakılırsa olay yayılmaz. orderField yoksa ya da bir satırda _id/id yoksa patches BOŞ dizidir (sıra yalnız ekranda tutulur). Seçim varsa düğümün selectedIndices/selectedItems/selectedIndex değeri yeni sıraya taşınarak yazılır"
|
|
298
307
|
}
|
|
299
308
|
}
|
|
300
309
|
};
|
|
@@ -82,6 +82,20 @@ export const SCREENSHOT_CAPTURE_DEVICES = {
|
|
|
82
82
|
tablet7: { width: 600, height: 960, scale: 2 },
|
|
83
83
|
tablet10: { width: 800, height: 1280, scale: 2 }
|
|
84
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* Pazarlama karesinin cihaz çerçevesi (`mobile_asset_preview`, ADR 0004). Tuval mağaza ölçüsüdür
|
|
87
|
+
* (`SCREENSHOT_CAPTURE_DEVICES` × scale), çerçeve CSS ile çizilir: Apple'ın cihaz görselleri yeniden
|
|
88
|
+
* dağıtılamaz. Cihaz sınıfları arasındaki fark yalnız bu satırlardır, kodda dallanma yoktur.
|
|
89
|
+
*/
|
|
90
|
+
export const STORE_FRAME_LAYOUTS = {
|
|
91
|
+
// Gerçek ekran köşesi ~0.125'tir; runtime karesinde alt güvenli alan olmadığı için o köşe sekme çubuğu
|
|
92
|
+
// etiketlerini kırpıyordu (2026-10-26, mobiltest `ana`) — köşe biraz daraltıldı.
|
|
93
|
+
iphone69: { deviceWidth: 0.78, bezel: 0.03, radius: 0.12, cutout: 'island' },
|
|
94
|
+
ipad13: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' },
|
|
95
|
+
phone: { deviceWidth: 0.76, bezel: 0.028, radius: 0.11, cutout: 'punch' },
|
|
96
|
+
tablet7: { deviceWidth: 0.8, bezel: 0.035, radius: 0.055, cutout: 'none' },
|
|
97
|
+
tablet10: { deviceWidth: 0.8, bezel: 0.03, radius: 0.05, cutout: 'none' }
|
|
98
|
+
};
|
|
85
99
|
/** Beyan anahtarları (`MobileStoreSettings.*Declaration`); iOS appPrivacy, ötekiler Android */
|
|
86
100
|
export const DECLARATION_KEYS = ['appPrivacy', 'dataSafety', 'contentRating', 'targetAudience', 'appAccess'];
|
|
87
101
|
/**
|
|
@@ -67,6 +67,8 @@ const TEMPLATE_VALUE = /\{\{[\s\S]*\}\}/;
|
|
|
67
67
|
* düşer (koşu 37 K37-c, `var(--ng-surface)`). Web profili bu kuralı koşmaz.
|
|
68
68
|
*/
|
|
69
69
|
const STYLE_GROUP_PROPS = new Set(['surfaceStyle', 'typographyStyle', 'headerStyle']);
|
|
70
|
+
// MOB-035'in kararını veren liste prop'ları: birine tek prop yazımı kayıtlı ağacı okutur.
|
|
71
|
+
const REORDER_PROPS = new Set(['reorderable', 'direction', 'columnCount']);
|
|
70
72
|
const UNRESOLVED_TOKEN = /var\(\s*(--ng-[\w-]+)\s*\)/g;
|
|
71
73
|
let themeVarNames;
|
|
72
74
|
function nearestThemeVar(name) {
|
|
@@ -115,6 +117,8 @@ export function profileFindings(profile, input) {
|
|
|
115
117
|
const namelessIconButtons = [];
|
|
116
118
|
const unknownTokens = [];
|
|
117
119
|
const motionSkips = [];
|
|
120
|
+
const flatReorder = [];
|
|
121
|
+
const repeaterProps = new Map();
|
|
118
122
|
const hit = (map, key, target) => map.set(key, [...(map.get(key) ?? []), target]);
|
|
119
123
|
const inspectTokens = (target, value) => {
|
|
120
124
|
for (const name of unknownThemeTokens(value)) {
|
|
@@ -205,6 +209,8 @@ export function profileFindings(profile, input) {
|
|
|
205
209
|
inspectMotion(target, props.motionStyle);
|
|
206
210
|
for (const group of STYLE_GROUP_PROPS)
|
|
207
211
|
inspectTokens(target, props[group]);
|
|
212
|
+
if (type === 'repeater')
|
|
213
|
+
repeaterProps.set(target, props);
|
|
208
214
|
if (type === 'image' && !imageHasDefiniteHeight(asRecord(node.appearance)))
|
|
209
215
|
floorImages.push(target);
|
|
210
216
|
if (type === 'button' && propValue(props.size) === 'icon' && !String(propValue(props.ariaLabel) ?? '').trim() && !String(propValue(props.text) ?? '').trim()) {
|
|
@@ -230,7 +236,12 @@ export function profileFindings(profile, input) {
|
|
|
230
236
|
};
|
|
231
237
|
walk(input.nodes, inspectNode);
|
|
232
238
|
const savedTypes = new Map();
|
|
233
|
-
|
|
239
|
+
const savedRepeaterProps = new Map();
|
|
240
|
+
walk(input.savedNodes, node => {
|
|
241
|
+
savedTypes.set(label(node), String(node.type ?? ''));
|
|
242
|
+
if (node.type === 'repeater')
|
|
243
|
+
savedRepeaterProps.set(label(node), asRecord(node.props) ?? {});
|
|
244
|
+
});
|
|
234
245
|
// Toplu yamanın zarfı açılır: içindeki op'lar tek tek yazılmış gibi denetlenir (TOOL-057).
|
|
235
246
|
const ops = flattenBatchOps(input.ops ?? []);
|
|
236
247
|
for (const op of ops) {
|
|
@@ -258,6 +269,9 @@ export function profileFindings(profile, input) {
|
|
|
258
269
|
inspectMotion(target, op.value);
|
|
259
270
|
if (STYLE_GROUP_PROPS.has(prop))
|
|
260
271
|
inspectTokens(target, op.value);
|
|
272
|
+
const listProps = REORDER_PROPS.has(prop) ? repeaterProps.get(target) ?? savedRepeaterProps.get(target) : undefined;
|
|
273
|
+
if (listProps)
|
|
274
|
+
repeaterProps.set(target, { ...listProps, [prop]: op.value });
|
|
261
275
|
// MOB-011: var olan düğümün tipi op'ta yok — tip biliniyorsa (aynı yazımda eklendi) ona, yoksa prop adına bakılır.
|
|
262
276
|
const knownType = nodeTypes.get(target);
|
|
263
277
|
const imageProp = knownType === undefined ? IMAGE_SOURCE_PROP_NAMES.has(prop) : (IMAGE_SOURCE_PROPS[knownType] ?? []).includes(prop);
|
|
@@ -266,6 +280,15 @@ export function profileFindings(profile, input) {
|
|
|
266
280
|
fileIdSources.push({ target: `${target}.${prop}`, expression });
|
|
267
281
|
}
|
|
268
282
|
}
|
|
283
|
+
// MOB-035: sıralama yalnız dikey, tek sütunlu listededir (frontend `reorderEnabled`); yatay şeritte ve ızgarada
|
|
284
|
+
// `reorderable` yazım kabul edilir ama tutamaç çizilmez — sessiz başarı. Denetlenen, yazımdan SONRAKİ prop'lardır:
|
|
285
|
+
// eklenen düğümün kendi prop'ları, var olan düğüme tek prop yazımında kayıtlı prop'larla birleşimi. Dokunulmayan
|
|
286
|
+
// kayıtlı liste denetlenmez. Sütun sayısı çizerinki gibi tam sayıya kesilir (`repeaterColumnCount`: 1.5 tek sütundur).
|
|
287
|
+
for (const [target, props] of repeaterProps) {
|
|
288
|
+
const columns = Math.trunc(Number(propValue(props.columnCount)));
|
|
289
|
+
if (propValue(props.reorderable) === true && (propValue(props.direction) === 'horizontal' || (Number.isFinite(columns) && columns > 1)))
|
|
290
|
+
flatReorder.push(target);
|
|
291
|
+
}
|
|
269
292
|
if (blockNodes.length > 0 || gridNodes.length > 0) {
|
|
270
293
|
findings.push({
|
|
271
294
|
code: MOBILE_RULES['MOB-001'].id,
|
|
@@ -302,6 +325,15 @@ export function profileFindings(profile, input) {
|
|
|
302
325
|
guide: "platform_guide('page') §Kural 1g-3"
|
|
303
326
|
});
|
|
304
327
|
}
|
|
328
|
+
if (flatReorder.length > 0) {
|
|
329
|
+
findings.push({
|
|
330
|
+
code: MOBILE_RULES['MOB-035'].id,
|
|
331
|
+
severity: 'warn',
|
|
332
|
+
target: flatReorder.join(', '),
|
|
333
|
+
message: `reorderable yalnız dikey, tek sütunlu listede çalışır — yatay şeritte (direction: horizontal) ve çok sütunda (columnCount > 1) tutamaç çizilmez, satırlar sürüklenmez. Sıralama gerekiyorsa listeyi dikey ve tek sütun yapın. ${NOT_BLOCKING}`,
|
|
334
|
+
guide: "platform_guide('page') §Kural 1g-5"
|
|
335
|
+
});
|
|
336
|
+
}
|
|
305
337
|
if (motionSkips.length > 0) {
|
|
306
338
|
findings.push({
|
|
307
339
|
code: MOBILE_RULES['MOB-028'].id,
|
|
@@ -483,12 +515,13 @@ function walkSteps(value, visit) {
|
|
|
483
515
|
}
|
|
484
516
|
/**
|
|
485
517
|
* Denetim bu yama için kayıtlı ağacı ister mi (`ProfileLintInput.savedNodes`): `style` yazımında hedefin tipi (belge
|
|
486
|
-
* CSS'i taşıyan düğüm mü — MOB-002), `heroTag` yazımında sayfanın öteki etiketleri (MOB-030)
|
|
518
|
+
* CSS'i taşıyan düğüm mü — MOB-002), `heroTag` yazımında sayfanın öteki etiketleri (MOB-030), sıralama prop'u yazımında
|
|
519
|
+
* listenin öteki prop'ları (MOB-035). Çağıran ağacı yalnız bu
|
|
487
520
|
* durumda okur. Denetimle AYNI op listesine bakar (zarf açılır): 2026-10-10'da denetim zarfı açıp çağıran açmayınca
|
|
488
521
|
* kayıtlı `html` düğümünün belge CSS'i zarf içinde yanlış MOB-002 aldı (regresyon `test/batch-profile-lint.test.ts`).
|
|
489
522
|
*/
|
|
490
523
|
export function profileLintReadsSavedNodes(ops) {
|
|
491
|
-
return writesHeroTag(ops) || flattenBatchOps(ops).some(op => (op.op === 'replace_prop' || op.op === 'set_binding') && op.prop === 'style');
|
|
524
|
+
return writesHeroTag(ops) || flattenBatchOps(ops).some(op => (op.op === 'replace_prop' || op.op === 'set_binding') && (op.prop === 'style' || REORDER_PROPS.has(String(op.prop))));
|
|
492
525
|
}
|
|
493
526
|
/** Bulguları `layoutWarnings[]` satırlarına çevirir. */
|
|
494
527
|
export function findingLines(findings) {
|
|
@@ -133,6 +133,12 @@ export const MOBILE_RULES = {
|
|
|
133
133
|
sourceRef: 'zeyrek-frontend/packages/ng-apps-ui-native/src/motion/hero-tag.ts (kural; ayna heroTag.ts); zeyrek-frontend/docs/adr/0022-hero-yolu.md; simülatör ölçümü 2026-10-10',
|
|
134
134
|
summary: "Aynı sayfada aynı statik heroTag'i birden çok düğüm taşıyor — sayfa yakınlaşmanın hedefi olduğunda ilk çizilen hizalanır; kaynak sayfada sorun değildir"
|
|
135
135
|
},
|
|
136
|
+
'MOB-035': {
|
|
137
|
+
id: 'MOB-035',
|
|
138
|
+
severity: 'warn',
|
|
139
|
+
sourceRef: 'zeyrek-frontend/packages/ng-apps-ui-native/src/components/repeater/repeater-reorder-logic.ts (reorderEnabled; plans/mobil-jest-katmani faz 02)',
|
|
140
|
+
summary: 'Sıralanabilir liste (`reorderable`) yatay şeritte ya da çok sütunda — çizer tutamaç çizmez, sürükleme yok'
|
|
141
|
+
},
|
|
136
142
|
'MOB-033': {
|
|
137
143
|
id: 'MOB-033',
|
|
138
144
|
severity: 'warn',
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { INTERNAL_ROLE_SEPARATOR } from '../../knowledge/componentCatalog.js';
|
|
2
2
|
import { studioWebTarget } from './targets/studio-web.js';
|
|
3
|
+
/**
|
|
4
|
+
* `swipe` jestinin adım sayısı ve aralığı: react-native-web yanıtlayıcısı tek seferde atılan hareketi jest saymaz
|
|
5
|
+
* (`ui-native.md` §flip-card ve swipeable-stack); 12 × 16 ms oyun alanında tutamaç sıralamasını sürdü (g-02, 2026-10-27).
|
|
6
|
+
*/
|
|
7
|
+
export const SWIPE_STEPS = 12;
|
|
8
|
+
export const SWIPE_INTERVAL_MS = 16;
|
|
9
|
+
/**
|
|
10
|
+
* `to` verilen jest hedefin içinde, hareket yönündeki kenarına bu kadar (CSS pikseli) kala biter. Merkezde bitseydi
|
|
11
|
+
* sürüklenen satırın merkezi hedefinkine EŞİT olurdu; çizer kardeşi kesin `<` ile saydığından (`reorderTarget`) aşağı
|
|
12
|
+
* taşıma bir satır eksik inerdi (inceleme bulgusu 2, oyun alanında `row-0 → row-2` → `reorder 0→1`).
|
|
13
|
+
*/
|
|
14
|
+
export const SWIPE_EDGE_INSET = 2;
|
|
3
15
|
/**
|
|
4
16
|
* G-24 — gateway sayfayı kurabiliyor, okuyabiliyor ve çizdirebiliyordu ama üzerinde OLAY
|
|
5
17
|
* TETİKLEYEMİYORDU: "filtre değişince veri yenileniyor" iddiası yalnız şema okumasıyla
|
|
@@ -273,6 +285,61 @@ export function actionScript(action, captureTarget = studioWebTarget) {
|
|
|
273
285
|
return { found: true, applied: true, requests: count };
|
|
274
286
|
})()`;
|
|
275
287
|
}
|
|
288
|
+
// g-02 (plans/mobil-jest-katmani) — mobil jestler (tutamaçtan sıralama, satır kaydırma, deste, kaydırıcı) işaretçiyi
|
|
289
|
+
// dinler; HTML5 `drag` onları süremez. Gerçek tarayıcı gibi her anda pointer + mouse birlikte atılır: oyun alanında
|
|
290
|
+
// yalnız `pointer*` react-native-web yanıtlayıcısını SÜRMEDİ, `mouse*` sürdü (ölçüldü 2026-10-27). Hareket olayı
|
|
291
|
+
// parmağın altındaki öğeye gider; basış kaynağa.
|
|
292
|
+
if (action.action === 'swipe') {
|
|
293
|
+
const destination = action.to === undefined ? 'null' : JSON.stringify(`[data-element-id="${action.to}"]`);
|
|
294
|
+
const webNote = captureTarget.name === 'studio-mobile'
|
|
295
|
+
? ''
|
|
296
|
+
: "swipe dokunmatik jesttir: web sayfasında yalnız işaretçiyi dinleyen öğeyi sürer — HTML5 sürükle-bırak (kanban, takvim) için 'drag'";
|
|
297
|
+
return `(function () {
|
|
298
|
+
var count = performance.getEntriesByType('resource').length;
|
|
299
|
+
var node = document.querySelector(${selector});
|
|
300
|
+
if (!node) return { found: false, applied: false, requests: count };
|
|
301
|
+
var center = function (element) { var rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; };
|
|
302
|
+
var from = center(node);
|
|
303
|
+
var to;
|
|
304
|
+
var destination = ${destination};
|
|
305
|
+
if (destination !== null) {
|
|
306
|
+
var goal = document.querySelector(destination);
|
|
307
|
+
if (!goal) return { found: false, applied: false, note: "jest kaynağı bulundu ama bitiş hedefi ('to') sayfada yok: " + ${JSON.stringify(String(action.to ?? ''))} + " — satır <elementId>__row-<i>, tutamaç <elementId>__row-<i>__handle", requests: count };
|
|
308
|
+
var box = goal.getBoundingClientRect();
|
|
309
|
+
var middle = center(goal);
|
|
310
|
+
var edge = function (start, mid, low, high) { return mid > start ? high - ${SWIPE_EDGE_INSET} : mid < start ? low + ${SWIPE_EDGE_INSET} : mid; };
|
|
311
|
+
to = { x: edge(from.x, middle.x, box.left, box.left + box.width), y: edge(from.y, middle.y, box.top, box.top + box.height) };
|
|
312
|
+
} else {
|
|
313
|
+
to = { x: from.x + ${Number(action.dx ?? 0)}, y: from.y + ${Number(action.dy ?? 0)} };
|
|
314
|
+
}
|
|
315
|
+
var fire = function (type, at, element) {
|
|
316
|
+
var init = { bubbles: true, cancelable: true, composed: true, clientX: at.x, clientY: at.y, screenX: at.x, screenY: at.y, button: 0, buttons: /up$/.test(type) ? 0 : 1, pointerId: 1, pointerType: 'mouse', isPrimary: true };
|
|
317
|
+
element.dispatchEvent(type.indexOf('pointer') === 0 ? new PointerEvent(type, init) : new MouseEvent(type, init));
|
|
318
|
+
};
|
|
319
|
+
var under = function (at) { return document.elementFromPoint(at.x, at.y) || node; };
|
|
320
|
+
var finish = function (result) { if (${JSON.stringify(webNote)}) result.note = ${JSON.stringify(webNote)}; return result; };
|
|
321
|
+
return new Promise(function (resolve) {
|
|
322
|
+
fire('pointerdown', from, node);
|
|
323
|
+
fire('mousedown', from, node);
|
|
324
|
+
var step = 0;
|
|
325
|
+
var move = function () {
|
|
326
|
+
step += 1;
|
|
327
|
+
var at = { x: from.x + ((to.x - from.x) * step) / ${SWIPE_STEPS}, y: from.y + ((to.y - from.y) * step) / ${SWIPE_STEPS} };
|
|
328
|
+
var element = under(at);
|
|
329
|
+
fire('pointermove', at, element);
|
|
330
|
+
fire('mousemove', at, element);
|
|
331
|
+
if (step < ${SWIPE_STEPS}) { setTimeout(move, ${SWIPE_INTERVAL_MS}); return; }
|
|
332
|
+
setTimeout(function () {
|
|
333
|
+
var end = under(to);
|
|
334
|
+
fire('pointerup', to, end);
|
|
335
|
+
fire('mouseup', to, end);
|
|
336
|
+
resolve(finish({ found: true, applied: true, requests: count }));
|
|
337
|
+
}, ${SWIPE_INTERVAL_MS});
|
|
338
|
+
};
|
|
339
|
+
setTimeout(move, ${SWIPE_INTERVAL_MS});
|
|
340
|
+
});
|
|
341
|
+
})()`;
|
|
342
|
+
}
|
|
276
343
|
const target = String(action.target ?? '');
|
|
277
344
|
const root = rootElementId(target);
|
|
278
345
|
// G-14 — `applied` yalnız "DOM'a yazıldı" demekti: tarih seçici olayı dinlemiyordu, kutu dolu
|
|
@@ -188,6 +188,31 @@ async function resolveClip(page, clip) {
|
|
|
188
188
|
}
|
|
189
189
|
return { x: Math.round(raw.x), y: Math.round(raw.y), width: Math.round(raw.width), height: Math.round(raw.height) };
|
|
190
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Gateway'in varsayılan tarayıcısı: kalıcı profilli Chromium. `mobile_asset_preview` de bunu kullanır
|
|
193
|
+
* (ADR 0004); aynı süreçte ikinci açılış profil dizini dolu olduğu için sıradaki yuvaya geçer.
|
|
194
|
+
*/
|
|
195
|
+
export async function launchScreenshotBrowser(config) {
|
|
196
|
+
let chromium;
|
|
197
|
+
try {
|
|
198
|
+
chromium = (await import('playwright-core')).chromium;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new PlatformApiError('playwright-core paketi yüklü değil — zeyrek-mcp dizininde `npm install playwright-core` çalıştırın, ardından tarayıcı için `npx playwright-core install chromium`.');
|
|
202
|
+
}
|
|
203
|
+
logStep('tarayıcı →', `chromium başlatılıyor (ilk çağrı — sonraki çağrılar önbellekteki tarayıcıyı kullanır) · profil ${screenshotProfileRoot()}`);
|
|
204
|
+
const open = persistentContextOpener(chromium, config.screenshotBrowserPath ? { executablePath: config.screenshotBrowserPath } : {});
|
|
205
|
+
const browser = new PersistentScreenshotBrowser(open);
|
|
206
|
+
try {
|
|
207
|
+
// İlk bağlam burada açılır ki başlatma hatası eskisi gibi `launch()`'tan dönsün.
|
|
208
|
+
await (await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 })).close();
|
|
209
|
+
}
|
|
210
|
+
catch (firstError) {
|
|
211
|
+
const message = firstError instanceof Error ? firstError.message : String(firstError);
|
|
212
|
+
throw new PlatformApiError(`Chromium başlatılamadı: ${message}. \`npx playwright-core install chromium\` ile indirin; alternatif olarak sistemdeki tarayıcının yolunu SCREENSHOT_BROWSER_PATH ile verin (alpine imajında \`apk add chromium\` + /usr/bin/chromium-browser).`);
|
|
213
|
+
}
|
|
214
|
+
return browser;
|
|
215
|
+
}
|
|
191
216
|
/**
|
|
192
217
|
* Playwright bağlamı ve tarayıcısı bu sınıfta PAYLAŞILIR. İki çekim aynı anda koştuğunda
|
|
193
218
|
* 2026-09-06 koşusunda ikisi de `Target page, context or browser has been closed` ile düştü
|
|
@@ -272,27 +297,7 @@ export class StudioScreenshotter {
|
|
|
272
297
|
}
|
|
273
298
|
}
|
|
274
299
|
async launch() {
|
|
275
|
-
|
|
276
|
-
return this.launcher();
|
|
277
|
-
let chromium;
|
|
278
|
-
try {
|
|
279
|
-
chromium = (await import('playwright-core')).chromium;
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
throw new PlatformApiError('playwright-core paketi yüklü değil — zeyrek-mcp dizininde `npm install playwright-core` çalıştırın, ardından tarayıcı için `npx playwright-core install chromium`.');
|
|
283
|
-
}
|
|
284
|
-
logStep('tarayıcı →', `chromium başlatılıyor (ilk çağrı — sonraki çağrılar önbellekteki tarayıcıyı kullanır) · profil ${screenshotProfileRoot()}`);
|
|
285
|
-
const open = persistentContextOpener(chromium, this.config.screenshotBrowserPath ? { executablePath: this.config.screenshotBrowserPath } : {});
|
|
286
|
-
const browser = new PersistentScreenshotBrowser(open);
|
|
287
|
-
try {
|
|
288
|
-
// İlk bağlam burada açılır ki başlatma hatası eskisi gibi `launch()`'tan dönsün.
|
|
289
|
-
await (await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 })).close();
|
|
290
|
-
}
|
|
291
|
-
catch (firstError) {
|
|
292
|
-
const message = firstError instanceof Error ? firstError.message : String(firstError);
|
|
293
|
-
throw new PlatformApiError(`Chromium başlatılamadı: ${message}. \`npx playwright-core install chromium\` ile indirin; alternatif olarak sistemdeki tarayıcının yolunu SCREENSHOT_BROWSER_PATH ile verin (alpine imajında \`apk add chromium\` + /usr/bin/chromium-browser).`);
|
|
294
|
-
}
|
|
295
|
-
return browser;
|
|
300
|
+
return this.launcher ? this.launcher() : launchScreenshotBrowser(this.config);
|
|
296
301
|
}
|
|
297
302
|
/**
|
|
298
303
|
* Bekleme stratejisi bilinçli olarak `networkidle` DEĞİL: Studio'nun kök layout'u
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { PlatformApiError } from '../errors.js';
|
|
5
|
+
import { imageSize } from '../capture/capture.js';
|
|
6
|
+
import { STORE_ART_LAYOUTS, storeCanvas } from './layouts.js';
|
|
7
|
+
import { BACKDROPS, FONT_STACKS, resolveStyle } from './styles.js';
|
|
8
|
+
import { cardFileName } from './deck.js';
|
|
9
|
+
/**
|
|
10
|
+
* Mağaza pazarlama karesi (ADR 0004, 0005): ham ekran görüntüsü yerleşimin kutularına oturtulur, stilin
|
|
11
|
+
* zemini, yazısı ve sahnesiyle mağazanın istediği ölçüde alfasız JPEG çizilir. Çizim gateway'in Chromium'uyla
|
|
12
|
+
* yapılır; kare HTML'i saf fonksiyondur ve yerleşime ya da stile göre dallanmaz — ikisi de kayıttan okunur.
|
|
13
|
+
* Editörün önizlemesi de aynı HTML'dir (`cardHtml`); önizleme ile çıktı ayrışamaz.
|
|
14
|
+
*/
|
|
15
|
+
export { storeCanvas } from './layouts.js';
|
|
16
|
+
const jpegQuality = 92;
|
|
17
|
+
const renderTimeoutMs = 30000;
|
|
18
|
+
/**
|
|
19
|
+
* Görseller `decode()` ile beklenir: `domcontentloaded` anında `<img>` henüz çözülmemiş olabilir ve kare
|
|
20
|
+
* boş ekranla çıkardı. Yazı tipi de beklenir; satır kırılımı yüklenen yazı tipine göre değişir.
|
|
21
|
+
*/
|
|
22
|
+
const slideReadyScript = "Promise.all([document.fonts.ready, ...Array.from(document.images).map(image => image.decode())]).then(() => true, error => String(error && error.message ? error.message : error))";
|
|
23
|
+
export function slideGeometry(deviceClass, frame, captionPosition, layout = 'classic') {
|
|
24
|
+
return STORE_ART_LAYOUTS[layout].geometry({ deviceClass, frame, captionPosition });
|
|
25
|
+
}
|
|
26
|
+
const escapeHtml = (value) => value.replace(/[&<>"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
27
|
+
const place = (box) => `left:${box.x}px;top:${box.y}px;width:${box.width}px;height:${box.height}px`;
|
|
28
|
+
const clamp = (lines) => `display:-webkit-box;-webkit-line-clamp:${lines};-webkit-box-orient:vertical;overflow:hidden`;
|
|
29
|
+
/**
|
|
30
|
+
* `locale` sayfanın `lang`'idir: CSS `text-transform: uppercase` dilin kuralını ondan alır — dil yokken Türkçe
|
|
31
|
+
* "bir" "BIR" çiziliyordu, "BİR" olmalı (2026-10-26, swiss stili).
|
|
32
|
+
*/
|
|
33
|
+
export function slideHtml(geometry, style, slide, imageUrl, locale = 'en') {
|
|
34
|
+
const { canvas, caption, device, screen, cutout } = geometry;
|
|
35
|
+
const phone = device ?? screen;
|
|
36
|
+
const shadow = `0 ${Math.round(canvas.height * 0.012)}px ${Math.round(canvas.height * 0.03)}px rgba(0,0,0,${style.shadow})`;
|
|
37
|
+
const glow = style.glow ? `,0 0 ${Math.round(canvas.width * 0.09)}px ${style.colors.accent}88` : '';
|
|
38
|
+
const showScreen = screen !== undefined && imageUrl !== undefined;
|
|
39
|
+
return [
|
|
40
|
+
`<!doctype html><html lang="${escapeHtml(locale)}"><head><meta charset="utf-8"><style>`,
|
|
41
|
+
'*{margin:0;padding:0;box-sizing:border-box}',
|
|
42
|
+
`html,body{width:${canvas.width}px;height:${canvas.height}px;overflow:hidden}`,
|
|
43
|
+
`body{position:relative;background:${BACKDROPS[style.backdrop](style.colors, canvas)};color:${style.colors.text};font-family:${FONT_STACKS[style.font]};-webkit-font-smoothing:antialiased}`,
|
|
44
|
+
`.caption{position:absolute;${place(caption)};display:flex;flex-direction:column;justify-content:center;align-items:${style.align === 'left' ? 'flex-start' : 'center'};gap:${Math.round(geometry.subtitleSize * 0.5)}px;text-align:${style.align}}`,
|
|
45
|
+
`.eyebrow{font-size:${geometry.eyebrowSize}px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${style.colors.accent}}`,
|
|
46
|
+
`.title{font-size:${geometry.titleSize}px;font-weight:${style.titleWeight};line-height:1.1;letter-spacing:-0.01em;text-transform:${style.titleCase};${clamp(geometry.titleLines)}}`,
|
|
47
|
+
`.subtitle{font-size:${geometry.subtitleSize}px;font-weight:500;line-height:1.25;opacity:0.8;${clamp(2)}}`,
|
|
48
|
+
phone ? `.phone{position:absolute;inset:0;transform-origin:${phone.x + phone.width / 2}px ${phone.y + phone.height / 2}px;transform:${geometry.phoneTransform ?? 'none'}}` : '',
|
|
49
|
+
device ? `.device{position:absolute;${place(device)};border-radius:${device.radius}px;background:${style.frameColor};box-shadow:${shadow}${glow},inset 0 0 0 ${Math.max(Math.round(device.width * 0.004), 2)}px #3a3a3e}` : '',
|
|
50
|
+
screen ? `.screen{position:absolute;${place(screen)};border-radius:${screen.radius}px;overflow:hidden;background:#ffffff${device ? '' : `;box-shadow:${shadow}${glow}`}}` : '',
|
|
51
|
+
'.screen img{display:block;width:100%;height:100%;object-fit:cover;object-position:top}',
|
|
52
|
+
cutout ? `.cutout{position:absolute;${place(cutout)};border-radius:${cutout.radius}px;background:#000000}` : '',
|
|
53
|
+
'</style></head><body>',
|
|
54
|
+
'<div class="caption">',
|
|
55
|
+
slide.eyebrow ? `<div class="eyebrow">${escapeHtml(slide.eyebrow)}</div>` : '',
|
|
56
|
+
`<div class="title">${escapeHtml(slide.title)}</div>`,
|
|
57
|
+
slide.subtitle ? `<div class="subtitle">${escapeHtml(slide.subtitle)}</div>` : '',
|
|
58
|
+
'</div>',
|
|
59
|
+
showScreen ? '<div class="phone">' : '',
|
|
60
|
+
showScreen && device ? '<div class="device"></div>' : '',
|
|
61
|
+
showScreen ? `<div class="screen"><img src="${escapeHtml(imageUrl)}" alt=""></div>` : '',
|
|
62
|
+
showScreen && cutout ? '<div class="cutout"></div>' : '',
|
|
63
|
+
showScreen ? '</div>' : '',
|
|
64
|
+
'</body></html>'
|
|
65
|
+
].join('');
|
|
66
|
+
}
|
|
67
|
+
/** Kartın HTML'i; `imageUrl` ham karenin adresidir (çizimde file://, editör önizlemesinde galeri adresi) */
|
|
68
|
+
export function cardHtml(deck, spec, imageUrl) {
|
|
69
|
+
const raw = spec.raw === undefined || !STORE_ART_LAYOUTS[spec.layout].needsScreen ? undefined : deck.raws[spec.raw];
|
|
70
|
+
return slideHtml(slideGeometry(deck.deviceClass, spec.frame, spec.captionPosition, spec.layout), resolveStyle(spec.style, { primary: spec.accent, text: spec.text }), spec, raw ? imageUrl(raw) : undefined, deck.locale);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Kartları tek tarayıcıda sırayla çizer; her kart `fileName(card)` adıyla galeri dizinine yazılır. Editör geçici
|
|
74
|
+
* ad verir ve dosyaları ancak bütün iş başarılı olunca yerine taşır (yarıda düşen çizim kartı ezmesin).
|
|
75
|
+
*/
|
|
76
|
+
export async function renderCards(launch, deck, directory, cards, fileName = cardFileName) {
|
|
77
|
+
const canvas = storeCanvas(deck.deviceClass);
|
|
78
|
+
const renderDirectory = path.join(directory, '.render');
|
|
79
|
+
let browser;
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(renderDirectory, { recursive: true });
|
|
82
|
+
browser = await launch();
|
|
83
|
+
const context = await browser.newContext({ viewport: canvas, deviceScaleFactor: 1 });
|
|
84
|
+
try {
|
|
85
|
+
const page = await context.newPage();
|
|
86
|
+
const files = [];
|
|
87
|
+
for (const card of cards) {
|
|
88
|
+
const htmlPath = path.join(renderDirectory, `${card.name}.html`);
|
|
89
|
+
await writeFile(htmlPath, cardHtml(deck, card.spec, raw => pathToFileURL(raw.path).href));
|
|
90
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'domcontentloaded', timeout: renderTimeoutMs });
|
|
91
|
+
const ready = await page.evaluate(slideReadyScript);
|
|
92
|
+
if (ready !== true) {
|
|
93
|
+
throw new PlatformApiError(`mobile_asset_preview: slides[${card.slide}] çizilemedi — ham kare tarayıcıda açılmadı (${String(ready)}). Dosyanın bozuk olmadığını page_screenshot ile yeniden çekerek doğrulayın (kart ${card.name}).`, 422);
|
|
94
|
+
}
|
|
95
|
+
const bytes = await page.screenshot({ type: 'jpeg', quality: jpegQuality, fullPage: false });
|
|
96
|
+
const size = imageSize(bytes);
|
|
97
|
+
if (size === undefined || size.width !== canvas.width || size.height !== canvas.height) {
|
|
98
|
+
throw new PlatformApiError(`mobile_asset_preview: slides[${card.slide}] ${size ? `${size.width}×${size.height}` : 'ölçüsü okunamayan'} çizildi, ${deck.deviceClass} mağaza ölçüsü ${canvas.width}×${canvas.height}. Mağaza bu kareyi reddederdi; dosya yazılmadı (kart ${card.name}).`, 500);
|
|
99
|
+
}
|
|
100
|
+
const file = path.join(directory, fileName(card));
|
|
101
|
+
await writeFile(file, bytes);
|
|
102
|
+
files.push({ card: card.name, style: card.spec.style, index: card.slide, path: file, width: size.width, height: size.height });
|
|
103
|
+
}
|
|
104
|
+
return files;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await context.close().catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await browser?.close().catch(() => undefined);
|
|
112
|
+
await rm(renderDirectory, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { COLOR_PATTERN, SCREENSHOT_CAPTURE_DEVICES } from '../../knowledge/mobileStore.js';
|
|
5
|
+
import { PlatformApiError } from '../errors.js';
|
|
6
|
+
import { imageSize } from '../capture/capture.js';
|
|
7
|
+
import { LAYOUT_IDS, STORE_ART_LAYOUTS } from './layouts.js';
|
|
8
|
+
import { STYLE_IDS } from './styles.js';
|
|
9
|
+
/**
|
|
10
|
+
* Pazarlama görseli destesi (ADR 0004 §Ek): araçtan gelen kareler × stiller birer KART olur; galeri editörü kartın
|
|
11
|
+
* spec'ini değiştirip yeniden çizer. Ham kareler galeri dizinine `raw-NN.<uzantı>` adıyla kopyalanır — sunucu
|
|
12
|
+
* yalnız bu adları sunar, çizici de kopyayı okur. Spec'in sınırları aracın girdi sınırlarıyla aynıdır (araç bu
|
|
13
|
+
* sabitleri kullanır); editörden gelen spec sunucuda bu şemayla yeniden doğrulanır.
|
|
14
|
+
*/
|
|
15
|
+
export const TITLE_MAX = 80;
|
|
16
|
+
export const SUBTITLE_MAX = 140;
|
|
17
|
+
export const EYEBROW_MAX = 30;
|
|
18
|
+
export const MAX_STYLES = 4;
|
|
19
|
+
export const MAX_CARDS = 40;
|
|
20
|
+
const defaultStyles = ['clean'];
|
|
21
|
+
const aspectTolerance = 0.01;
|
|
22
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
23
|
+
/** `deck.json`'a ve istemciye tanımsız alan gitmez: boş üst etiket "alan yok" demektir */
|
|
24
|
+
const withoutUndefined = (value) => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined));
|
|
25
|
+
/** Boş metin alanı siler: editörde üst etiketi ya da alt başlığı temizlemek alanı kaldırmaktır */
|
|
26
|
+
const optionalText = (max) => z.string().trim().max(max).optional().transform(value => value ? value : undefined);
|
|
27
|
+
const hexColor = z.string().regex(COLOR_PATTERN);
|
|
28
|
+
const cardSpecSchema = z.object({
|
|
29
|
+
raw: z.number().int().min(0).optional(),
|
|
30
|
+
eyebrow: optionalText(EYEBROW_MAX),
|
|
31
|
+
title: z.string().trim().min(1).max(TITLE_MAX),
|
|
32
|
+
subtitle: optionalText(SUBTITLE_MAX),
|
|
33
|
+
style: z.enum(STYLE_IDS),
|
|
34
|
+
layout: z.enum(LAYOUT_IDS),
|
|
35
|
+
captionPosition: z.enum(['top', 'bottom']),
|
|
36
|
+
accent: hexColor.optional(),
|
|
37
|
+
text: hexColor.optional(),
|
|
38
|
+
frame: z.boolean()
|
|
39
|
+
}).strict();
|
|
40
|
+
/** Editörden gelen spec: şema + destenin kendi kuralları (ham kare destede olmalı; cihaz gösteren yerleşim kare ister) */
|
|
41
|
+
export function parseCardSpec(deck, value) {
|
|
42
|
+
const parsed = cardSpecSchema.safeParse(value);
|
|
43
|
+
if (!parsed.success)
|
|
44
|
+
return { error: parsed.error.issues.map(issue => `${issue.path.join('.') || 'spec'}: ${issue.message}`).join('; ') };
|
|
45
|
+
const spec = withoutUndefined(parsed.data);
|
|
46
|
+
if (spec.raw !== undefined && spec.raw >= deck.raws.length)
|
|
47
|
+
return { error: `raw: destede ${deck.raws.length} ham kare var, ${spec.raw} yok` };
|
|
48
|
+
if (STORE_ART_LAYOUTS[spec.layout].needsScreen && spec.raw === undefined)
|
|
49
|
+
return { error: `layout: '${spec.layout}' ham kare ister — raw verin ya da 'text' seçin` };
|
|
50
|
+
return { spec };
|
|
51
|
+
}
|
|
52
|
+
async function readSlideSource(source, index) {
|
|
53
|
+
const absolute = path.resolve(source);
|
|
54
|
+
let bytes;
|
|
55
|
+
try {
|
|
56
|
+
bytes = await readFile(absolute);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path okunamadı — ${absolute} (${error instanceof Error ? error.message : String(error)}). Yol gateway sürecinin çalıştığı makineye göre çözülür; page_screenshot outPath'ini verin. Hiçbir kare çizilmedi.`, 400);
|
|
60
|
+
}
|
|
61
|
+
const size = imageSize(bytes);
|
|
62
|
+
if (size === undefined) {
|
|
63
|
+
throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path PNG ya da JPEG değil — ${absolute}. Ham kareyi page_screenshot { format: 'jpeg', outPath } ile üretin. Hiçbir kare çizilmedi.`, 400);
|
|
64
|
+
}
|
|
65
|
+
return { absolute, size, png: bytes[0] === 0x89 };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Araç girdisinden desteyi kurar: ham kareleri doğrular ve galeri dizinine kopyalar, kartları kare sırasıyla
|
|
69
|
+
* (her karenin stilleri yan yana) dizer — deste sırası yükleme sırasıdır.
|
|
70
|
+
*/
|
|
71
|
+
export async function buildDeck(input, directory) {
|
|
72
|
+
const styles = input.styles ?? defaultStyles;
|
|
73
|
+
if (new Set(styles).size !== styles.length) {
|
|
74
|
+
throw new PlatformApiError(`mobile_asset_preview: styles tekrar ediyor (${styles.join(', ')}) — her stil bir kez verilir. Hiçbir kare çizilmedi.`, 400);
|
|
75
|
+
}
|
|
76
|
+
const capture = SCREENSHOT_CAPTURE_DEVICES[input.deviceClass];
|
|
77
|
+
const sources = await Promise.all(input.slides.map(async (slide, index) => {
|
|
78
|
+
const layout = slide.layout ?? 'classic';
|
|
79
|
+
if (!STORE_ART_LAYOUTS[layout].needsScreen)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (slide.path === undefined) {
|
|
82
|
+
throw new PlatformApiError(`mobile_asset_preview: slides[${index}].path gerekli — '${layout}' yerleşimi ham kareyi cihazda gösterir; yalnız 'text' yerleşimi kare istemez. Hiçbir kare çizilmedi.`, 400);
|
|
83
|
+
}
|
|
84
|
+
return readSlideSource(slide.path, index);
|
|
85
|
+
}));
|
|
86
|
+
// Oranı farklı kare ekrana `object-fit: cover` ile kırpılarak oturur; çizim durmaz ama ajan bilmeli.
|
|
87
|
+
const warnings = sources.flatMap((source, index) => source && Math.abs(source.size.height / source.size.width - capture.height / capture.width) > aspectTolerance
|
|
88
|
+
? [`slides[${index}]: ham kare ${source.size.width}×${source.size.height}, ${input.deviceClass} ekranı ${capture.width}×${capture.height} oranında — kare ekrana kırpılarak oturdu. page_screenshot'ı device { width: ${capture.width}, height: ${capture.height}, scale: ${capture.scale} } ile çekin.`]
|
|
89
|
+
: []);
|
|
90
|
+
const raws = [];
|
|
91
|
+
const rawOfSlide = [];
|
|
92
|
+
await mkdir(directory, { recursive: true });
|
|
93
|
+
for (const [index, source] of sources.entries()) {
|
|
94
|
+
if (source === undefined) {
|
|
95
|
+
rawOfSlide.push(undefined);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const name = `raw-${pad(index + 1)}.${source.png ? 'png' : 'jpg'}`;
|
|
99
|
+
const copy = path.join(directory, name);
|
|
100
|
+
await copyFile(source.absolute, copy);
|
|
101
|
+
raws.push({ name, path: copy, ...source.size });
|
|
102
|
+
rawOfSlide.push(raws.length - 1);
|
|
103
|
+
}
|
|
104
|
+
const cards = input.slides.flatMap((slide, index) => styles.map((style) => ({
|
|
105
|
+
name: `${style}-${pad(index + 1)}`,
|
|
106
|
+
slide: index,
|
|
107
|
+
version: 1,
|
|
108
|
+
// Araç girdisi zod'dan geçmiştir; yalnız tanımsız alanlar atılır.
|
|
109
|
+
spec: withoutUndefined({
|
|
110
|
+
raw: rawOfSlide[index],
|
|
111
|
+
eyebrow: slide.eyebrow,
|
|
112
|
+
title: slide.title,
|
|
113
|
+
subtitle: slide.subtitle,
|
|
114
|
+
style,
|
|
115
|
+
layout: slide.layout ?? 'classic',
|
|
116
|
+
captionPosition: input.captionPosition ?? 'top',
|
|
117
|
+
accent: input.palette?.primary,
|
|
118
|
+
text: input.palette?.text,
|
|
119
|
+
frame: input.frame ?? true
|
|
120
|
+
})
|
|
121
|
+
})));
|
|
122
|
+
return { deck: { deviceClass: input.deviceClass, locale: input.locale, slot: `screenshot/${input.locale}/${input.deviceClass}`, raws, cards }, warnings };
|
|
123
|
+
}
|
|
124
|
+
/** Kartın kopyası: aynı spec, kare sırası ve dosya; ad `<ad>-2`, `<ad>-3` … */
|
|
125
|
+
export function duplicateCard(deck, original) {
|
|
126
|
+
const names = new Set(deck.cards.map(card => card.name));
|
|
127
|
+
let suffix = 2;
|
|
128
|
+
while (names.has(`${original.name}-${suffix}`))
|
|
129
|
+
suffix += 1;
|
|
130
|
+
return { name: `${original.name}-${suffix}`, slide: original.slide, version: 1, spec: { ...original.spec } };
|
|
131
|
+
}
|
|
132
|
+
export const cardFileName = (card) => `${card.name}.jpg`;
|
|
133
|
+
export const saveDeck = (directory, deck) => writeFile(path.join(directory, 'deck.json'), JSON.stringify(deck, null, 2));
|