@nuraly/lumenjs 0.1.4 → 0.2.0

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.
Files changed (76) hide show
  1. package/dist/auth/native-auth.d.ts +9 -0
  2. package/dist/auth/native-auth.js +49 -2
  3. package/dist/auth/routes/login.js +24 -1
  4. package/dist/auth/routes/totp.d.ts +22 -0
  5. package/dist/auth/routes/totp.js +232 -0
  6. package/dist/auth/routes.js +14 -0
  7. package/dist/auth/token.js +2 -2
  8. package/dist/build/build-server.d.ts +2 -1
  9. package/dist/build/build-server.js +10 -1
  10. package/dist/build/build.js +13 -4
  11. package/dist/build/scan.d.ts +1 -0
  12. package/dist/build/scan.js +2 -1
  13. package/dist/build/serve.js +131 -11
  14. package/dist/dev-server/config.js +18 -1
  15. package/dist/dev-server/index-html.d.ts +1 -0
  16. package/dist/dev-server/index-html.js +4 -1
  17. package/dist/dev-server/plugins/vite-plugin-routes.js +3 -2
  18. package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +34 -6
  19. package/dist/dev-server/server.js +146 -88
  20. package/dist/dev-server/ssr-render.js +10 -2
  21. package/dist/editor/ai/backend.js +11 -2
  22. package/dist/editor/ai/deepseek-client.d.ts +7 -0
  23. package/dist/editor/ai/deepseek-client.js +113 -0
  24. package/dist/editor/ai/opencode-client.d.ts +1 -1
  25. package/dist/editor/ai/opencode-client.js +21 -47
  26. package/dist/editor/ai-chat-panel.js +27 -1
  27. package/dist/editor/editor-bridge.js +2 -1
  28. package/dist/editor/overlay-hmr.js +2 -1
  29. package/dist/runtime/app-shell.d.ts +1 -1
  30. package/dist/runtime/app-shell.js +1 -0
  31. package/dist/runtime/island.d.ts +16 -0
  32. package/dist/runtime/island.js +80 -0
  33. package/dist/runtime/router-hydration.js +9 -2
  34. package/dist/runtime/router.d.ts +3 -1
  35. package/dist/runtime/router.js +49 -1
  36. package/dist/runtime/webrtc.d.ts +44 -0
  37. package/dist/runtime/webrtc.js +263 -13
  38. package/dist/shared/dom-shims.js +4 -2
  39. package/dist/shared/types.d.ts +1 -0
  40. package/dist/storage/adapters/s3.js +6 -3
  41. package/package.json +33 -7
  42. package/templates/social/api/posts/[id].ts +0 -14
  43. package/templates/social/api/posts.ts +0 -11
  44. package/templates/social/api/profile/[username].ts +0 -10
  45. package/templates/social/api/upload.ts +0 -19
  46. package/templates/social/data/migrations/001_init.sql +0 -78
  47. package/templates/social/data/migrations/002_add_image_url.sql +0 -1
  48. package/templates/social/data/migrations/003_auth.sql +0 -7
  49. package/templates/social/docs/architecture.md +0 -76
  50. package/templates/social/docs/components.md +0 -100
  51. package/templates/social/docs/data.md +0 -89
  52. package/templates/social/docs/pages.md +0 -96
  53. package/templates/social/docs/theming.md +0 -52
  54. package/templates/social/lib/media.ts +0 -130
  55. package/templates/social/lumenjs.auth.ts +0 -21
  56. package/templates/social/lumenjs.config.ts +0 -3
  57. package/templates/social/package.json +0 -5
  58. package/templates/social/pages/_layout.ts +0 -239
  59. package/templates/social/pages/apps/[id].ts +0 -173
  60. package/templates/social/pages/apps/index.ts +0 -116
  61. package/templates/social/pages/auth/login.ts +0 -92
  62. package/templates/social/pages/bookmarks.ts +0 -57
  63. package/templates/social/pages/explore.ts +0 -73
  64. package/templates/social/pages/index.ts +0 -351
  65. package/templates/social/pages/messages.ts +0 -298
  66. package/templates/social/pages/new.ts +0 -77
  67. package/templates/social/pages/notifications.ts +0 -73
  68. package/templates/social/pages/post/[id].ts +0 -124
  69. package/templates/social/pages/profile/[username].ts +0 -100
  70. package/templates/social/pages/settings/accessibility.ts +0 -153
  71. package/templates/social/pages/settings/account.ts +0 -260
  72. package/templates/social/pages/settings/help.ts +0 -141
  73. package/templates/social/pages/settings/language.ts +0 -103
  74. package/templates/social/pages/settings/privacy.ts +0 -183
  75. package/templates/social/pages/settings/security.ts +0 -133
  76. package/templates/social/pages/settings.ts +0 -185
