Uzupełnienie CV · dla rekruterów i hiring managerów

AI Transformation & Digital Adoption Lead.

Daniel Czaja · 22 lata pracy z C-level i właścicielami firm B2B premium (m.in. Moët & Chandon, Belvedere, PayEye, Sunset Hospitality Dubai), 3 lata projektów AI od audytu, przez pilotaż, do realnej adopcji. Pomagam firmom 100–500 osób przejść od chaosu wokół AI do uporządkowanego portfela inicjatyw, governance i mierzalnego ROI.

LokalizacjaWrocław · zdalnie / hybrydowo
Telefon+48 573 997 306
22 lata B2B premium · klienci m.in.
Moët & Chandon Belvedere PayEye Sunset Hospitality Dubai + 50 marek B2B premium
01 / Profil
Kim jestem zawodowo

Co wnoszę do organizacji

Nie pozycjonuję się jako AI developer ani prompt engineer. Moja rola to diagnoza procesu, ocena danych i feasibility, wybór właściwych use case'ów, prowadzenie pilotażu, ograniczenie ryzyka i doprowadzenie do realnego użycia AI przez ludzi.

01Strategia

Porządkuję chaos wokół AI

Mapuję inicjatywy, procesy, dane, właścicieli i ryzyka. Pomagam firmie zdecydować, które pomysły mają sens biznesowy, a które należy odrzucić — zanim spalą budżet i zaufanie zarządu.

02Decyzja

Zamieniam pomysły w pilotaże

Projektuję roadmapę 60–90 dni, definiuję KPI, właściciela procesu, zakres MVP, model wdrożenia, koszty, ryzyka i sposób mierzenia efektów — wszystko w formie czytelnej dla CFO i zarządu.

03Ramy

Buduję AI Governance

Wdrażam zasady użycia AI, ograniczam Shadow AI, porządkuję dostęp do narzędzi, logi, human-in-the-loop, polityki AI oraz dokumentację pod RODO i AI Act readiness.

04Adopcja

Doprowadzam do adopcji przez ludzi

Prowadzę szkolenia, komunikację zmiany, pracę z oporem zespołów, monitoring użycia, feedback loops i optymalizację po pilotażu. AI ma działać w codziennej pracy, nie tylko na prezentacji.

Łączę 22+ lata pracy z właścicielami, zarządami i zespołami B2B premium z 3 latami projektów AI od audytu, przez pilotaż, do adopcji. 100% projektów AI prowadzonych z udziałem C-level lub właściciela firmy.
02 / Business Impact
Mierzone efekty

Liczby, które pokazują efekt

Selekcja kluczowych wskaźników z czterech projektów AI prowadzonych w 2024–2026. Wspólne jest to, że decyzje były podejmowane na liczbach, a nie na entuzjazmie.

Selekcja TELOS
73

Inicjatywy AI po selekcji TELOS · 4 pomysły odrzucone z uzasadnieniem biznesowym, technologicznym, regulacyjnym i time-to-value.

Audyt portfela
143

Projekty AI wybrane do pilotażu w firmie 200-osobowej · pierwszy pilot ruszył w 6 tygodni od decyzji zarządu.

Shadow AI · Adopcja
0/85%

0 incydentów wycieku do publicznych modeli po 60 dniach · 85% aktywnych użytkowników zamkniętego środowiska AI.

Obsługa zapytań
~50%

Krótszy czas obsługi zapytań klientów po wdrożeniu trzech równoległych pilotaży AI w usłudze B2B premium.

Koszt subskrypcji AI
~30%

Niższy koszt subskrypcji AI po audycie używanych narzędzi, konsolidacji licencji i wyłączeniu Shadow AI.

Praca z decydentami
50+ klientów

Klienci premium w portfelu, typowa retencja 5–8 lat · 100% projektów z udziałem C-level lub właściciela firmy.

Uwaga: Dane projektów AI są zanonimizowane ze względu na NDA. Liczby pokazują skalę efektów i sposób pracy, bez ujawniania danych klientów. Każda metryka jest dostępna do walidacji w rozmowie referencyjnej po podpisaniu NDA z drugiej strony.

03 / Proof of Work
NDA · Anonimizowane

Zanonimizowane case studies

Cztery wybrane projekty AI z lat 2024–2026. Każdy z innym scope'em, branżą i punktem startu — wspólna jest struktura pracy: problem → działanie → mierzalny wynik.

Case 01 4 tyg · 2024

B2B SaaS · priorytetyzacja inicjatyw AI

