release/windows-microsoft-store
mjs 224 lines 11.1 KB
Raw
1 // Microsoft Store screenshot generator — Siti AI.
2 // Renders editorial frames using Siti AI's REAL chat UI markup and its own
3 // stylesheet (src/App.css), framed with a caption in the app's minimal,
4 // monochrome Scandinavian style, at the Windows Store desktop size
5 // (1920x1080 landscape) via headless chromium.
6 //
7 // node assets/aso/microsoftstore/screenshots/generate.mjs (run from repo root)
8 //
9 // Output: assets/aso/microsoftstore/screenshots/windows/NN-<slug>.png (1920x1080)
10 // Playwright is borrowed from the sibling SplitFire repo's shared cache
11 // (playwright is repo-agnostic); Siti AI has none of its own.
12 //
13 // Honest feature set only (see assets/aso/microsoftstore/en-US.md): a private,
14 // on-device AI assistant — ask anything, draft, summarize, translate; no
15 // cloud, no account, works offline after a one-time model download.
16
17 import { createRequire } from "node:module";
18 import { existsSync, mkdirSync, readFileSync } from "node:fs";
19 import { dirname, resolve } from "node:path";
20 import { fileURLToPath } from "node:url";
21
22 const HERE = dirname(fileURLToPath(import.meta.url));
23 const REPO = resolve(HERE, "../../../..");
24 const require = createRequire(import.meta.url);
25 const pw = [
26 process.env.HOME + "/Repositories/splitfire/.ds-sync/node_modules/playwright",
27 resolve(REPO, ".ds-sync/node_modules/playwright"),
28 ].find((p) => existsSync(p));
29 if (!pw) throw new Error("playwright not found (borrow from splitfire/.ds-sync)");
30 const { chromium } = require(pw);
31
32 // The app's real stylesheet is the source of truth for the chat UI look.
33 const APP_CSS = readFileSync(resolve(REPO, "src/App.css"), "utf8");
34
35 const PLATFORMS = { windows: { w: 1920, h: 1080, zoom: 1.15, cap: 62, sub: 25 } };
36
37 // ── Real Siti AI markup (classes from src/App.tsx + src/App.css) ────────────
38 const bubble = (role, text) =>
39 `<div class="message-row ${role}"><div class="bubble">${text}</div></div>`;
40
41 const settingsIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
42 const sendIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4 20-7z"/></svg>`;
43
44 // A bounded chat window holding the real .app markup.
45 const chatWindow = (subtitle, rows, placeholder = "Message Siti…") => `
46 <div class="siti-window">
47 <div class="app">
48 <header class="header">
49 <div class="header-title">
50 <h1>Siti AI</h1>
51 <span class="header-subtitle">${subtitle}</span>
52 </div>
53 <div class="header-actions">
54 <span class="status-dot"></span>
55 <button class="icon-button">${settingsIcon}</button>
56 </div>
57 </header>
58 <div class="messages">${rows}</div>
59 <div class="input-area">
60 <div class="input-form">
61 <input class="input-field" value="" placeholder="${placeholder}" readonly />
62 <button class="send-button">${sendIcon}</button>
63 </div>
64 <div class="input-hint">Private &middot; runs on your PC</div>
65 </div>
66 </div>
67 </div>`;
68
69 const settingsWindow = () => `
70 <div class="siti-window">
71 <div class="app">
72 <div class="settings-header">
73 <h1>Settings</h1>
74 <button class="settings-done">Done</button>
75 </div>
76 <div class="settings-content">
77 <div class="settings-section">
78 <div class="section-label">Model</div>
79 <div class="settings-card">
80 <div class="settings-row">
81 <span class="row-label">On-device model</span>
82 <span class="row-value">Gemma 2 &middot; 2B</span>
83 </div>
84 <div class="model-detail">
85 <div class="model-org">Google DeepMind</div>
86 <div class="model-desc">Runs entirely on your PC. Downloaded once, then works offline.</div>
87 <div class="model-status ready"><span class="model-status-dot"></span>Ready</div>
88 </div>
89 </div>
90 <div class="settings-note">No account, no cloud, no tracking. Your conversations never leave this device.</div>
91 </div>
92 </div>
93 </div>
94 </div>`;
95
96 // ── Frames (honest: private, on-device, capable, offline) ───────────────────
97 const FRAMES = [
98 {
99 slug: "ask-siti-anything",
100 caption: "Ask Siti anything",
101 sub: "A capable assistant for questions, drafts, and ideas",
102 stage: () =>
103 chatWindow("Gemma 2 &middot; 2B &middot; private", [
104 bubble("user", "Explain vector embeddings like I'm five."),
105 bubble(
106 "assistant",
107 "Imagine every word gets a spot on a huge map. Words that mean similar things sit close together — so the computer can tell that “cat” and “kitten” are neighbours.",
108 ),
109 bubble("user", "Nice. Now give me three names for a coffee app."),
110 bubble("assistant", "Sure — Dailygrind, Pour, and Warmup. Want a few more playful ones?"),
111 ].join("")),
112 },
113 {
114 slug: "private-on-device",
115 caption: "Private by design",
116 sub: "Every conversation stays on your own machine",
117 stage: () =>
118 chatWindow("Gemma 2 &middot; 2B &middot; private", [
119 bubble("user", "Is any of this sent to the cloud?"),
120 bubble(
121 "assistant",
122 "No. Everything runs on your PC. Your messages never leave this device — no account, no servers, no tracking.",
123 ),
124 bubble("user", "So I can paste something sensitive?"),
125 bubble("assistant", "Yes. Nothing you type here is uploaded. It is yours alone."),
126 ].join("")),
127 },
128 {
129 slug: "draft-and-summarize",
130 caption: "Draft, summarise, translate",
131 sub: "Turn a rough note into something finished",
132 stage: () =>
133 chatWindow("Gemma 2 &middot; 2B &middot; private", [
134 bubble("user", "Summarise this in one line: the team agreed to ship the beta on Friday, Alex owns QA, Sam writes the release notes."),
135 bubble("assistant", "Beta ships Friday — Alex on QA, Sam on release notes."),
136 bubble("user", "Now say it in Indonesian."),
137 bubble("assistant", "Beta rilis Jumat — Alex urus QA, Sam tulis catatan rilis."),
138 ].join("")),
139 },
140 {
141 slug: "works-offline",
142 caption: "Works offline, anywhere",
143 sub: "After a one-time download, no connection needed",
144 stage: () =>
145 chatWindow("Gemma 2 &middot; 2B &middot; offline", [
146 bubble("user", "Do I need internet for this?"),
147 bubble(
148 "assistant",
149 "Nope. Once the model is downloaded, Siti works fully offline — on a plane, on a commute, or off the grid, it still answers.",
150 ),
151 bubble("user", "Perfect for the flight tomorrow."),
152 bubble("assistant", "Exactly. Airplane mode, no problem."),
153 ].join("")),
154 },
155 {
156 slug: "on-device-model",
157 caption: "The model lives on your PC",
158 sub: "One download, then it is yours — no sign-in, no cloud",
159 stage: () => settingsWindow(),
160 },
161 ];
162
163 // ── Page ────────────────────────────────────────────────────────────────────
164 const WIN_W = 560;
165 const WIN_H = 672;
166
167 const page = (p, frame, idx) => `<!doctype html><html><head><meta charset="utf-8"><style>
168 ${APP_CSS}
169 /* Force the light theme regardless of the headless colour-scheme. */
170 :root{--bg:#fafafa;--surface:#ffffff;--border:#e8e8e8;--text-primary:#1a1a1a;--text-secondary:#8a8a8a;--accent:#2d2d2d;--accent-hover:#1a1a1a;--user-bubble:#1a1a1a;--user-bubble-text:#fff;--ai-bubble:#f0f0f0;--ai-bubble-text:#1a1a1a;--input-bg:#f5f5f5}
171 html,body{margin:0;width:${p.w}px;height:${p.h}px;overflow:hidden}
172 .shot{width:${p.w}px;height:${p.h}px;display:flex;flex-direction:column;
173 font-family:"Inter",-apple-system,"Segoe UI",Roboto,sans-serif;color:#1a1a1a;
174 background:
175 radial-gradient(1100px 640px at 82% 14%, rgba(0,0,0,0.035), transparent 55%),
176 linear-gradient(165deg,#f7f7f5 0%,#eeeeeb 100%);
177 background-color:#f2f2ef}
178 .cap-wrap{padding:${Math.round(p.h * 0.055)}px ${Math.round(p.w * 0.07)}px 0}
179 .cap-index{display:inline-block;background:#1a1a1a;color:#fafafa;padding:5px 11px;border-radius:6px;
180 font-size:${Math.round(p.sub * 0.72)}px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase}
181 .cap{font-size:${p.cap}px;line-height:1.04;font-weight:700;letter-spacing:-0.03em;margin-top:20px;color:#141414}
182 .sub{font-size:${p.sub}px;margin-top:15px;color:#6a6a6a}
183 .stage{flex:1;display:flex;align-items:center;justify-content:center;padding:${Math.round(p.h * 0.035)}px}
184 .stage-inner{zoom:${p.zoom}}
185 /* Bounded desktop window that holds the real .app markup. */
186 .siti-window{width:${WIN_W}px;height:${WIN_H}px;background:var(--bg);border:1px solid var(--border);
187 border-radius:22px;overflow:hidden;box-shadow:0 34px 80px rgba(20,20,20,0.16)}
188 .siti-window .app{height:100%}
189 .siti-window .header,.siti-window .input-area{padding-left:22px;padding-right:22px}
190 .siti-window .header{padding-top:20px;padding-bottom:16px}
191 .siti-window .input-area{padding-top:16px;padding-bottom:18px}
192 .siti-window .settings-header{padding:20px}
193 .siti-window .messages{padding:20px 0}
194 </style></head>
195 <body>
196 <div class="shot">
197 <div class="cap-wrap">
198 <span class="cap-index">Siti AI &mdash; 0${idx + 1}</span>
199 <div class="cap">${frame.caption}</div>
200 <div class="sub">${frame.sub}</div>
201 </div>
202 <div class="stage"><div class="stage-inner">${frame.stage(p)}</div></div>
203 </div>
204 </body></html>`;
205
206 // ── Render ──────────────────────────────────────────────────────────────────
207 const browser = await chromium.launch();
208 try {
209 for (const [key, p] of Object.entries(PLATFORMS)) {
210 const dir = resolve(HERE, key);
211 mkdirSync(dir, { recursive: true });
212 const ctx = await browser.newContext({ viewport: { width: p.w, height: p.h }, deviceScaleFactor: 1 });
213 const pg = await ctx.newPage();
214 for (const [i, frame] of FRAMES.entries()) {
215 await pg.setContent(page(p, frame, i), { waitUntil: "load" });
216 const file = resolve(dir, `0${i + 1}-${frame.slug}.png`);
217 await pg.screenshot({ path: file, clip: { x: 0, y: 0, width: p.w, height: p.h } });
218 console.log("wrote", file.replace(REPO + "/", ""));
219 }
220 await ctx.close();
221 }
222 } finally {
223 await browser.close();
224 }