@@ -22,6 +22,8 @@ import { createHealthCheckHandler } from '../shared/health.js';
22
22
  import { createRequestIdMiddleware } from '../shared/request-id.js';
23
23
  import { getRequestId } from '../shared/request-id.js';
24
24
  import { setupGracefulShutdown } from '../shared/graceful-shutdown.js';
25
+ import { setupSocketIO } from '../shared/socket-io-setup.js';
26
+ import crypto from 'crypto';
25
27
  export async function serveProject(options) {
26
28
  const { projectDir } = options;
27
29
  const port = options.port || 3000;
@@ -51,7 +53,9 @@ export async function serveProject(options) {
51
53
  logger.fatal('No index.html found in build output.');
52
54
  process.exit(1);
53
55
  }
54
- const indexHtmlShell = fs.readFileSync(indexHtmlPath, 'utf-8');
56
+ let indexHtmlShell = fs.readFileSync(indexHtmlPath, 'utf-8');
57
+ // Substitute env var placeholders (e.g. __UMAMI_WEBSITE_ID__)
58
+ indexHtmlShell = indexHtmlShell.replace(/__([A-Z0-9_]+)__/g, (_, key) => process.env[key] || '');
55
59
  // Load bundled SSR runtime first — its install-global-dom-shim sets up
56
60
  // the proper HTMLElement/window/document shims that @lit-labs/ssr needs.
57
61
  // The lit-shared chunk handles missing HTMLElement via a fallback to its own shim,
@@ -195,14 +199,8 @@ export async function serveProject(options) {
195
199
  if (res.writableEnded)
196
200
  return;
197
201
  }
198
- // -1. Auth routes (login, logout, me, signup, etc.)
199
- if (authConfig && pathname.startsWith('/__nk_auth/')) {
200
- const handled = await handleAuthRoutes(authConfig, req, res, authDb);
201
- if (handled)
202
- return;
203
- }
204
- // 0. Run user middleware chain
205
- if (middlewareModules.size > 0 && !pathname.includes('.') && !pathname.startsWith('/__nk_')) {
202
+ // 0. Run user middleware chain (runs before auth routes so middleware can gate signup etc.)
203
+ if (middlewareModules.size > 0 && !pathname.includes('.') && (!pathname.startsWith('/__nk_') || pathname.startsWith('/__nk_auth/'))) {
206
204
  const matching = getMiddlewareDirsForPathname(pathname, middlewareEntries);
207
205
  const allMw = [];
208
206
  for (const entry of matching) {
@@ -221,12 +219,120 @@ export async function serveProject(options) {
221
219
  return;
222
220
  }
223
221
  }
224
- // 1. API routes
222
+ // 1. Auth routes (login, logout, me, signup, etc. — runs after user middleware so invite gate can intercept)
223
+ if (authConfig && pathname.startsWith('/__nk_auth/')) {
224
+ const handled = await handleAuthRoutes(authConfig, req, res, authDb);
225
+ if (handled)
226
+ return;
227
+ }
228
+ // 2. API routes
225
229
  if (pathname.startsWith('/api/')) {
226
230
  await handleApiRoute(manifest, serverDir, pathname, queryString, method, req, res);
227
231
  return;
228
232
  }
229
- // 2. Static assets — try to serve from client dir
233
+ // 2b. Communication file upload
234
+ if (pathname === '/__nk_comm/upload' && method === 'POST') {
235
+ const userId = req.nkAuth?.user?.sub;
236
+ if (!userId) {
237
+ res.statusCode = 401;
238
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
239
+ return;
240
+ }
241
+ const MAX_UPLOAD_SIZE = 10 * 1024 * 1024;
242
+ const chunks = [];
243
+ let uploadSize = 0;
244
+ let aborted = false;
245
+ req.on('data', (c) => {
246
+ uploadSize += c.length;
247
+ if (uploadSize > MAX_UPLOAD_SIZE) {
248
+ aborted = true;
249
+ req.destroy();
250
+ res.statusCode = 413;
251
+ res.end(JSON.stringify({ error: 'File too large' }));
252
+ return;
253
+ }
254
+ chunks.push(c);
255
+ });
256
+ req.on('end', async () => {
257
+ if (aborted)
258
+ return;
259
+ try {
260
+ const body = Buffer.concat(chunks);
261
+ const id = crypto.randomUUID();
262
+ const fileName = req.headers['x-filename'] || `file-${id}`;
263
+ const mimeType = req.headers['content-type'] || 'application/octet-stream';
264
+ const ext = fileName.includes('.') ? `.${fileName.split('.').pop()}` : '';
265
+ const key = `chat-uploads/${id}${ext}`;
266
+ // Use R2/S3 if configured, otherwise fall back to local disk
267
+ if (process.env.R2_BUCKET && process.env.R2_ENDPOINT) {
268
+ const { S3StorageAdapter } = await import('../storage/adapters/s3.js');
269
+ const s3 = new S3StorageAdapter({
270
+ bucket: process.env.R2_BUCKET,
271
+ region: 'auto',
272
+ accessKeyId: process.env.LUMENJS_S3_ACCESS_KEY || '',
273
+ secretAccessKey: process.env.LUMENJS_S3_SECRET_KEY || '',
274
+ endpoint: process.env.R2_ENDPOINT,
275
+ publicBaseUrl: process.env.R2_PUBLIC_URL,
276
+ });
277
+ const stored = await s3.put(body, { key, mimeType, fileName });
278
+ res.statusCode = 201;
279
+ res.setHeader('Content-Type', 'application/json');
280
+ res.end(JSON.stringify({ id, url: stored.url, size: body.length }));
281
+ }
282
+ else {
283
+ // Local fallback
284
+ const uploadDir = path.join(projectDir, 'data', 'uploads');
285
+ if (!fs.existsSync(uploadDir))
286
+ fs.mkdirSync(uploadDir, { recursive: true });
287
+ fs.writeFileSync(path.join(uploadDir, `${id}.bin`), body);
288
+ fs.writeFileSync(path.join(uploadDir, `${id}.meta.json`), JSON.stringify({ id, filename: fileName, mimetype: mimeType, size: body.length }));
289
+ res.statusCode = 201;
290
+ res.setHeader('Content-Type', 'application/json');
291
+ res.end(JSON.stringify({ id, url: `/__nk_comm/files/${id}`, size: body.length }));
292
+ }
293
+ }
294
+ catch (err) {
295
+ logger.error('Upload failed', { error: err?.message });
296
+ res.statusCode = 500;
297
+ res.end(JSON.stringify({ error: 'Upload failed' }));
298
+ }
299
+ });
300
+ return;
301
+ }
302
+ // Serve locally stored files (fallback when R2 is not configured)
303
+ if (pathname.startsWith('/__nk_comm/files/') && method === 'GET') {
304
+ const fileId = pathname.slice('/__nk_comm/files/'.length);
305
+ if (!/^[a-zA-Z0-9._-]+$/.test(fileId)) {
306
+ res.statusCode = 400;
307
+ res.end('Invalid file ID');
308
+ return;
309
+ }
310
+ const uploadDir = path.join(projectDir, 'data', 'uploads');
311
+ const filePath = path.resolve(uploadDir, `${fileId}.bin`);
312
+ if (!filePath.startsWith(path.resolve(uploadDir))) {
313
+ res.statusCode = 400;
314
+ res.end('Invalid file ID');
315
+ return;
316
+ }
317
+ if (!fs.existsSync(filePath)) {
318
+ res.statusCode = 404;
319
+ res.end('File not found');
320
+ return;
321
+ }
322
+ let contentType = 'application/octet-stream';
323
+ try {
324
+ const meta = JSON.parse(fs.readFileSync(path.resolve(uploadDir, `${fileId}.meta.json`), 'utf-8'));
325
+ contentType = meta.mimetype || contentType;
326
+ }
327
+ catch { }
328
+ const stat = fs.statSync(filePath);
329
+ res.setHeader('Content-Type', contentType);
330
+ res.setHeader('Content-Length', stat.size);
331
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
332
+ fs.createReadStream(filePath).pipe(res);
333
+ return;
334
+ }
335
+ // 3. Static assets — try to serve from client dir
230
336
  if (pathname.includes('.')) {
231
337
  const served = serveStaticFile(clientDir, pathname, req, res);
232
338
  if (served)
@@ -298,6 +404,20 @@ export async function serveProject(options) {
298
404
  logger.request(req, res.statusCode, duration, { requestId: getRequestId(req) });
299
405
  }
300
406
  });
407
+ // Socket.IO setup (attach before listen so it shares the HTTP server)
408
+ const socketRoutes = manifest.routes
409
+ .filter(r => r.hasSocket && r.module)
410
+ .map(r => ({ path: r.path, hasSocket: true, filePath: path.join(serverDir, r.module) }));
411
+ if (socketRoutes.length > 0) {
412
+ setupSocketIO({
413
+ httpServer: server,
414
+ loadModule: (fp) => import(fp),
415
+ routes: socketRoutes,
416
+ projectDir,
417
+ }).catch((err) => {
418
+ logger.warn('Socket.IO setup failed', { error: err?.message });
419
+ });
420
+ }
301
421
  // Graceful shutdown
302
422
  setupGracefulShutdown(server, {
303
423
  onShutdown: async () => {
@@ -12,6 +12,7 @@ export function readProjectConfig(projectDir) {
12
12
  let prefetch = 'viewport';
13
13
  let prerender;
14
14
  let i18n;
15
+ let securityHeaders;
15
16
  const configPath = path.join(projectDir, 'lumenjs.config.ts');
16
17
  if (fs.existsSync(configPath)) {
17
18
  try {
@@ -38,6 +39,22 @@ export function readProjectConfig(projectDir) {
38
39
  if (prerenderMatch) {
39
40
  prerender = prerenderMatch[1] === 'true';
40
41
  }
42
+ const secHeadersMatch = configContent.match(/securityHeaders\s*:\s*\{([\s\S]*?)\}/);
43
+ if (secHeadersMatch) {
44
+ const block = secHeadersMatch[1];
45
+ const cspMatch = block.match(/contentSecurityPolicy\s*:\s*"([^"]+)"/)
46
+ || block.match(/contentSecurityPolicy\s*:\s*'([^']+)'/)
47
+ || block.match(/contentSecurityPolicy\s*:\s*`([^`]+)`/);
48
+ const ppMatch = block.match(/permissionsPolicy\s*:\s*'([^']+)'/)
49
+ || block.match(/permissionsPolicy\s*:\s*"([^"]+)"/)
50
+ || block.match(/permissionsPolicy\s*:\s*`([^`]+)`/);
51
+ if (cspMatch || ppMatch) {
52
+ securityHeaders = {
53
+ ...(cspMatch ? { contentSecurityPolicy: cspMatch[1] } : {}),
54
+ ...(ppMatch ? { permissionsPolicy: ppMatch[1] } : {}),
55
+ };
56
+ }
57
+ }
41
58
  const i18nMatch = configContent.match(/i18n\s*:\s*\{([\s\S]*?)\}/);
42
59
  if (i18nMatch) {
43
60
  const block = i18nMatch[1];
@@ -69,7 +86,7 @@ export function readProjectConfig(projectDir) {
69
86
  }
70
87
  catch { /* ignore */ }
71
88
  }
72
- return { title, integrations, prefetch, version, ...(i18n ? { i18n } : {}), ...(prerender ? { prerender } : {}) };
89
+ return { title, integrations, prefetch, version, ...(i18n ? { i18n } : {}), ...(prerender ? { prerender } : {}), ...(securityHeaders ? { securityHeaders } : {}) };
73
90
  }
74
91
  /**
75
92
  * Reads the project title from lumenjs.config.ts (or returns default).
@@ -18,6 +18,7 @@ export interface IndexHtmlOptions {
18
18
  prefetch?: string;
19
19
  authUser?: any;
20
20
  headContent?: string;
21
+ base?: string;
21
22
  }
22
23
  /**
23
24
  * Generates the index.html shell that loads the LumenJS app.
@@ -4,6 +4,9 @@ import { escapeHtml } from '../shared/utils.js';
4
4
  * Includes the router, app shell, and optionally the editor bridge.
5
5
  */
6
6
  export function generateIndexHtml(options) {
7
+ // Note: script src uses /@lumenjs/ (no base prefix) because Vite's
8
+ // transformIndexHtml already prepends config.base to absolute URLs.
9
+ // Including base here would double it when base != '/'.
7
10
  const editorScript = options.editorMode
8
11
  ? `<script type="module" src="/@lumenjs/editor-bridge"></script>`
9
12
  : '';
@@ -50,7 +53,7 @@ export function generateIndexHtml(options) {
50
53
  <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
51
54
  <title>${escapeHtml(options.title)}</title>
52
55
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
53
- ${options.integrations?.includes('nuralyui') ? '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@nuraly/lumenui@latest/packages/themes/dist/default.css">' : ''}${options.integrations?.includes('tailwind') ? '\n <script type="module">import "/styles/tailwind.css";</script>' : ''}
56
+ ${options.integrations?.includes('tailwind') ? '<script type="module">import "/styles/tailwind.css";</script>' : ''}
54
57
  ${options.headContent || ''}
55
58
  <style>
56
59
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { dirToLayoutTagName, fileHasLoader, fileHasSubscribe, fileHasAuth, fileHasMeta, fileHasStandalone, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
3
+ import { dirToLayoutTagName, fileHasLoader, fileHasSubscribe, fileHasSocket, fileHasAuth, fileHasMeta, fileHasStandalone, filePathToRoute, filePathToTagName } from '../../shared/utils.js';
4
4
  const VIRTUAL_MODULE_ID = 'virtual:lumenjs-routes';
5
5
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
6
6
  /**
@@ -105,6 +105,7 @@ export function lumenRoutesPlugin(pagesDir) {
105
105
  .map(r => {
106
106
  const hasLoader = fileHasLoader(r.componentPath);
107
107
  const hasSubscribe = fileHasSubscribe(r.componentPath);
108
+ const hasSocketFlag = fileHasSocket(r.componentPath);
108
109
  const hasAuth = fileHasAuth(r.componentPath);
109
110
  const hasMeta = fileHasMeta(r.componentPath);
110
111
  const isStandalone = fileHasStandalone(r.componentPath);
@@ -121,7 +122,7 @@ export function lumenRoutesPlugin(pagesDir) {
121
122
  });
122
123
  layoutsStr = `, layouts: [${items.join(', ')}]`;
123
124
  }
124
- return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}${hasSubscribe ? ', hasSubscribe: true' : ''}${hasMeta ? ', hasMeta: true' : ''}${hasAuth ? ', __nk_has_auth: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
125
+ return ` { path: ${JSON.stringify(r.path)}, tagName: ${JSON.stringify(r.tagName)}${hasLoader ? ', hasLoader: true' : ''}${hasSubscribe ? ', hasSubscribe: true' : ''}${hasSocketFlag ? ', hasSocket: true' : ''}${hasMeta ? ', hasMeta: true' : ''}${hasAuth ? ', __nk_has_auth: true' : ''}, load: () => import('${componentPath}')${layoutsStr} }`;
125
126
  })
126
127
  .join(',\n');
127
128
  return `export const routes = [\n${routeArray}\n];\n`;
@@ -19,6 +19,7 @@ export function virtualModulesPlugin(runtimeDir, editorDir) {
19
19
  'communication': 'communication.js',
20
20
  'webrtc': 'webrtc.js',
21
21
  'error-boundary': 'error-boundary.js',
22
+ 'island': 'island.js',
22
23
  'hydrate-support': '__virtual__',
23
24
  };
24
25
  // Modules resolved via resolve.alias instead of virtual module.
@@ -36,17 +37,27 @@ export function virtualModulesPlugin(runtimeDir, editorDir) {
36
37
  'standalone-overlay-styles': 'standalone-overlay-styles.js',
37
38
  'standalone-file-panel': 'standalone-file-panel.js',
38
39
  'overlay-utils': 'overlay-utils.js',
40
+ 'overlay-events': 'overlay-events.js',
41
+ 'overlay-hmr': 'overlay-hmr.js',
42
+ 'overlay-selection': 'overlay-selection.js',
39
43
  'text-toolbar': 'text-toolbar.js',
44
+ 'toolbar-styles': 'toolbar-styles.js',
40
45
  'editor-toolbar': 'editor-toolbar.js',
41
46
  'css-rules': 'css-rules.js',
42
47
  'ast-modification': 'ast-modification.js',
43
48
  'ast-service': 'ast-service.js',
44
49
  'file-service': 'file-service.js',
50
+ 'file-editor': 'file-editor.js',
51
+ 'syntax-highlighter': 'syntax-highlighter.js',
45
52
  'property-registry': 'property-registry.js',
46
53
  'properties-panel': 'properties-panel.js',
54
+ 'properties-panel-persist': 'properties-panel-persist.js',
55
+ 'properties-panel-rows': 'properties-panel-rows.js',
56
+ 'properties-panel-styles': 'properties-panel-styles.js',
47
57
  'i18n-key-gen': 'i18n-key-gen.js',
48
58
  'ai-chat-panel': 'ai-chat-panel.js',
49
59
  'ai-project-panel': 'ai-project-panel.js',
60
+ 'ai-markdown': 'ai-markdown.js',
50
61
  };
51
62
  function rewriteRelativeImports(code, modules) {
52
63
  for (const name of Object.keys(modules)) {
@@ -54,14 +65,26 @@ export function virtualModulesPlugin(runtimeDir, editorDir) {
54
65
  // Aliased modules use @lumenjs/name (resolved by Vite alias).
55
66
  // Virtual modules use /@lumenjs/name (resolved by this plugin).
56
67
  const prefix = aliasedModules.has(name) ? '@lumenjs' : '/@lumenjs';
57
- code = code.replace(new RegExp(`from\\s+['"]\\.\\/${file.replace('.', '\\.')}['"]`, 'g'), `from '${prefix}/${name}'`);
68
+ const escaped = file.replace('.', '\\.');
69
+ // Rewrite `from './file.js'`
70
+ code = code.replace(new RegExp(`from\\s+['"]\\.\\/${escaped}['"]`, 'g'), `from '${prefix}/${name}'`);
71
+ // Rewrite side-effect `import './file.js'`
72
+ code = code.replace(new RegExp(`import\\s+['"]\\.\\/${escaped}['"]`, 'g'), `import '${prefix}/${name}'`);
58
73
  }
59
74
  return code;
60
75
  }
76
+ let viteBase = '/';
61
77
  return {
62
78
  name: 'lumenjs-virtual-modules',
63
79
  enforce: 'pre',
80
+ configResolved(config) {
81
+ viteBase = config.base || '/';
82
+ },
64
83
  resolveId(id) {
84
+ // Strip Vite base prefix if present (e.g. /__app_dev/{id}/@lumenjs/foo → /@lumenjs/foo)
85
+ if (viteBase !== '/' && id.startsWith(viteBase)) {
86
+ id = '/' + id.slice(viteBase.length);
87
+ }
65
88
  const match = id.match(/^\/@lumenjs\/(.+)$/);
66
89
  if (!match)
67
90
  return;
@@ -143,13 +166,18 @@ globalThis.litElementHydrateSupport = ({LitElement}) => {
143
166
  try {
144
167
  hydrate(value, this.renderRoot, this.renderOptions);
145
168
  } catch (err) {
146
- // Digest mismatch — clear SSR content and render fresh (CSR fallback)
147
- console.warn('[LumenJS] Hydration failed for <' + this.localName + '>, falling back to CSR:', err.message);
169
+ // Digest mismatch — re-render fresh but avoid visible flash
170
+ console.warn('[LumenJS] Hydration mismatch for <' + this.localName + '>, falling back to CSR');
148
171
  const root = this.renderRoot;
149
- while (root.firstChild) root.removeChild(root.firstChild);
150
- delete root._$litPart$;
151
- // Re-adopt styles since clearing removed SSR <style> tags
172
+ // Preserve adopted styles so content is never unstyled
152
173
  adoptElementStyles(this);
174
+ // Remove only non-style children to keep styles applied during re-render
175
+ const toRemove = [];
176
+ for (let c = root.firstChild; c; c = c.nextSibling) {
177
+ if (c.nodeName !== 'STYLE') toRemove.push(c);
178
+ }
179
+ toRemove.forEach(c => root.removeChild(c));
180
+ delete root._$litPart$;
153
181
  render(value, root, this.renderOptions);
154
182
  }
155
183
  } else {