60 osób · 1 lokalizacja
Sytuacja

CEO chciał „wdrożyć AI", ale firma miała 7 luźnych pomysłów na pilotaże bez wspólnych kryteriów decyzji i wyliczonego ROI.

Działanie

Inwentaryzacja, TELOS, analiza ROI 24 miesięcy, feasibility technologiczne, ocena ryzyk danych, regulacji i time-to-value.

Wynik

3 projekty zatwierdzone, 4 odrzucone z jasnym uzasadnieniem. Szacowana oszczędność: 6 miesięcy chaosu i 200–300 tys. zł.

Case 02 60 dni · 2024

Organizacja regulowana · likwidacja Shadow AI

80 osób · 5 lokalizacji
Sytuacja

Niekontrolowane użycie publicznych modeli AI przez pracowników; ryzyko regulacyjne i wycieku danych wrażliwych. Zarząd nie znał skali użycia.

Działanie

Audyt (ankieta + logi), polityka AI, zamknięte środowisko RAG / Copilot Enterprise, szkolenia, audytowalne logi.

Wynik po 60 dniach

0 incydentów wycieku, 85% aktywnych użytkowników środowiska firmowego, dokumentacja RODO + AI Act readiness.

Case 03 14 dni · 2025

Producent FMCG · audyt portfela AI dla CFO

200 osób · produkcja + sieć
Sytuacja

14 propozycji AI z różnych działów i vendorów. CFO potrzebował jasnej rekomendacji inwestycyjnej opartej na liczbach, nie demo.

Działanie

Ekspresowy 14-dniowy audyt: TELOS, business case, ROI, TCO (wdrożenie + utrzymanie), czas odzyskany, feasibility i mapa danych.

Wynik

Rekomendacja: 3 inicjatywy do wdrożenia, 5 przesuniętych, 6 trwale odrzuconych. Pierwszy pilot ruszył w 6 tygodni.

Case 04 ongoing

Firma usługowa B2B premium · Fractional / Interim AI Lead

120 osób · 6 dni / miesiąc
Sytuacja

Zarząd chciał wdrożeń AI bez stałego CTO/CIO i bez chęci zatrudniania AI Leada na pełen etat. Wewnątrz brakowało osoby z mandatem.

Działanie

6 dni miesięcznie: raporty zarządcze, 3 pilotaże równolegle (CRM AI, automatyzacja ofertowania, agent obsługi), vendor management, polityka AI, szkolenia.

Wynik

3 pilotaże w produkcji, ~50% krótszy czas obsługi, ~30% niższy koszt subskrypcji AI, kontrola nad roadmapą i vendorami.

04 / Doświadczenie zawodowe
Praca z C-level od 2000 roku

Doświadczenie zawodowe

22+ lata prowadzenia projektów dla zarządów B2B premium, plus 3 lata projektów AI od 2023. Każde stanowisko z bezpośrednią odpowiedzialnością za wynik.

01.2023
— obecnie3 lata

AI Transformation & Automation Consultant / Interim AI Lead

Consulting B2B·Wrocław / zdalnie
  • Audyty procesów, discovery AS-IS / TO-BE i wybór use case'ów AI dla firm 100–500 osób.
  • Budowa portfela inicjatyw AI: priorytetyzacja, feasibility, business case, KPI, ROI, TCO i mapa zależności danych.
  • Projektowanie i prowadzenie pilotaży AI: chatboty, voiceboty, RAG, agenci AI, automatyzacje CRM / ERP / API.
  • Wdrażanie AI governance: polityki użycia AI, ograniczenie Shadow AI, bezpieczeństwo, dokumentacja i raportowanie dla zarządu.
  • Koordynacja zespołów interdyscyplinarnych: zarząd, CFO, marketing, sprzedaż, obsługa, IT, BI i operacje.
  • Szkolenia, adopcja użytkowników, monitoring KPI, raportowanie miesięczne i wsparcie powdrożeniowe.
03.2024
— obecnierównolegle

Konsultant ds. AI (part-time)

AI w środowisku regulowanym
  • Wsparcie wdrażania rozwiązań AI w środowisku regulowanym: bezpieczeństwo, dane, adopcja użytkowników.
  • Przekładanie możliwości AI na realne procesy operacyjne, zasady użycia i scenariusze wdrożeniowe.
01.2000
— 12.202222 lata

Założyciel i konsultant strategiczny

Czaja Studio·Brand & Strategy Consulting

22 lata prowadzenia firmy doradczo-kreatywnej dla klientów B2B premium. Klienci m.in. Moët & Chandon, Belvedere, PayEye, Sunset Hospitality Dubai. To doświadczenie przenoszę dziś do projektów AI: praca z decydentami, diagnoza problemów, warsztaty strategiczne, odpowiedzialność za wynik, prowadzenie zmiany.

  • Budowa firmy od zera do portfela 50+ klientów premium; średnia retencja 5–8 lat.
  • Bezpośrednia współpraca z właścicielami, zarządami i C-level przy strategii, procesach, komunikacji i sprzedaży.
  • Prowadzenie warsztatów, prezentacji dla zarządów, negocjacji kontraktów i projektów end-to-end.
  • Zarządzanie zespołami projektowymi, budżetami, harmonogramami, jakością delivery i rentownością kontraktów.
1998
— 2015równolegle

Wcześniejsze doświadczenie równoległe

Good Event·CompoSys IT
  • Good Event — współwłaściciel / dyrektor operacyjny: zarządzanie agencją eventową i realizacja dużych projektów B2B.
  • CompoSys IT — manager ekspansji europejskiej: rozwój struktur sprzedaży i bezpośrednia współpraca z klientami zagranicznymi.
05 / Metodologia
Jak pracuję · 60–90 dni

Cztery fazy od decyzji do skalowania

Standardowy cykl wdrożeniowy. Każda faza ma jasne deliverables, KPI i decyzję go / no-go przed wejściem w następną.

01 Tydzień 1–2

Diagnoza

AS-IS / TO-BE, procesy, dane, interesariusze, ryzyka, właściciele procesów i punkty utraty czasu lub jakości.

02 Tydzień 3–4

Priorytety

Scoring inicjatyw AI, TELOS, feasibility, business case, KPI, ROI, TCO i rekomendacja go / no-go dla zarządu.

03 Tydzień 5–10

Pilot

Zakres MVP, owner, vendorzy, integracje, human-in-the-loop, testy, monitoring i raport efektów po 30–60 dniach.

04 Tydzień 11–13

Adopcja & skalowanie

Szkolenia, feedback loops, praca z oporem, optymalizacja procesu i decyzja: skalować, poprawić albo zamknąć.

06 / Proof of Work · Tools
Własne narzędzia doradcze

Narzędzia, które wprowadzam na warsztat

Dwa autorskie narzędzia używane podczas pracy z zarządami: jedno do priorytetyzacji procesów, drugie do myślenia systemowego o AI w dużych organizacjach.

Narzędzie · warsztat z zarządem

AI Automation Score

Interaktywne narzędzie doradcze do oceny potencjału automatyzacji procesów. Pomaga podjąć konkretną decyzję: co automatyzować, co tylko wspierać AI, a czego nie ruszać bez człowieka.

Co ocenia
  • powtarzalność procesu, regułowość i zmienność
  • dostępność i jakość danych
  • łatwość wdrożenia oraz ryzyko prawne / koszt błędu
  • wartość ludzkiego kontaktu i potrzebę reskillingu zespołu
Co generuje
  • Automation Score (0–100) i rekomendację: automatyzuj / AI wspiera / człowiek decyduje / reskilling
  • szacunek oszczędności czasu i kosztu, typ wdrożenia, ryzyka
  • pierwszy pilot, quick wins, heatmapę zadań działu, symulator zmiany pracy
Automation Score · przykład
Ofertowanie B2B
84
Onboarding klienta
72
Obsługa zapytań L1
68
Triage reklamacji
54
Negocjacje kontraktu
28
Rozmowy dyscyplinarne
11

Score > 70 → automatyzacja · 40–70 → AI wspiera · < 40 → człowiek decyduje.

Wartość biznesowa

Pozwala przejść od intuicyjnej rozmowy o AI do uporządkowanej decyzji inwestycyjnej i pierwszego pilotażu — w jednym warsztacie z zarządem.

Case systemowy · ochrona zdrowia

AI Fix Pack

Koncepcyjny case systemowy: AI w ochronie zdrowia. Pokazuje sposób myślenia o AI na poziomie całego systemu — kolejki, no-show, przeciążenie rejestracji i niewykorzystane sloty w publicznej ochronie zdrowia.

Scope · 9 elementów systemu
01
Voicebot 24/7
Zapisy poza godzinami pracy rejestracji.
02
Predykcja no-show
Model oceniający prawdopodobieństwo nieprzyjścia.
03
Auto-uzupełnianie slotów
Powiadomienia listy oczekujących w czasie rzeczywistym.
04
Triage chatbot
Wstępna kwalifikacja objawów i ścieżki.
05
Tłumaczenie dokumentów
Skierowania i wypisy w prostym języku.
06
Mapa kolejek
Czas oczekiwania w realnym czasie, transparentny.
07
Asystent lekarza
Wsparcie w dokumentacji i wyszukiwaniu danych.
08
Przypomnienia
Dostosowane do profilu i historii pacjenta.
09
Pulpit dyrektora
KPI operacyjne i kliniczne w jednym widoku.
Kluczowy punkt

To nie jest „pomysł na chatbota". To mapa procesów, danych, odpowiedzialności, KPI, ryzyk i warunków wdrożenia — gotowa do walidacji z dyrekcją, IT, działem prawnym i NFZ.

Wartość biznesowa

Pokazuje zdolność do myślenia systemowego: proces, pacjent, personel, technologia, governance, KPI i compliance — w jednej ramie. Kompetencja przenoszalna na każdy duży system regulowany: bankowość, energetyka, retail sieciowy, edukacja.

07 / Stack & kompetencje
Narzędzia, frameworki, standardy

Z czego korzystam

Pełny stack technologiczny i standardy compliance, w których pracuję. Bez „pisania własnych modeli" — moja rola to architektura wdrożenia i właściwy dobór gotowych narzędzi.

Strategia & metody

AI strategy, ROI, governance

TELOS AS-IS / TO-BE Impact–Feasibility–Risk Business case ROI / TCO Roadmapa 60–90 dni Change management Human-in-the-loop Vendor management
AI / LLM

Modele, RAG, agenci

ChatGPT Claude Gemini Perplexity Microsoft Copilot Enterprise RAG Chatboty / Voiceboty Agenci AI Prompt engineering
Automatyzacja & integracje

Low-code, API, workflow

Make Zapier n8n Airtable Google Apps Script REST API Webhooki Workflow automation
CRM / ERP / Dane

Systemy biznesowe

HubSpot Pipedrive ActiveCampaign Zoho CRM Comarch ERP enova365 (podstawy) Excel Google Sheets Looker Studio
Standardy & compliance

Ramy regulacyjne, w których pracuję

EU AI Act readiness RODO / GDPR NIST AI RMF ISO/IEC 42001 ISO/IEC 23894 OWASP LLM Top 10 MITRE ATLAS Data governance Audytowalne logi
08 / Wiarygodność
Certyfikacja & wyróżnienie

Certyfikacja i wyróżnienie

Formalne potwierdzenia kompetencji z programu Konsultant Transformacji AI oraz wyróżnienie społeczności kursu. Studia magisterskie — Uniwersytet Wrocławski.

Wyróżnienie · 2026

AI Community Leader

Elephant AI PRO · Konsultant Transformacji AI

Wyróżnienie przyznane za aktywność w społeczności kursu Konsultant Transformacji AI oraz udział w zadaniach praktycznych.

2026

AI Transformation Consultant

Elephant AI PRO

Discovery, use case prioritization, data governance, AI governance, roadmap, AI UX, wdrożenia i wsparcie powdrożeniowe.

25.04.2024 · nr 1932129

AI for Managers (incl. GenAI & ChatGPT)

Elephant AI · instruktor: Maria Parysz

7-tygodniowy program · 35h+ warsztatów i wykładów · projekt capstone · egzamin pisemny.

mgr

Uniwersytet Wrocławski

Studia magisterskie
09 / Dopasowanie
Dla rekruterów i HR

Obszary dopasowania

Stanowiska i obszary kompetencji, w których szukam dialogu z firmami 100–500 osób — etat, kontrakt B2B lub model Fractional / Interim.

AI Transformation Manager AI Adoption & Transformation Lead Interim AI Lead Fractional AI Lead Manager ds. AI Specjalista ds. Sztucznej Inteligencji Transformation Manager Digital Transformation Lead Dyrektor Transformacji Cyfrowej Process Automation Lead Change Management Lead AI Governance Lead AI Portfolio Lead ROI-driven Pilots AI Consulting (B2B) Head of AI Adoption
Dla rekruterów i hiring managerów

Otwarte na rozmowy o stałej roli lub kontrakcie.

Jeśli rekrutujesz na stanowisko związane z transformacją AI, adopcją, governance albo procesami — chętnie porozmawiam. Pierwsze 30 minut bez zobowiązań, telefonicznie lub przez kalendarz. Odpowiadam tego samego dnia roboczego.

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