{"name":"Nameflare","format":"source-file-bundle","license":"AGPL-3.0-only","files":{"package.json":"{\n  \"name\": \"nameflare\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Open-source animated name cosmetics with one official hosted API.\",\n  \"license\": \"AGPL-3.0-only\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=22.13.0\"\n  },\n  \"scripts\": {\n    \"start\": \"node --env-file-if-exists=.env server.js\",\n    \"demo\": \"node --env-file-if-exists=.env server.js --demo\",\n    \"test\": \"node --test test/*.test.js\",\n    \"check\": \"node --check server.js && node --check lib/store.js && node --check lib/cosmetics.js && node --check lib/oauth.js && node --check public/app.js && node --check public/renderer.js && node --check public/paints.js && node --check public/editor.js && node --check public/client.js && node --check public/overlay.js && node --check cloudflare/store.js && node --check cloudflare/worker.js\",\n    \"cf:dev\": \"wrangler dev --env local --ip 127.0.0.1 --port 8787\",\n    \"cf:check\": \"wrangler deploy --env='' --dry-run --outdir .wrangler/build\",\n    \"cf:migrate:local\": \"wrangler d1 migrations apply nameflare-local --env local --local\"\n  },\n  \"overrides\": { \"sharp\": \"^0.35.5\" },\n  \"devDependencies\": {\n    \"wrangler\": \"^4.148.0\"\n  }\n}\n","server.js":"import http from 'node:http';\nimport { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';\nimport { resolve, join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { timingSafeEqual } from 'node:crypto';\nimport { Store, digest } from './lib/store.js';\nimport { assert, HttpError, validateRecipe, validateTexture, text, publishingFlag } from './lib/cosmetics.js';\nimport { beginOAuth, finishOAuth, providerConfig, avatarSources } from './lib/oauth.js';\n\nconst root = dirname(fileURLToPath(import.meta.url));\nconst safeEqual = (a, b) => typeof a === 'string' && typeof b === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));\nconst parseCookies = req => Object.fromEntries((req.headers.cookie || '').split(';').map(c => c.trim().split('=')).filter(c => c.length === 2));\nconst types = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.md': 'text/plain; charset=utf-8', '.gif': 'image/gif', '.png': 'image/png', '.webp': 'image/webp', '.svg': 'image/svg+xml; charset=utf-8' };\n\nexport function createApp({ store, baseUrl = 'http://localhost:3000', demo = false, dataDir = join(root, 'data'), env = process.env } = {}) {\n  baseUrl = baseUrl.replace(/\\/$/, '');\n  if (demo) {\n    assert(env.NODE_ENV !== 'production' && ['localhost','127.0.0.1'].includes(new URL(baseUrl).hostname), 500, 'Demo mode is restricted to local development.');\n  }\n  mkdirSync(join(dataDir, 'textures'), { recursive: true });\n  store ||= new Store(join(dataDir, 'nameflare.sqlite'));\n  if (demo) store.seedDemo();\n  const origin = new URL(baseUrl).origin;\n  const secure = origin.startsWith('https:');\n  const rates = new Map(), streams = new Set();\n  const cookie = (name, value, age) => `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`;\n  const sessionCookie = (res, raw) => res.setHeader('Set-Cookie', cookie('nameflare_session', raw, 604800));\n  const getSession = req => store.session(parseCookies(req).nameflare_session);\n  const requireUser = req => { const session = getSession(req); assert(session, 401, 'Sign in to continue.'); return session; };\n  const requireMod = req => { const session = requireUser(req); assert(['owner','moderator'].includes(session.role), 403, 'Moderator access required.'); return session; };\n  const requireOwner = req => { const session = requireUser(req); assert(session.role === 'owner', 403, 'Owner access required.'); return session; };\n  const mutation = req => {\n    assert(req.headers.origin === origin, 403, 'Requests must come from the official app origin.');\n    const session = requireUser(req);\n    assert(safeEqual(req.headers['x-csrf-token'], session.csrf), 403, 'Invalid security token. Refresh the page.');\n    return session;\n  };\n  async function body(req) {\n    assert((req.headers['content-type'] || '').split(';')[0] === 'application/json', 415, 'Send application/json.');\n    let length = 0; const chunks = [];\n    for await (const chunk of req) { length += chunk.length; assert(length <= 2900000, 413, 'Request body is too large.'); chunks.push(chunk); }\n    try { const parsed = JSON.parse(Buffer.concat(chunks).toString()); assert(parsed && typeof parsed === 'object' && !Array.isArray(parsed), 400, 'Expected a JSON object.'); return parsed; }\n    catch (error) { if (error instanceof HttpError) throw error; throw new HttpError(400, 'Invalid JSON.'); }\n  }\n  function json(res, status, value, publicCache = false) {\n    const serialized = JSON.stringify(value);\n    if (publicCache) {\n      res.setHeader('Access-Control-Allow-Origin', '*');\n      res.setHeader('Cache-Control', 'public, max-age=15, must-revalidate');\n      res.setHeader('ETag', `\"${digest(serialized)}\"`);\n    }\n    res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(serialized);\n  }\n  function rate(req, key, max) {\n    // Never trust X-Forwarded-For from arbitrary clients. Apply per-client limits at your reverse proxy too.\n    const address = req.socket.remoteAddress || 'unknown';\n    const id = `${address}:${key}`;\n    const entry = rates.get(id);\n    if (!entry || entry.reset < Date.now()) { rates.set(id, { count: 1, reset: Date.now() + 60000 }); return; }\n    entry.count++; assert(entry.count <= max, 429, 'Too many requests. Try again in a minute.');\n  }\n  const cleanup = setInterval(() => {\n    store.cleanup();\n    for (const [id, entry] of rates) if (entry.reset < Date.now()) rates.delete(id);\n    for (const response of streams) response.write(': heartbeat\\n\\n');\n  }, 25000).unref();\n\n  const server = http.createServer(async (req, res) => {\n    res.setHeader('X-Content-Type-Options','nosniff');\n    res.setHeader('Referrer-Policy','no-referrer');\n    res.setHeader('Cache-Control','no-store');\n    res.setHeader('Permissions-Policy','camera=(), microphone=(), geolocation=()');\n    res.setHeader('Content-Security-Policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${avatarSources}; connect-src 'self' wss://irc-ws.chat.twitch.tv; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`);\n    if (secure) res.setHeader('Strict-Transport-Security','max-age=31536000');\n    try {\n      const url = new URL(req.url, origin), path = url.pathname;\n      const method = req.method;\n      if (path.startsWith('/api/v1/')) res.setHeader('Access-Control-Allow-Origin','*');\n      if (method === 'OPTIONS' && path.startsWith('/api/v1/')) {\n        res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400' }); return res.end();\n      }\n      rate(req, method === 'GET' ? 'read':'write', method === 'GET' ? 300 : 40);\n      if (method === 'GET' && path === '/healthz') return json(res,200,{ status:'ok' });\n      if (method === 'GET' && path === '/license') {\n        res.writeHead(200, { 'Content-Type':'text/plain; charset=utf-8' }); return res.end(readFileSync(join(root,'LICENSE')));\n      }\n      if (method === 'GET' && path === '/source') {\n        if (env.SOURCE_URL) {\n          const source = new URL(env.SOURCE_URL);\n          assert(source.protocol === 'https:',500,'SOURCE_URL must be a public HTTPS repository URL.');\n          res.writeHead(302, { Location:source.toString() }); return res.end();\n        }\n        const sourceFiles = ['package.json','server.js','lib/cosmetics.js','lib/store.js','lib/oauth.js','public/index.html','public/logo.svg','public/style.css','public/effects.css','public/app.js','public/editor.js','public/renderer.js','public/paints.js','public/client.js','public/SDK-LICENSE.md','test/client.test.js','public/overlay.html','public/overlay.css','public/overlay.js','test/nameflare.test.js','test/accounts.test.js','test/editor.test.js','test/paints.test.js','README.md','LICENSE','SECURITY.md','docs/API.md','docs/DEPLOYMENT.md','docs/CLOUDFLARE.md','docs/ORACLE.md','docs/oracle/nameflare.service','docs/oracle/nameflare.nginx.conf','docs/oracle/nameflare-http.nginx.conf','cloudflare/worker.js','cloudflare/store.js','cloudflare/migrations/0001_initial.sql','cloudflare/migrations/0002_profile_avatars.sql','wrangler.jsonc','test/cloudflare.test.js','cloudflare/migrations/0003_flare_publication.sql','package-lock.json','Dockerfile','compose.yaml','.env.example','.gitignore','.dockerignore'];\n        res.setHeader('Content-Disposition','attachment; filename=\"nameflare-source.json\"');\n        return json(res,200,{ name:'Nameflare', format:'source-file-bundle', license:'AGPL-3.0-only', files:Object.fromEntries(sourceFiles.map(file => [file,readFileSync(join(root,file),'utf8')])) });\n      }\n      if (method === 'GET' && path === '/api/config') return json(res,200,{ name:'Nameflare', owner:'B_X_N_E', demo, officialApi: `${baseUrl}/api/v1`, providers:Object.fromEntries(['twitch','kick','youtube'].map(p => { const c = providerConfig(p,env); return [p,Boolean(c.clientId && c.clientSecret)]; })), stats: store.stats() });\n      if (method === 'GET' && path === '/api/me') {\n        const session = getSession(req);\n        return json(res,200,{ user: session ? store.account(session.id):null, csrf:session?.csrf || null });\n      }\n      if (method === 'POST' && path === '/api/demo/login') {\n        assert(demo && ['127.0.0.1','::1','::ffff:127.0.0.1'].includes(req.socket.remoteAddress),404,'Not found.');\n        assert(req.headers.origin === origin,403,'Invalid origin.');\n        const data = await body(req);\n        assert(['owner','creator','mod'].includes(data.role),400,'Invalid demo role.');\n        store.revokeSession(parseCookies(req).nameflare_session);\n        const session = store.newSession(`demo-${data.role}`); sessionCookie(res,session.raw);\n        return json(res,200,{ ok:true });\n      }\n      const auth = /^\\/auth\\/(twitch|kick|youtube)(\\/callback)?$/.exec(path);\n      if (method === 'GET' && auth) {\n        assert(!demo,403,'Real account linking is disabled in the isolated demo.');\n        const provider = auth[1], session = getSession(req);\n        if (!auth[2]) {\n          const started = beginOAuth(provider,store,session,baseUrl,env);\n          res.setHeader('Set-Cookie',cookie(`nameflare_oauth_${provider}`,started.binding,600));\n          res.writeHead(302,{ Location:started.url }); return res.end();\n        }\n        const state = store.consumeState(url.searchParams.get('state'),provider,parseCookies(req)[`nameflare_oauth_${provider}`]);\n        if (state.user_id) assert(session?.id === state.user_id,403,'Account changed during linking. Please start again.');\n        else assert(!session,403,'You signed in during this login attempt. Please start again.');\n        assert(!url.searchParams.has('error'),400,'Authorization was cancelled.');\n        const profile = await finishOAuth(provider,url.searchParams.get('code'),state.verifier,baseUrl,env);\n        const id = store.linkIdentity(profile,state.user_id,env.OWNER_TWITCH_ID);\n        store.revokeSession(parseCookies(req).nameflare_session);\n        const created = store.newSession(id);\n        res.setHeader('Set-Cookie',[cookie('nameflare_session',created.raw,604800),cookie(`nameflare_oauth_${provider}`,'',0)]);\n        res.writeHead(302,{ Location:'/#account' }); return res.end();\n      }\n      if (method === 'POST' && path === '/api/logout') {\n        mutation(req); store.revokeSession(parseCookies(req).nameflare_session);\n        res.setHeader('Set-Cookie',cookie('nameflare_session','',0)); return json(res,200,{ ok:true });\n      }\n      if (method === 'POST' && path === '/api/account/profile') {\n        const session = mutation(req), data = await body(req);\n        assert(['twitch','kick','youtube'].includes(data.provider),400,'Choose a connected platform for your profile.');\n        return json(res,200,{ user:store.updateProfile(session.id,data.provider) });\n      }\n      if (method === 'GET' && path === '/api/admin/users') {\n        requireOwner(req);\n        const offset = Number(url.searchParams.get('offset') || 0);\n        assert(Number.isInteger(offset) && offset >= 0 && offset <= 1000000,400,'Invalid pagination.');\n        const users = store.adminUsers({query:(url.searchParams.get('q') || '').slice(0,80),offset,limit:51});\n        return json(res,200,{ users:users.slice(0,50), next_offset:users.length > 50 ? offset+50:null });\n      }\n      if (method === 'GET' && path === '/api/my/cosmetics') return json(res,200,{ cosmetics:store.mine(requireUser(req).id) });\n      if (method === 'POST' && path === '/api/cosmetics') {\n        const session = mutation(req), data = await body(req);\n        const recipe = validateRecipe(data.recipe);\n        const textureIds = new Set([recipe.textureId,...(recipe.layers || []).filter(layer => layer.type === 'image').map(layer => layer.textureId)].filter(Boolean));\n        for (const id of textureIds) assert(store.db.prepare('SELECT id FROM textures WHERE id=? AND owner_id=?').get(id,session.id),400,'Upload every image layer from your own account first.');\n        return json(res,201,{ cosmetic:store.submit(session.id,text(data.name,2,40,'Name'),text(data.description || '',0,400,'Description'),recipe,publishingFlag(data.listed)) });\n      }\n      if (method === 'POST' && path === '/api/cosmetics/publication') {\n        const session = mutation(req), data = await body(req);\n        assert(typeof data.listed === 'boolean',400,'Public listing must be true or false.');\n        return json(res,200,{ cosmetic:store.setPublication(session.id,text(data.id,1,100,'Flare ID'),data.listed) });\n      }\n      if (method === 'POST' && path === '/api/textures') {\n        const session = mutation(req);\n        const count = store.db.prepare('SELECT COUNT(*) AS n FROM textures WHERE owner_id=?').get(session.id).n;\n        assert(count < 50,429,'Texture limit reached (50 per account).');\n        const uploaded = validateTexture((await body(req)).data);\n        const existing = store.db.prepare('SELECT owner_id FROM textures WHERE id=?').get(uploaded.id);\n        assert(!existing || existing.owner_id === session.id,409,'This texture is already owned by another creator.');\n        writeFileSync(join(dataDir,'textures',uploaded.id),uploaded.bytes);\n        store.db.prepare('INSERT OR IGNORE INTO textures VALUES (?,?)').run(uploaded.id,session.id);\n        return json(res,201,{ id:uploaded.id });\n      }\n      if (method === 'POST' && path === '/api/equip') {\n        const session = mutation(req), data = await body(req);\n        assert(data.cosmeticId === null || typeof data.cosmeticId === 'string',400,'Invalid cosmetic ID.');\n        store.equip(session.id,data.cosmeticId); return json(res,200,{ ok:true });\n      }\n      if (method === 'GET' && path === '/api/admin/queue') {\n        requireMod(req);\n        const status = url.searchParams.get('status') || 'pending', offset = Number(url.searchParams.get('offset') || 0);\n        assert(['pending','approved','rejected'].includes(status),400,'Invalid review status.');\n        assert(Number.isInteger(offset) && offset >= 0 && offset <= 1000000,400,'Invalid pagination.');\n        const cosmetics = store.moderationCatalog({status,offset,limit:51,query:(url.searchParams.get('q') || '').slice(0,80)});\n        return json(res,200,{ cosmetics:cosmetics.slice(0,50), stats:store.stats(), next_offset:cosmetics.length > 50 ? offset+50:null });\n      }\n      if (method === 'GET' && path === '/api/admin/audit') { requireMod(req); return json(res,200,{ entries:store.auditLog() }); }\n      if (method === 'GET' && path === '/api/admin/moderators') { requireOwner(req); return json(res,200,{ moderators:store.moderators() }); }\n      if (method === 'POST' && path === '/api/admin/review') {\n        const session = mutation(req); requireMod(req);\n        const data = await body(req);\n        assert(['approved','rejected'].includes(data.status),400,'Invalid review decision.');\n        const reason = text(data.reason || '',data.status === 'rejected' ? 3:0,400,'Review note');\n        return json(res,200,{ cosmetic:store.review(session,text(data.id,1,100,'Cosmetic ID'),data.status,reason) });\n      }\n      if (method === 'POST' && path === '/api/admin/moderators') {\n        const session = mutation(req); requireOwner(req);\n        const data = await body(req);\n        assert(typeof data.enabled === 'boolean' && typeof data.twitchId === 'string' && /^\\d{1,30}$/.test(data.twitchId),400,'Provide a numeric Twitch ID and enabled flag.');\n        store.setModerator(session,data.twitchId,data.enabled); return json(res,200,{ moderators:store.moderators() });\n      }\n      if (method === 'GET' && path === '/api/v1/cosmetics') {\n        const offset = Number(url.searchParams.get('offset') || 0), limit = Number(url.searchParams.get('limit') || 60);\n        assert(Number.isInteger(offset) && offset >= 0 && offset <= 1000000 && Number.isInteger(limit) && limit >= 1 && limit <= 100,400,'Invalid pagination.');\n        const results = store.catalog({offset,limit:limit+1,query:(url.searchParams.get('q') || '').slice(0,80),listedOnly:true,effect:url.searchParams.get('effect') || 'all',sort:url.searchParams.get('sort') || 'newest'});\n        return json(res,200,{ version:1, total:store.stats().public, cosmetics:results.slice(0,limit).map(c => store.publicCosmetic(c)), next_offset:results.length > limit ? offset+limit:null },true);\n      }\n      const cosmeticMatch = /^\\/api\\/v1\\/cosmetics\\/([a-zA-Z0-9-]+)$/.exec(path);\n      if (method === 'GET' && cosmeticMatch) {\n        const cosmetic = store.publicCosmetic(store.getCosmetic(cosmeticMatch[1]));\n        assert(cosmetic,404,'Approved cosmetic not found.'); return json(res,200,{ cosmetic },true);\n      }\n      const identityMatch = /^\\/api\\/v1\\/users\\/(twitch|kick|youtube)\\/([^/]+)$/.exec(path);\n      if (method === 'GET' && identityMatch) {\n        const identity = store.resolve(identityMatch[1],decodeURIComponent(identityMatch[2]));\n        assert(identity,404,'No linked identity found.'); return json(res,200,{ user:identity },true);\n      }\n      if (method === 'POST' && path === '/api/v1/resolve') {\n        const data = await body(req);\n        assert(['twitch','kick','youtube'].includes(data.provider) && Array.isArray(data.ids) && data.ids.length <= 100 && data.ids.every(id => typeof id === 'string' && id.length > 0 && id.length <= 100),400,'Provide a platform and up to 100 string user IDs.');\n        return json(res,200,{ users:data.ids.map(id => store.resolve(data.provider,id)).filter(Boolean) });\n      }\n      const texture = /^\\/textures\\/([a-f0-9]{64}\\.(gif|png|webp))$/.exec(path);\n      if (method === 'GET' && texture) {\n        const id = texture[1];\n        const approved = store.db.prepare(\"SELECT c.id FROM cosmetics c WHERE c.status='approved' AND (json_extract(c.recipe,'$.textureId')=? OR EXISTS (SELECT 1 FROM json_each(c.recipe,'$.layers') layer WHERE json_extract(layer.value,'$.textureId')=?)) LIMIT 1\").get(id,id);\n        if (!approved) {\n          const session = requireUser(req);\n          assert(['owner','moderator'].includes(session.role) || store.db.prepare('SELECT id FROM textures WHERE id=? AND owner_id=?').get(id,session.id),403,'This texture is awaiting approval.');\n        } else { res.setHeader('Access-Control-Allow-Origin','*'); res.setHeader('Cache-Control','public, max-age=15, must-revalidate'); }\n        assert(existsSync(join(dataDir,'textures',id)),404,'Texture not found.');\n        res.writeHead(200,{'Content-Type':types[`.${id.split('.').pop()}`]}); return res.end(readFileSync(join(dataDir,'textures',id)));\n      }\n      if (method === 'GET' && path === '/api/overlay/events') {\n        assert(env.OVERLAY_ROOM_KEY && safeEqual(url.searchParams.get('room'),env.OVERLAY_ROOM_KEY),403,'Invalid overlay room.');\n        assert(streams.size < 100,503,'Overlay capacity reached.');\n        res.writeHead(200,{'Content-Type':'text/event-stream','Connection':'keep-alive','X-Accel-Buffering':'no'});\n        res.write(': connected\\n\\n'); streams.add(res); req.on('close',() => streams.delete(res)); return;\n      }\n      if (method === 'POST' && path === '/api/overlay/messages') {\n        assert(env.OVERLAY_INGEST_TOKEN && safeEqual(req.headers.authorization,`Bearer ${env.OVERLAY_INGEST_TOKEN}`),401,'Valid server-side overlay ingest token required.');\n        const data = await body(req);\n        assert(['twitch','kick','youtube'].includes(data.provider),400,'Invalid platform.');\n        const message = { provider:data.provider, provider_id:text(data.provider_id,1,100,'User ID'), name:text(data.name,1,80,'Display name'), text:text(data.text,1,1000,'Message') };\n        for (const response of streams) response.write(`data: ${JSON.stringify(message)}\\n\\n`);\n        return json(res,202,{ accepted:true });\n      }\n      if (method === 'GET') {\n        const pages = { '/logo.svg':'logo.svg', '/favicon.ico':'logo.svg', '/':'index.html', '/guide':'index.html', '/overlay':'overlay.html', '/index.html':'index.html', '/overlay.html':'overlay.html', '/app.js':'app.js', '/style.css':'style.css', '/renderer.js':'renderer.js', '/overlay.js':'overlay.js', '/overlay.css':'overlay.css', '/effects.css':'effects.css', '/paints.js':'paints.js', '/editor.js':'editor.js', '/client.js':'client.js', '/SDK-LICENSE.md':'SDK-LICENSE.md' };\n        const file = pages[path];\n        if (file) {\n          res.writeHead(200,{'Content-Type':types[`.${file.split('.').pop()}`]}); return res.end(readFileSync(join(root,'public',file)));\n        }\n      }\n      throw new HttpError(404,'Not found.');\n    } catch (error) {\n      if (res.headersSent) return res.end();\n      if (!(error instanceof HttpError)) console.error('Request failed:',error.message);\n      json(res,error.status || 500,{ error:error instanceof HttpError ? error.message:'Something went wrong. Please try again.' });\n    }\n  });\n  server.headersTimeout = 15000; server.requestTimeout = 20000;\n  server.on('close',() => { clearInterval(cleanup); for (const response of streams) response.end(); });\n  return { server, store };\n}\n\nif (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {\n  const demo = process.argv.includes('--demo');\n  const port = Number(process.env.PORT || 3000);\n  const host = demo ? '127.0.0.1' : (process.env.HOST || '127.0.0.1');\n  const baseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\\/$/,'');\n  if (demo && process.env.NODE_ENV === 'production') throw new Error('Demo mode is forbidden in production.');\n  if (demo && !['localhost','127.0.0.1'].includes(new URL(baseUrl).hostname)) throw new Error('Demo mode requires a loopback PUBLIC_BASE_URL.');\n  if (process.env.NODE_ENV === 'production') {\n    if (!baseUrl.startsWith('https://') || !/^\\d+$/.test(process.env.OWNER_TWITCH_ID || '')) throw new Error('Production needs an HTTPS PUBLIC_BASE_URL and numeric OWNER_TWITCH_ID.');\n  }\n  const dataDir = resolve(process.env.DATA_DIR || join(root,demo ? 'data-demo':'data'));\n  mkdirSync(dataDir,{recursive:true});\n  const {server,store} = createApp({store:new Store(join(dataDir,'nameflare.sqlite')),baseUrl,demo,dataDir});\n  server.listen(port,host,() => console.log(`Nameflare ${demo ? 'isolated DEMO':'server'} ready at ${baseUrl}`));\n  for (const signal of ['SIGINT','SIGTERM']) process.on(signal,() => {\n    server.close(() => { store.close(); process.exit(0); });\n    setTimeout(() => process.exit(1),5000).unref();\n  });\n}\n","lib/cosmetics.js":"import { createHash } from 'node:crypto';\nimport { normalizeLayers, normalizeShadows, MAX_TEXTURE_BYTES } from '../public/paints.js';\n\nexport class HttpError extends Error {\n  constructor(status, message) { super(message); this.status = status; }\n}\nexport function assert(condition, status, message) {\n  if (!condition) throw new HttpError(status, message);\n}\nexport function text(value, min, max, label) {\n  assert(typeof value === 'string' && value.trim().length >= min && value.trim().length <= max, 400, `${label} must be ${min}–${max} characters.`);\n  return value.trim();\n}\nexport function publishingFlag(value = true) {\n  assert(typeof value === 'boolean', 400, 'Public listing must be true or false.');\n  return value;\n}\n// Only allowlisted SQL fragments are used by both storage adapters.\nexport function catalogSelection({ effect = 'all', sort = 'newest' } = {}) {\n  const image = \"(json_extract(c.recipe,'$.textureId') IS NOT NULL OR EXISTS (SELECT 1 FROM json_each(c.recipe,'$.layers') layer WHERE json_extract(layer.value,'$.type')='image'))\";\n  const filters = { all:'1=1', animated:`(json_extract(c.recipe,'$.animation')!='none' OR ${image})`, static:`(json_extract(c.recipe,'$.animation')='none' AND NOT ${image})`, glow:\"json_extract(c.recipe,'$.glow')>0\", texture:image, depth:\"json_extract(c.recipe,'$.depth')>0\" };\n  const orders = { newest:'COALESCE(c.reviewed_at,c.created_at) DESC,c.id', popular:'equipped_count DESC,COALESCE(c.reviewed_at,c.created_at) DESC,c.id', name:'c.name COLLATE NOCASE,c.id' };\n  assert(Object.hasOwn(filters,effect) && Object.hasOwn(orders,sort),400,'Invalid Flare filter or sort.');\n  return { filter:filters[effect], order:orders[sort] };\n}\nexport function validateRecipe(input) {\n  assert(input && typeof input === 'object' && !Array.isArray(input), 400, 'A recipe is required.');\n  assert(Array.isArray(input.colors) && input.colors.length >= 2 && input.colors.length <= 5, 400, 'Choose 2–5 colors.');\n  assert(input.colors.every(c => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c)), 400, 'Colors must be six-digit hex values.');\n  assert(['flow', 'pulse', 'shimmer', 'none'].includes(input.animation), 400, 'Invalid animation.');\n  const numeric = (name, min, max) => {\n    assert(typeof input[name] === 'number' && Number.isFinite(input[name]) && input[name] >= min && input[name] <= max, 400, `${name} must be between ${min} and ${max}.`);\n    return input[name];\n  };\n  const recipe = { colors: input.colors, animation: input.animation, speed: numeric('speed', 1, 20), angle: numeric('angle', 0, 360), glow: numeric('glow', 0, 20) };\n  const optionalNumber = (key, fallback, min, max) => {\n    if (input[key] === undefined) return fallback;\n    return numeric(key, min, max);\n  };\n  const optionalColor = (key, fallback) => {\n    const value = input[key] === undefined ? fallback : input[key];\n    assert(typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value), 400, `${key} must be a six-digit hex color.`);\n    return value;\n  };\n  Object.assign(recipe, {\n    glowColor: optionalColor('glowColor', input.colors[0]),\n    shadowX: optionalNumber('shadowX', 0, -12, 12),\n    shadowY: optionalNumber('shadowY', 0, -12, 12),\n    shadowBlur: optionalNumber('shadowBlur', 0, 0, 20),\n    shadowOpacity: optionalNumber('shadowOpacity', 0, 0, 100),\n    shadowColor: optionalColor('shadowColor', '#000000'),\n    outline: optionalNumber('outline', 0, 0, 2),\n    outlineColor: optionalColor('outlineColor', '#ffffff'),\n    depth: optionalNumber('depth', 0, 0, 8),\n    depthAngle: optionalNumber('depthAngle', 45, 0, 360),\n    depthColor: optionalColor('depthColor', '#303047')\n  });\n  assert(Number.isInteger(recipe.depth), 400, '3D depth must be a whole number.');\n  if (input.layers !== undefined || input.shadows !== undefined || input.fontWeight !== undefined) {\n    try {\n      if (input.layers !== undefined) recipe.layers = normalizeLayers(input.layers);\n      if (input.shadows !== undefined) recipe.shadows = normalizeShadows(input.shadows);\n      if (input.fontWeight !== undefined) recipe.fontWeight = numeric('fontWeight', 400, 900);\n    } catch (error) { throw new HttpError(400, error.message); }\n  }\n  if (input.textureId) {\n    assert(typeof input.textureId === 'string' && /^[a-f0-9]{64}\\.(gif|webp|png)$/.test(input.textureId), 400, 'Invalid texture ID.');\n    recipe.textureId = input.textureId;\n  }\n  return recipe;\n}\nexport function validateTexture(data) {\n  assert(typeof data === 'string' && data.length <= 2800000, 400, 'Texture must be under 2 MB.');\n  const match = /^data:image\\/(gif|webp|png);base64,([A-Za-z0-9+/]+={0,2})$/.exec(data);\n  assert(match, 400, 'Upload a GIF, WebP, or PNG. SVG and arbitrary URLs are not accepted.');\n  const bytes = Buffer.from(match[2], 'base64');\n  assert(bytes.length > 12 && bytes.length <= MAX_TEXTURE_BYTES, 400, 'Texture must be under 2 MB.');\n  let valid = false, width = 0, height = 0;\n  if (match[1] === 'gif') {\n    valid = ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString());\n    width = bytes.readUInt16LE(6); height = bytes.readUInt16LE(8);\n  } else if (match[1] === 'png') {\n    valid = bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) && bytes.subarray(12,16).toString() === 'IHDR';\n    if (valid) { width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20); }\n  } else {\n    valid = bytes.subarray(0,4).toString() === 'RIFF' && bytes.subarray(8,12).toString() === 'WEBP' && bytes.length >= 30;\n    const kind = bytes.subarray(12,16).toString();\n    if (valid && kind === 'VP8X') {\n      width = 1 + bytes.readUIntLE(24,3); height = 1 + bytes.readUIntLE(27,3);\n    } else if (valid && kind === 'VP8L' && bytes[20] === 0x2f) {\n      const bits = bytes.readUInt32LE(21); width = (bits & 0x3fff) + 1; height = ((bits >>> 14) & 0x3fff) + 1;\n    } else if (valid && kind === 'VP8 ' && bytes.subarray(23,26).equals(Buffer.from([0x9d,0x01,0x2a]))) {\n      width = bytes.readUInt16LE(26) & 0x3fff; height = bytes.readUInt16LE(28) & 0x3fff;\n    } else { valid = false; }\n  }\n  assert(valid && width > 0 && height > 0 && width <= 2048 && height <= 2048, 400, 'Invalid image header or dimensions above 2048×2048.');\n  return { bytes, id: `${createHash('sha256').update(bytes).digest('hex')}.${match[1]}` };\n}\n","lib/store.js":"import { DatabaseSync } from 'node:sqlite';\nimport { randomUUID, randomBytes, createHash } from 'node:crypto';\nimport { assert, catalogSelection, publishingFlag } from './cosmetics.js';\nimport { safeAvatar } from './oauth.js';\nconst paintSelect = `SELECT c.*, u.name AS creator, u.avatar_url AS creator_avatar, u.profile_provider AS creator_provider, r.name AS reviewer, (SELECT COUNT(*) FROM users e WHERE e.equipped=c.id) AS equipped_count FROM cosmetics c JOIN users u ON u.id=c.owner_id LEFT JOIN users r ON r.id=c.reviewer_id`;\n\nexport const digest = value => createHash('sha256').update(value).digest('hex');\nexport const token = () => randomBytes(32).toString('base64url');\nconst now = () => new Date().toISOString();\n\nexport class Store {\n  constructor(path = ':memory:') {\n    this.db = new DatabaseSync(path);\n    this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;\n      CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', equipped TEXT, created_at TEXT NOT NULL);\n      CREATE TABLE IF NOT EXISTS identities (provider TEXT NOT NULL, provider_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id), login TEXT NOT NULL, display_name TEXT NOT NULL, PRIMARY KEY(provider,provider_id), UNIQUE(user_id,provider));\n      CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), csrf TEXT NOT NULL, expires INTEGER NOT NULL);\n      CREATE TABLE IF NOT EXISTS oauth_states (hash TEXT PRIMARY KEY, provider TEXT NOT NULL, user_id TEXT, binding TEXT NOT NULL, verifier TEXT NOT NULL, expires INTEGER NOT NULL);\n      CREATE TABLE IF NOT EXISTS cosmetics (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, description TEXT NOT NULL, recipe TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, reviewed_at TEXT, reviewer_id TEXT);\n      CREATE TABLE IF NOT EXISTS textures (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id));\n      CREATE TABLE IF NOT EXISTS audit (id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL);\n      CREATE INDEX IF NOT EXISTS cosmetic_status ON cosmetics(status,created_at);\n      CREATE INDEX IF NOT EXISTS identity_user ON identities(user_id);`);\n    // Additive migrations preserve existing production accounts, sessions, and paints.\n    for (const [table, column, definition] of [['users','avatar_url',\"TEXT NOT NULL DEFAULT ''\"],['users','profile_provider',\"TEXT NOT NULL DEFAULT ''\"],['identities','avatar_url',\"TEXT NOT NULL DEFAULT ''\"],['cosmetics','listed','INTEGER NOT NULL DEFAULT 1 CHECK(listed IN (0,1))']]) {\n      if (!this.db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column)) this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);\n    }\n  }\n  transaction(fn) {\n    this.db.exec('BEGIN IMMEDIATE');\n    try { const result = fn(); this.db.exec('COMMIT'); return result; }\n    catch (error) { this.db.exec('ROLLBACK'); throw error; }\n  }\n  user(id) { return this.db.prepare('SELECT * FROM users WHERE id=?').get(id); }\n  identities(id) { return this.db.prepare('SELECT provider, provider_id, login, display_name, avatar_url FROM identities WHERE user_id=?').all(id); }\n  account(id) {\n    const user = this.user(id);\n    return user ? { ...user, identities: this.identities(id) } : null;\n  }\n  linkIdentity(profile, currentId, ownerTwitchId) {\n    return this.transaction(() => {\n      const existing = this.db.prepare('SELECT * FROM identities WHERE provider=? AND provider_id=?').get(profile.provider, profile.id);\n      if (currentId && existing) assert(existing.user_id === currentId, 409, 'This identity belongs to another account. Sign out to access that account.');\n      let id = currentId || existing?.user_id;\n      if (!id) {\n        id = randomUUID();\n        this.db.prepare('INSERT INTO users (id,name,created_at) VALUES (?,?,?)').run(id, profile.name, now());\n      }\n      const slot = this.db.prepare('SELECT * FROM identities WHERE user_id=? AND provider=?').get(id, profile.provider);\n      assert(!slot || slot.provider_id === profile.id, 409, 'This account already has a different identity for this platform.');\n      const avatar = safeAvatar(profile.provider, profile.avatar_url);\n      this.db.prepare('INSERT INTO identities (provider,provider_id,user_id,login,display_name,avatar_url) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,provider_id) DO UPDATE SET login=excluded.login, display_name=excluded.display_name, avatar_url=excluded.avatar_url').run(profile.provider, profile.id, id, profile.login, profile.name, avatar);\n      this.db.prepare(\"UPDATE users SET profile_provider=?,name=?,avatar_url=? WHERE id=? AND (profile_provider='' OR profile_provider=?)\").run(profile.provider, profile.name, avatar, id, profile.provider);\n      if (profile.provider === 'twitch' && profile.id === ownerTwitchId) {\n        this.db.prepare(\"UPDATE users SET role='owner' WHERE id=?\").run(id);\n      }\n      return id;\n    });\n  }\n  updateProfile(id, provider) {\n    const identity = this.identities(id).find(item => item.provider === provider);\n    assert(identity, 400, 'Choose a connected platform for your profile.');\n    this.db.prepare('UPDATE users SET profile_provider=?,name=?,avatar_url=? WHERE id=?').run(provider, identity.display_name, safeAvatar(provider, identity.avatar_url), id);\n    return this.account(id);\n  }\n  adminUsers({ query = '', offset = 0, limit = 50 } = {}) {\n    const rows = this.db.prepare(`SELECT u.*,i.provider_id AS twitch_id FROM users u LEFT JOIN identities i ON i.user_id=u.id AND i.provider='twitch' WHERE u.name LIKE ? OR EXISTS (SELECT 1 FROM identities x WHERE x.user_id=u.id AND (x.login LIKE ? OR x.provider_id=?)) ORDER BY u.created_at DESC,u.id LIMIT ? OFFSET ?`).all(`%${query}%`, `%${query}%`, query, limit, offset);\n    return rows.map(row => ({ ...row, identities: this.identities(row.id) }));\n  }\n  moderationCatalog(options) { return this.catalog(options).map(paint => ({ ...paint, creator_identities: this.identities(paint.owner_id) })); }\n  newSession(id) {\n    const raw = token(), csrf = token();\n    this.db.prepare('INSERT INTO sessions VALUES (?,?,?,?)').run(digest(raw), id, csrf, Date.now() + 7 * 86400000);\n    return { raw, csrf };\n  }\n  session(raw) {\n    if (!raw) return null;\n    return this.db.prepare('SELECT sessions.csrf, users.* FROM sessions JOIN users ON users.id=sessions.user_id WHERE hash=? AND expires>?').get(digest(raw), Date.now()) || null;\n  }\n  revokeSession(raw) { if (raw) this.db.prepare('DELETE FROM sessions WHERE hash=?').run(digest(raw)); }\n  state(provider, userId, binding, verifier) {\n    const raw = token();\n    this.db.prepare('INSERT INTO oauth_states VALUES (?,?,?,?,?,?)').run(digest(raw), provider, userId || null, digest(binding), verifier, Date.now() + 600000);\n    return raw;\n  }\n  consumeState(raw, provider, binding) {\n    return this.transaction(() => {\n      const state = this.db.prepare('SELECT * FROM oauth_states WHERE hash=?').get(digest(raw || ''));\n      if (state) this.db.prepare('DELETE FROM oauth_states WHERE hash=?').run(state.hash);\n      assert(state && state.provider === provider && state.expires > Date.now() && state.binding === digest(binding || ''), 400, 'Login expired or invalid. Please start again.');\n      return state;\n    });\n  }\n  cosmetic(row) {\n    if (!row) return null;\n    return { ...row, recipe: JSON.parse(row.recipe) };\n  }\n  getCosmetic(id) { return this.cosmetic(this.db.prepare(`${paintSelect} WHERE c.id=?`).get(id)); }\n  catalog({ status = 'approved', owner, offset = 0, limit = 60, query = '', listedOnly = false, effect = 'all', sort = 'newest' } = {}) {\n    const selection = catalogSelection({effect,sort});\n    const params = [status];\n    let filter = `c.status=? AND (${selection.filter})${listedOnly ? ' AND c.listed=1' : ''}`;\n    if (owner) { filter += ' AND c.owner_id=?'; params.push(owner); }\n    if (query) { filter += ' AND (c.name LIKE ? OR u.name LIKE ?)'; params.push(`%${query}%`, `%${query}%`); }\n    return this.db.prepare(`${paintSelect} WHERE ${filter} ORDER BY ${selection.order} LIMIT ? OFFSET ?`).all(...params, limit, offset).map(row => this.cosmetic(row));\n  }\n  mine(id) { return this.db.prepare(`${paintSelect} WHERE c.owner_id=? ORDER BY c.created_at DESC LIMIT 100`).all(id).map(row => this.cosmetic(row)); }\n  submit(id, name, description, recipe, listed = true) {\n    publishingFlag(listed);\n    const count = this.db.prepare(\"SELECT COUNT(*) AS n FROM cosmetics WHERE owner_id=? AND status='pending'\").get(id).n;\n    assert(count < 10, 429, 'You can have at most 10 pending submissions.');\n    const cosmeticId = randomUUID();\n    this.db.prepare('INSERT INTO cosmetics (id,owner_id,name,description,recipe,created_at,listed) VALUES (?,?,?,?,?,?,?)').run(cosmeticId, id, name, description, JSON.stringify(recipe), now(), Number(listed));\n    return this.getCosmetic(cosmeticId);\n  }\n  setPublication(userId, id, listed) {\n    publishingFlag(listed);\n    return this.transaction(() => {\n      const cosmetic = this.getCosmetic(id);\n      assert(cosmetic, 404, 'Flare not found.');\n      assert(cosmetic.owner_id === userId, 403, 'Only the creator can change publication.');\n      this.db.prepare('UPDATE cosmetics SET listed=? WHERE id=?').run(Number(listed), id);\n      this.audit(userId, 'flare.publication', id, listed ? 'public' : 'unlisted');\n      return this.getCosmetic(id);\n    });\n  }\n  audit(actor, action, target, detail = '') {\n    this.db.prepare('INSERT INTO audit (actor_id,action,target,detail,created_at) VALUES (?,?,?,?,?)').run(actor, action, target, detail, now());\n  }\n  review(actor, id, status, reason) {\n    return this.transaction(() => {\n      const cosmetic = this.getCosmetic(id);\n      assert(cosmetic, 404, 'Cosmetic not found.');\n      assert(cosmetic.owner_id !== actor.id, 403, 'You cannot review your own submission.');\n      assert(cosmetic.status === 'pending' || (status === 'rejected' && cosmetic.status === 'approved'), 409, 'This submission has already been reviewed.');\n      this.db.prepare('UPDATE cosmetics SET status=?, reason=?,reviewed_at=?,reviewer_id=? WHERE id=?').run(status, reason, now(), actor.id, id);\n      if (status === 'rejected') this.db.prepare('UPDATE users SET equipped=NULL WHERE equipped=?').run(id);\n      this.audit(actor.id, status === 'approved' ? 'cosmetic.approved' : 'cosmetic.rejected', id, reason);\n      return this.getCosmetic(id);\n    });\n  }\n  equip(userId, cosmeticId) {\n    if (cosmeticId !== null) assert(this.getCosmetic(cosmeticId)?.status === 'approved', 400, 'Only approved cosmetics can be equipped.');\n    this.db.prepare('UPDATE users SET equipped=? WHERE id=?').run(cosmeticId, userId);\n  }\n  publicCosmetic(c) {\n    if (!c || c.status !== 'approved') return null;\n    return { id: c.id, name: c.name, description: c.description, creator: c.creator, creator_avatar: c.creator_avatar, creator_provider: c.creator_provider, listed: Boolean(c.listed), equipped_count: c.equipped_count || 0, recipe: c.recipe, updated_at: c.reviewed_at || c.created_at };\n  }\n  resolve(provider, providerId) {\n    const identity = this.db.prepare('SELECT i.provider, i.provider_id, i.login, i.display_name, u.equipped FROM identities i JOIN users u ON u.id=i.user_id WHERE i.provider=? AND i.provider_id=?').get(provider, providerId);\n    if (!identity) return null;\n    return { provider: identity.provider, provider_id: identity.provider_id, login: identity.login, display_name: identity.display_name, cosmetic: this.publicCosmetic(this.getCosmetic(identity.equipped || '')) };\n  }\n  moderators() { return this.db.prepare(\"SELECT u.id,u.name,u.role,u.avatar_url,u.profile_provider,u.created_at,i.provider_id AS twitch_id,i.login FROM users u JOIN identities i ON u.id=i.user_id AND i.provider='twitch' WHERE u.role IN ('moderator','owner')\").all(); }\n  setModerator(actor, twitchId, enabled) {\n    return this.transaction(() => {\n      const row = this.db.prepare(\"SELECT u.* FROM identities i JOIN users u ON i.user_id=u.id WHERE i.provider='twitch' AND i.provider_id=?\").get(twitchId);\n      assert(row, 404, 'That Twitch account must sign in to Nameflare first. Use their numeric Twitch user ID.');\n      assert(row.role !== 'owner', 403, 'The owner role cannot be changed here.');\n      this.db.prepare('UPDATE users SET role=? WHERE id=?').run(enabled ? 'moderator' : 'user', row.id);\n      this.audit(actor.id, enabled ? 'moderator.added' : 'moderator.removed', row.id, twitchId);\n    });\n  }\n  auditLog() { return this.db.prepare('SELECT a.*,u.name AS actor FROM audit a JOIN users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 100').all(); }\n  stats() { return { public: this.db.prepare(\"SELECT COUNT(*) AS n FROM cosmetics WHERE status='approved' AND listed=1\").get().n, approved: this.db.prepare(\"SELECT COUNT(*) AS n FROM cosmetics WHERE status='approved'\").get().n, creators: this.db.prepare(\"SELECT COUNT(DISTINCT owner_id) AS n FROM cosmetics WHERE status='approved'\").get().n, pending: this.db.prepare(\"SELECT COUNT(*) AS n FROM cosmetics WHERE status='pending'\").get().n, rejected: this.db.prepare(\"SELECT COUNT(*) AS n FROM cosmetics WHERE status='rejected'\").get().n }; }\n  cleanup() { this.db.prepare('DELETE FROM sessions WHERE expires<?').run(Date.now()); this.db.prepare('DELETE FROM oauth_states WHERE expires<?').run(Date.now()); }\n  seedDemo() {\n    if (this.user('demo-owner')) return;\n    this.transaction(() => {\n      for (const [id,name,role,twitch] of [['demo-owner','orbit','owner','1001'],['demo-creator','lumi','user','1002'],['demo-mod','nova','moderator','1003']]) {\n        this.db.prepare('INSERT INTO users (id,name,role,created_at) VALUES (?,?,?,?)').run(id,name,role,now());\n        this.db.prepare('INSERT INTO identities (provider,provider_id,user_id,login,display_name) VALUES (?,?,?,?,?)').run('twitch',twitch,id,name,name);\n        this.db.prepare(\"UPDATE users SET profile_provider='twitch' WHERE id=?\").run(id);\n      }\n      const presets = [\n        ['Aurora drift',['#71f5bd','#82b8ff','#b798ff'],'flow',4,120,5],\n        ['Solar flare',['#ffcc76','#ff735c','#f95d9b'],'flow',3,45,6],\n        ['Ultraviolet',['#c39bff','#965dff','#f2a4f9'],'shimmer',5,90,8],\n        ['Ocean glass',['#84f2f2','#4c9bff','#bbdcff'],'flow',6,160,3],\n        ['Cherry blossom',['#ffd6e9','#ff83bd','#e5a9ff'],'pulse',4,65,6],\n        ['Liquid gold',['#fff2b7','#e4ab4a','#fff9de'],'shimmer',7,100,4],\n        ['Acid rain',['#dbff85','#60f59c','#a4e54c'],'flow',3,135,5],\n        ['Midnight signal',['#829dff','#dd94ff','#738bfa'],'pulse',5,90,7]\n      ];\n      presets.forEach(([name,colors,animation,speed,angle,glow],i) => {\n        this.db.prepare('INSERT INTO cosmetics (id,owner_id,name,description,recipe,status,created_at,reviewed_at,reviewer_id) VALUES (?,?,?,?,?,?,?,?,?)').run(`demo-${i}`, i%2 ? 'demo-mod':'demo-creator', name, 'A sample cosmetic for the isolated demo.',JSON.stringify({colors,animation,speed,angle,glow}),'approved',now(),now(),'demo-owner');\n      });\n      this.equip('demo-creator','demo-0');\n      this.submit('demo-creator','Starlight circuit','Soft violet with a slow blue shimmer.',{colors:['#8de7ff','#b68bff','#ffe4fa'],animation:'shimmer',speed:5,angle:120,glow:7});\n    });\n  }\n  close() { this.db.close(); }\n}\n","lib/oauth.js":"import { createHash, randomBytes } from 'node:crypto';\nimport { assert, HttpError } from './cosmetics.js';\nconst token = () => randomBytes(32).toString('base64url');\n\n// Provider-controlled image hosts only; never fetch arbitrary avatar URLs on the server.\nconst avatarHosts = {\n  twitch: ['static-cdn.jtvnw.net'],\n  kick: ['files.kick.com', 'kick.com', 'www.kick.com', 'pfp.kick.com'],\n  youtube: ['yt3.ggpht.com', 'yt3.googleusercontent.com', 'lh3.googleusercontent.com']\n};\nexport const avatarSources = [...new Set(Object.values(avatarHosts).flat())].map(host => `https://${host}`).join(' ');\nexport function safeAvatar(provider, value) {\n  try {\n    if (typeof value !== 'string' || value.length > 2048) return '';\n    const url = new URL(value);\n    return url.protocol === 'https:' && !url.username && !url.password && !url.port && avatarHosts[provider]?.includes(url.hostname) ? url.toString() : '';\n  } catch { return ''; }\n}\n\nconst providers = {\n  twitch: { authorize: 'https://id.twitch.tv/oauth2/authorize', token: 'https://id.twitch.tv/oauth2/token', scope: '' },\n  kick: { authorize: 'https://id.kick.com/oauth/authorize', token: 'https://id.kick.com/oauth/token', scope: 'user:read' },\n  youtube: { authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', scope: 'https://www.googleapis.com/auth/youtube.readonly' }\n};\nexport function providerConfig(name, env = process.env) {\n  const info = providers[name];\n  assert(info, 404, 'Unknown identity provider.');\n  const prefix = name.toUpperCase();\n  return { ...info, clientId: env[`${prefix}_CLIENT_ID`], clientSecret: env[`${prefix}_CLIENT_SECRET`] };\n}\nexport function beginOAuth(name, store, session, baseUrl, env = process.env) {\n  const config = providerConfig(name, env);\n  assert(config.clientId && config.clientSecret, 503, `${name} linking needs OAuth credentials from the service operator.`);\n  const binding = token(), verifier = token();\n  const state = store.state(name, session?.id, binding, verifier);\n  return { url:authorizationURL(name,baseUrl,env,state,verifier), binding };\n}\nexport function authorizationURL(name,baseUrl,env,state,verifier) {\n  const config=providerConfig(name,env);\n  const url = new URL(config.authorize);\n  url.search = new URLSearchParams({ client_id: config.clientId, redirect_uri: `${baseUrl}/auth/${name}/callback`, response_type: 'code', scope: config.scope, state }).toString();\n  if (name !== 'twitch') {\n    url.searchParams.set('code_challenge', createHash('sha256').update(verifier).digest('base64url'));\n    url.searchParams.set('code_challenge_method', 'S256');\n  }\n  if (name === 'youtube') url.searchParams.set('prompt', 'select_account');\n  return url.toString();\n}\nasync function request(url, options) {\n  const response = await fetch(url, { ...options, signal: AbortSignal.timeout(12000) });\n  if (!response.ok) throw new HttpError(502, 'The identity provider could not verify this account. Please try again.');\n  return response.json();\n}\nexport async function finishOAuth(name, code, verifier, baseUrl, env = process.env) {\n  assert(typeof code === 'string' && code.length > 0 && code.length <= 4096, 400, 'Missing authorization code.');\n  const config = providerConfig(name, env);\n  const body = new URLSearchParams({ client_id: config.clientId, client_secret: config.clientSecret, code, grant_type: 'authorization_code', redirect_uri: `${baseUrl}/auth/${name}/callback` });\n  if (name !== 'twitch') body.set('code_verifier', verifier);\n  const credentials = await request(config.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });\n  assert(typeof credentials.access_token === 'string', 502, 'Provider did not return an access token.');\n  const headers = { Authorization: `Bearer ${credentials.access_token}` };\n  let profile;\n  if (name === 'twitch') {\n    headers['Client-Id'] = config.clientId;\n    const result = await request('https://api.twitch.tv/helix/users', { headers });\n    const user = result.data?.[0];\n    profile = user && { provider: name, id: String(user.id), login: user.login, name: user.display_name, avatar_url: safeAvatar(name, user.profile_image_url) };\n  } else if (name === 'kick') {\n    const result = await request('https://api.kick.com/public/v1/users', { headers });\n    const user = result.data?.[0];\n    profile = user && { provider: name, id: String(user.user_id), login: user.name, name: user.name, avatar_url: safeAvatar(name, user.profile_picture) };\n  } else {\n    const result = await request('https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true', { headers });\n    const channel = result.items?.[0];\n    assert(channel, 400, 'This Google account has no YouTube channel. Create or select a channel first.');\n    profile = { provider: name, id: channel.id, login: channel.snippet.customUrl || channel.snippet.title, name: channel.snippet.title, avatar_url: safeAvatar(name, channel.snippet.thumbnails?.medium?.url || channel.snippet.thumbnails?.default?.url) };\n  }\n  assert(profile && profile.id && profile.login && profile.name, 502, 'Provider returned an incomplete identity.');\n  // Tokens are used only for this identity check, never persisted or sent to the browser.\n  return profile;\n}\n","public/index.html":"<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"theme-color\" content=\"#08080c\">\n  <meta name=\"description\" content=\"Nameflare by B_X_N_E — create original Flares: animated names, GIF textures, and layered chat effects.\">\n  <title>Nameflare — by B_X_N_E</title><link rel=\"icon\" type=\"image/svg+xml\" href=\"/logo.svg\"><link rel=\"stylesheet\" href=\"/style.css\"><script type=\"module\" src=\"/app.js\"></script>\n</head>\n<body>\n<!-- Brand paths: Simple Icons (CC0); platform trademarks remain with their owners. -->\n<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"sr-only\" aria-hidden=\"true\" focusable=\"false\"><defs>\n<symbol id=\"logo-twitch\" viewBox=\"0 0 24 24\"><path d=\"M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z\"/></symbol>\n<symbol id=\"logo-kick\" viewBox=\"0 0 24 24\"><path d=\"M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z\"/></symbol>\n<symbol id=\"logo-youtube\" viewBox=\"0 0 24 24\"><path d=\"M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z\"/></symbol>\n</defs></svg>\n<a class=\"skip-link\" href=\"#main-content\">Skip to content</a>\n<aside class=\"sidebar\">\n  <a class=\"brand\" href=\"#discover\"><img class=\"brand-symbol\" src=\"/logo.svg\" width=\"36\" height=\"36\" alt=\"\"> NAMEFLARE<span class=\"brand-dot\">.</span></a>\n  <a class=\"owner-credit\" href=\"https://chat.bxne.dev\" target=\"_blank\" rel=\"noopener\">BY B_X_N_E <span>↗</span></a>\n  <div class=\"nav-label\">YOUR CREATIVE SPACE</div>\n  <nav aria-label=\"Main navigation\">\n    <a href=\"#discover\" data-nav=\"discover\"><span>◈</span> Discover</a>\n    <a href=\"#studio\" data-nav=\"studio\"><span>✧</span> Flare studio <span class=\"nav-new\">CREATE</span></a>\n    <a href=\"#collection\" data-nav=\"collection\"><span>▦</span> My collection</a>\n    <a href=\"#account\" data-nav=\"account\"><span>◎</span> My account</a>\n  </nav>\n  <div class=\"nav-label spaced\">BEYOND THE STUDIO</div>\n  <nav aria-label=\"Developer navigation\"><a href=\"#guide\" data-nav=\"guide\"><span>?</span> Guide</a><a href=\"#integrations\" data-nav=\"integrations\"><span>⌘</span> Integrations & API</a><a href=\"#moderation\" data-nav=\"moderation\" hidden><span>⛨</span> Moderation <span id=\"mod-badge\" class=\"nav-count\" hidden></span></a></nav>\n  <div class=\"sidebar-bottom\"><div class=\"open-card\"><span class=\"open-dot\"></span> Open code. Original names.<p>Built for the community.<br>Owned by B_X_N_E.</p><a href=\"https://chat.bxne.dev\" target=\"_blank\" rel=\"noopener\">Explore Banechat <span>↗</span></a></div><button id=\"sidebar-account\" class=\"account-button\"><span class=\"avatar\">N</span><span id=\"account-label\">Find your Nameflare<small>Sign in to get started</small></span><span>↗</span></button></div>\n</aside>\n<div class=\"app-shell\">\n  <header class=\"topbar\"><div><span class=\"breadcrumb\">Workspace</span><span class=\"crumb-slash\">/</span><span id=\"page-label\">Discover</span></div><div class=\"top-actions\"><span class=\"live-label\"><i></i> A B_X_N_E PROJECT</span><button id=\"signin-button\" class=\"button subtle\">Sign in / Create account ↗</button></div></header>\n  <div id=\"demo-banner\" class=\"demo-banner\" hidden>LOCAL DEMO · isolated sample data <button id=\"demo-switch\">Switch demo account ↗</button></div>\n  <main id=\"main-content\">\n    <section id=\"view-discover\" class=\"view\">\n      <div class=\"hero\"><div class=\"hero-copy\"><div class=\"eyebrow\"><span></span> YOUR NAME. YOUR SIGNAL.</div><h1>Your name.<br><span class=\"hero-gradient\">Turn up the heat.</span></h1><p>Layer color, motion, and a little chaos.<br>Your next signature starts here.</p><div class=\"hero-buttons\"><a href=\"#studio\" class=\"button primary\">✧ Open Flare studio ↗</a><a href=\"#guide\" class=\"button subtle\">How it works →</a></div><div class=\"platform-line\"><span>ONE NAME. EVERYWHERE.</span><b class=\"twitch-word\"><svg class=\"platform-logo\" aria-hidden=\"true\"><use href=\"#logo-twitch\"></use></svg> Twitch</b><b class=\"kick-word\"><svg class=\"platform-logo\" aria-hidden=\"true\"><use href=\"#logo-kick\"></use></svg> Kick</b><b class=\"youtube-word\"><svg class=\"platform-logo\" aria-hidden=\"true\"><use href=\"#logo-youtube\"></use></svg> YouTube</b></div></div>\n      <div class=\"hero-art\" aria-label=\"Animated Flare preview\"><div class=\"orbit orbit-one\"></div><div class=\"orbit orbit-two\"></div><span class=\"art-star star-one\">✳</span><span class=\"art-star star-two\">✧</span><div class=\"floating-tag\">✦ MADE TO STAND OUT</div><div class=\"chat-card\"><div class=\"chat-card-top\"><span class=\"tiny-avatar\">B</span><span>LIVE CHAT</span><i></i></div><div class=\"chat-message\"><span class=\"chat-badge\">♛</span> <strong id=\"hero-name\">B_X_N_E</strong><span class=\"chat-timestamp\">just now</span><p>this name has its own atmosphere.</p></div><div class=\"chat-card-bottom\"><span class=\"color-dots\"><i></i><i></i><i></i></span>Crimson signal <span>ANIMATED</span></div></div><div class=\"floating-bottom\"><span>↗</span> Not another ordinary username.</div></div></div>\n      <div class=\"section-heading\"><div><div class=\"eyebrow muted\">THE COMMUNITY COLLECTION</div><h2>Find your signature.</h2><p>Approved Flares from real creators. Find one, try your name, and equip it.</p></div><span class=\"count-pill\"><i></i> <span id=\"approved-count\">0</span> public Flares</span></div>\n      <div class=\"catalog-toolbar\"><div class=\"filters\" role=\"group\" aria-label=\"Flare filters\"><button data-filter=\"all\" class=\"active\">All Flares</button><button data-filter=\"animated\">✦ Animated</button><button data-filter=\"glow\">☀ With glow</button><button data-filter=\"texture\">▧ GIF & textures</button><button data-filter=\"depth\">▱ 3D depth</button><button data-filter=\"static\">Static</button></div><label class=\"search\">⌕ <input id=\"catalog-search\" type=\"search\" placeholder=\"Flare or creator…\" aria-label=\"Search Flares\"><span>/</span></label></div>\n      <div class=\"browse-options\"><label>Sort by<select id=\"catalog-sort\"><option value=\"newest\">Recently approved</option><option value=\"popular\">Most equipped</option><option value=\"name\">Name A–Z</option></select></label><label>Preview your name<input id=\"catalog-name\" maxlength=\"30\" placeholder=\"yourname\"></label><button id=\"catalog-pause\" class=\"button subtle\" aria-pressed=\"false\">Ⅱ Pause previews</button><button id=\"catalog-refresh\" class=\"button subtle\">↻ Refresh</button></div>\n      <div class=\"flow-strip\"><span><b>01</b> Discover a Flare</span><span><b>02</b> Try it on your name</span><span><b>03</b> Equip across linked accounts</span></div><p id=\"catalog-status\" class=\"form-footnote\" role=\"status\"></p>\n      <div id=\"catalog\" class=\"cosmetic-grid\" aria-live=\"polite\"></div><button id=\"load-more\" class=\"button subtle load-more\" hidden>Load more ↓</button>\n      <div class=\"community-strip\"><img class=\"strip-symbol brand-mark\" src=\"/logo.svg\" width=\"44\" height=\"44\" alt=\"\"><div><h3>The next favorite could be yours.</h3><p>Start with a preset. Drop in a GIF. Make it unmistakable.</p></div><a href=\"#studio\">Make something original ↗</a></div>\n      <footer><span>Owned by <a href=\"https://chat.bxne.dev\" target=\"_blank\" rel=\"noopener\">B_X_N_E ↗</a> · Independent of 7TV and streaming platforms.</span><span><a href=\"/source\">Source ↗</a> · <a href=\"/license\" target=\"_blank\" rel=\"noopener\">AGPL-3.0</a> · No warranty</span></footer>\n    </section>\n    <section id=\"view-studio\" class=\"view\" hidden>\n      <div class=\"page-heading studio-heading\"><div><div class=\"eyebrow\">THE NAMEFLARE PLAYGROUND</div><h1>Make a name for yourself<span class=\"accent\">.</span></h1><p>Start simple. Stack something wild. See every change live.</p></div><button type=\"button\" id=\"randomize-design\" class=\"button subtle\">✦ Surprise me</button></div>\n      <div class=\"studio-layout\">\n        <form id=\"studio-form\" class=\"panel editor-panel\" novalidate>\n          <div class=\"studio-tabs\" role=\"tablist\" aria-label=\"Paint editor sections\"><button type=\"button\" role=\"tab\" data-editor-tab=\"fill\" id=\"tab-fill\" aria-controls=\"editor-fill\" aria-selected=\"true\">01 / Paint</button><button type=\"button\" role=\"tab\" data-editor-tab=\"effects\" id=\"tab-effects\" aria-controls=\"editor-effects\" aria-selected=\"false\" tabindex=\"-1\">02 / Effects</button><button type=\"button\" role=\"tab\" data-editor-tab=\"publish\" id=\"tab-publish\" aria-controls=\"editor-publish\" aria-selected=\"false\" tabindex=\"-1\">03 / Finish</button></div>\n          <div id=\"editor-fill\" role=\"tabpanel\" aria-labelledby=\"tab-fill\" class=\"editor-section\">\n            <div class=\"panel-heading\"><h2>Find your starting point</h2><span>NO BLANK-CANVAS PANIC</span></div>\n            <label>Preset<select id=\"preset-select\"><option value=\"crimson\">Crimson signal</option><option value=\"aurora\">Aurora drift</option><option value=\"neon\">Neon arcade</option><option value=\"gold\">Golden dimension</option><option value=\"shadow\">After hours</option><option value=\"ice\">Ice chrome</option></select></label>\n            <div id=\"preset-gallery\" class=\"preset-gallery\" aria-label=\"Paint presets\"></div>\n            <div class=\"form-divider\"></div><div class=\"panel-heading\"><h2>Paint layers</h2><span>TOP LAYER FIRST</span></div>\n            <p class=\"form-footnote\">Mix gradients, solid color, and animated images. Adjust opacity to reveal the layers underneath.</p>\n            <div id=\"layer-list\" class=\"layer-list\"></div><button type=\"button\" id=\"add-layer\" class=\"button subtle full\">＋ Add a paint layer</button><p class=\"form-footnote\">New layers go underneath your paint. Reordering never removes files; an opaque top layer can hide those below.</p>\n            <details class=\"effect-group\"><summary>Fallback palette</summary><p class=\"form-footnote\">Used by older clients and when motion is disabled.</p><div class=\"color-inputs\"><label>Color 1<input type=\"color\" name=\"color1\" value=\"#ff1a35\"></label><label>Color 2<input type=\"color\" name=\"color2\" value=\"#ff7185\"></label><label>Color 3<input type=\"color\" name=\"color3\" value=\"#ffd4dc\"></label></div><label>Fallback angle <output id=\"angle-value\">120°</output><input type=\"range\" name=\"angle\" min=\"0\" max=\"360\" value=\"120\"></label></details>\n            <div class=\"form-row\"><label>Animation<select name=\"animation\"><option value=\"flow\">Gradient flow</option><option value=\"shimmer\">Shimmer</option><option value=\"pulse\">Soft pulse</option><option value=\"none\">Static</option></select></label><label>Duration <output id=\"speed-value\">4s</output><input type=\"range\" name=\"speed\" min=\"1\" max=\"20\" value=\"4\"></label></div>\n            <div id=\"texture-drop\" class=\"upload-zone\" tabindex=\"0\" role=\"button\" aria-label=\"Choose or drop a GIF, WebP, or PNG\"><span class=\"upload-icon\">▧</span><h3>Give your name some motion.</h3><p>Drop a GIF here, or choose a file.</p><span>GIF / WEBP / PNG · 2 MB · 2048 × 2048</span><input type=\"file\" id=\"texture-file\" accept=\"image/gif,image/webp,image/png,.gif,.webp,.png\" aria-label=\"Upload an animated name texture\"></div>\n            <div class=\"texture-preview-row\" id=\"texture-preview-row\" hidden><img id=\"texture-preview\" alt=\"Selected name texture\"><div><strong id=\"texture-filename\"></strong><p id=\"texture-status\">Preview only until you submit.</p></div><button type=\"button\" id=\"clear-texture\" class=\"text-button\">Remove</button></div><div id=\"texture-attachments\" class=\"draft-actions\" aria-label=\"Attached image files\"></div><p id=\"upload-feedback\" class=\"form-footnote\" role=\"status\">GIFs can be previewed without signing in. Uploads happen on submission.</p>\n            <button type=\"button\" data-next-tab=\"effects\" class=\"button primary full section-next\">Next: shape the effects →</button>\n          </div>\n          <div id=\"editor-effects\" role=\"tabpanel\" aria-labelledby=\"tab-effects\" class=\"editor-section\" hidden>\n            <div class=\"panel-heading\"><h2>The finishing touches</h2><span>LESS OR MORE. YOUR CALL.</span></div>\n            <div class=\"form-row\"><label>Glow strength <output id=\"glow-value\">5px</output><input type=\"range\" name=\"glow\" min=\"0\" max=\"20\" value=\"5\"></label><label>Glow color<input type=\"color\" name=\"glowColor\" value=\"#ff1a35\"></label></div>\n            <div class=\"form-row\"><label>Outline width <output id=\"outline-value\">0px</output><input type=\"range\" name=\"outline\" min=\"0\" max=\"2\" step=\"0.1\" value=\"0\"></label><label>Outline color<input type=\"color\" name=\"outlineColor\" value=\"#ffffff\"></label></div>\n            <label>Text weight <output id=\"fontWeight-value\">800</output><input type=\"range\" name=\"fontWeight\" min=\"400\" max=\"900\" step=\"100\" value=\"800\"></label>\n            <details class=\"effect-group\" open><summary>◒ Drop shadow</summary><div class=\"form-row\"><label>Shadow color<input type=\"color\" name=\"shadowColor\" value=\"#000000\"></label><label>Opacity <output id=\"shadowOpacity-value\">0%</output><input type=\"range\" name=\"shadowOpacity\" min=\"0\" max=\"100\" value=\"0\"></label></div><div class=\"form-row\"><label>Horizontal <output id=\"shadowX-value\">0px</output><input type=\"range\" name=\"shadowX\" min=\"-12\" max=\"12\" value=\"0\"></label><label>Vertical <output id=\"shadowY-value\">0px</output><input type=\"range\" name=\"shadowY\" min=\"-12\" max=\"12\" value=\"0\"></label></div><label>Blur <output id=\"shadowBlur-value\">0px</output><input type=\"range\" name=\"shadowBlur\" min=\"0\" max=\"20\" value=\"0\"></label></details>\n            <details class=\"effect-group\"><summary>✦ Multi-shadow stack</summary><p class=\"form-footnote\">Up to eight colored shadows for neon, halos, and complex Flares.</p><div id=\"shadow-list\"></div><button type=\"button\" id=\"add-shadow\" class=\"button subtle full\">＋ Add shadow</button></details>\n            <details class=\"effect-group\" open><summary>▱ 3D-style depth</summary><div class=\"form-row\"><label>Depth <output id=\"depth-value\">0 layers</output><input type=\"range\" name=\"depth\" min=\"0\" max=\"8\" value=\"0\"></label><label>Direction <output id=\"depthAngle-value\">45°</output><input type=\"range\" name=\"depthAngle\" min=\"0\" max=\"360\" value=\"45\"></label></div><label>Extrusion color<input type=\"color\" name=\"depthColor\" value=\"#4b1423\"></label><p class=\"form-footnote\">Layered text depth. No heavyweight 3D engine in your chat.</p></details>\n            <button type=\"button\" data-next-tab=\"publish\" class=\"button primary full section-next\">Next: name your creation →</button>\n          </div>\n          <div id=\"editor-publish\" role=\"tabpanel\" aria-labelledby=\"tab-publish\" class=\"editor-section\" hidden>\n            <div class=\"panel-heading\"><h2>Ready for the spotlight?</h2><span>REVIEWED, THEN RELEASED.</span></div>\n            <label>Flare name<input name=\"name\" required minlength=\"2\" maxlength=\"40\" placeholder=\"e.g. Crimson signal\"></label><label>Description<textarea name=\"description\" maxlength=\"400\" rows=\"3\" placeholder=\"What makes this one yours?\"></textarea></label>\n            <details class=\"effect-group\"><summary>Import & export</summary><p class=\"form-footnote\">Import a Nameflare design or 7TV paint JSON. Remote images require a local upload and artwork permission. Unsupported features are reported.</p><div class=\"draft-actions\"><button type=\"button\" id=\"export-design\" class=\"button subtle\">Export design</button><label class=\"button subtle import-label\">Import JSON<input type=\"file\" id=\"import-design\" accept=\"application/json,.json\" hidden></label></div><p id=\"import-status\" class=\"form-footnote\" role=\"status\"></p></details>\n            <label>After approval<select name=\"listed\"><option value=\"public\">Public · everyone can discover and equip</option><option value=\"unlisted\">Unlisted · anyone with the link can equip</option></select></label><p class=\"form-footnote\">Public Flares appear on the front page once approved. Unlisted is not private: the recipe and assets are public through its link and equipped identities. Change this later in My collection.</p>\n            <label class=\"checkbox-label\"><input type=\"checkbox\" name=\"rights\" required> I own or have permission to use this design and uploaded artwork.</label>\n            <button class=\"button primary full\" id=\"submit-cosmetic\" type=\"submit\">Submit for review ↗</button><p class=\"form-footnote\">Pending Flares stay private. No approval or asset rights are inherited from an import.</p>\n          </div>\n          <div class=\"editor-utility\"><div class=\"draft-actions\"><button type=\"button\" class=\"button subtle\" id=\"undo-design\" disabled>↶ Undo</button><button type=\"button\" class=\"button subtle\" id=\"redo-design\" disabled>↷ Redo</button><button type=\"button\" class=\"button subtle\" id=\"save-draft\">Save draft</button></div><p id=\"draft-status\" class=\"form-footnote\">Local drafts keep your recipe; selected image files need to be reattached after reload.</p></div>\n        </form>\n        <div class=\"preview-column\"><div class=\"panel preview-panel\"><div class=\"panel-heading\"><h2>Your live canvas</h2><span class=\"live-chip\">● LIVE PREVIEW</span></div><label class=\"preview-name-input\">Try a name<input id=\"preview-username\" value=\"B_X_N_E\" maxlength=\"30\"></label><div class=\"preview-controls\"><label>Backdrop<select id=\"preview-background\"><option value=\"dark\">Dark chat</option><option value=\"light\">Light chat</option><option value=\"transparent\">Transparent</option></select></label><label>Chat font size <output id=\"preview-chat-size-value\">18px</output><input id=\"preview-chat-size\" type=\"range\" min=\"12\" max=\"32\" value=\"18\"></label><label>Inspection zoom <output id=\"preview-size-value\">3×</output><input id=\"preview-size\" type=\"range\" min=\"1\" max=\"4\" step=\"0.25\" value=\"3\"></label></div><div class=\"studio-name-stage\" id=\"preview-stage\"><strong id=\"studio-name\">B_X_N_E</strong><span id=\"preview-caption\">CHAT RENDER · MAGNIFIED, NOT RE-STYLED</span></div><div class=\"preview-actions\"><button type=\"button\" id=\"pause-preview\" class=\"button subtle\" aria-pressed=\"false\">Ⅱ Pause motion</button><span id=\"layer-count\">1 layer · live</span></div><div class=\"mini-chat\"><div class=\"mini-chat-title\">ACTUAL CHAT SIZE · SAME RENDERER AS OBS</div><p><b>streambot</b> welcome to the chat ♡</p><p><strong id=\"studio-chat-name\">B_X_N_E</strong> this one feels like me</p><p><b>moonchild</b> that Flare goes hard</p></div></div><div class=\"info-card\"><span>⛨</span><h3>Original style. Shared trust.</h3><p>Moderators check rights, harmful content, and flashing effects before your Flare reaches the official API.</p><a href=\"https://chat.bxne.dev\" target=\"_blank\" rel=\"noopener\">Explore B_X_N_E’s Banechat ↗</a></div></div>\n      </div>\n    </section>\n    <section id=\"view-guide\" class=\"view\" hidden>\n      <div class=\"page-heading\"><div class=\"eyebrow\">NAMEFLARE HANDBOOK</div><h1>From first Flare to live chat.</h1><p>A practical guide to creating, publishing, and using Flares. Nameflare is owned by B_X_N_E; it is independent of 7TV and the streaming platforms.</p></div>\n      <div class=\"guide-layout\"><nav class=\"guide-index\" aria-label=\"Guide contents\"><a href=\"#guide/start\">Getting started</a><a href=\"#guide/paint\">Layers & GIFs</a><a href=\"#guide/effects\">Preview & effects</a><a href=\"#guide/accounts\">Accounts & OAuth</a><a href=\"#guide/review\">Review & moderation</a><a href=\"#guide/obs\">OBS setup</a><a href=\"#guide/api\">API & adapters</a><a href=\"#guide/hosting\">Cloudflare hosting</a><a href=\"#guide/troubleshooting\">Troubleshooting</a></nav><div class=\"guide-content\">\n      <article id=\"guide-start\" class=\"panel\"><h2>1. Create your first Flare</h2><ol><li>Open <a href=\"#studio\">Flare studio</a> and choose a preset. You can edit and preview without signing in.</li><li>Set a preview name. Add colors, gradients, or an image you own.</li><li>Use Effects to adjust glow, outline, weight, shadows, and depth. Check the actual chat-size preview, not just the zoomed view.</li><li>Open Finish, name the Flare, confirm artwork rights, and submit while signed in.</li><li>Track the decision in <a href=\"#collection\">My collection</a>. After approval, equip it there or in Discover.</li></ol><p>Approval makes a Flare shareable; public listing puts it in Discover. Unlisted Flares remain accessible to anyone with their link. You can change listing in My collection; equipping assigns it to your linked identities. Neither step modifies your Twitch, Kick, or YouTube display name.</p></article>\n      <article id=\"guide-paint\" class=\"panel\"><h2>2. Layers, gradients, and GIFs</h2><p>Use up to six layers. The first layer is on top. New layers are added underneath. A fully opaque solid or image layer can cover everything below it; lower its opacity or move it down to blend.</p><ul><li><strong>Linear:</strong> color along an angle. <strong>Radial:</strong> color outward from a center. <strong>Conic:</strong> color around a center. <strong>Solid:</strong> a single RGBA color.</li><li>Each gradient accepts 2–12 stops. Set positions from 0–100%, and use #RRGGBBAA or the alpha slider for transparency.</li><li>Scale changes the fill canvas, not the name's font size. Position aligns that canvas. Gradient repeat and canvas tiling are separate controls.</li><li><strong>Image:</strong> choose a GIF, WebP, or PNG up to 2 MB with a canvas no larger than 2048 × 2048. Preview is local; upload happens when you submit.</li></ul><p>Attached-file buttons show images across all layers. Moving, duplicating, or temporarily changing a layer's type keeps its attached image in this editing session. Switch back to Image to restore it. Removing a layer removes it from the recipe; Undo restores it. Image animation is independent of the gradient animation selector.</p><p>Save draft and Export JSON preserve recipe settings, not local image bytes. Reattach files after a reload or import. Presets replace the design; use Undo if you changed one accidentally. A 7TV import converts supported settings only: remote images must be reattached, and entitlements, flairs, and artwork rights do not transfer.</p></article>\n      <article id=\"guide-effects\" class=\"panel\"><h2>3. Preview at the size your chat uses</h2><p>Set Chat font size to your overlay's font size (the built-in OBS overlay uses 18px). Inspection zoom magnifies that same render, including its shadows and outline, rather than making a different large-font paint. The lower preview is shown at actual size.</p><p>Glow, outline, and shadow offsets are pixel values. Increasing them can overwhelm small text. Check readability on both light and dark backdrops. A different client font, letter spacing, or font size can still change the result; integrations must match the renderer and typography.</p><p>Up to eight extra shadows can be stacked. Depth is a lightweight text extrusion, not a 3D scene. Pause motion stops CSS animation and replaces animated image fills with the fallback palette; it does not freeze a GIF frame. Reduced-motion visitors get static fallback fills automatically.</p></article>\n      <article id=\"guide-accounts\" class=\"panel\"><h2>4. Sign in and link accounts</h2><p>Anyone can create an account: the first sign-in with an enabled provider automatically registers a regular user, with no invitation or separate password. Returning users sign in through the same provider. Then link other platforms from <a href=\"#account\">My account</a> while still signed in. Twitch and Kick use numeric account IDs; YouTube uses a channel ID. Renaming a platform account does not change the identity key.</p><p>OAuth verifies identity on the provider's own page. Nameflare does not ask for passwords and does not retain provider access tokens. Kick requests user:read; YouTube requests read-only channel access, so a YouTube channel is required. Twitch does not request chat-posting permissions.</p><p>Your verified platform name and avatar are copied to Nameflare. Choose a connected platform as your profile source in My account, and sign in through that platform again to refresh it. Unsupported or missing profile images use an initial instead.</p><p>One identity per provider can be linked to an account. An identity already linked elsewhere cannot be silently merged. Sign out to access that account. Unlinking, merging, account deletion, and data export are not implemented yet. Local demo accounts are isolated samples, not real OAuth logins.</p><p>If a provider says “setup required,” the operator has not supplied its client credentials. Register exact callbacks under the canonical HTTPS origin: <code>/auth/twitch/callback\n/auth/kick/callback\n/auth/youtube/callback</code></p><details><summary>Operator: enable Kick sign-in</summary><ol><li>Open <a href=\"https://dev.kick.com/\" target=\"_blank\" rel=\"noopener\">Kick Developers</a>, then account settings → Developer and create an application.</li><li>Register <code>https://nameflare.bxne.dev/auth/kick/callback</code> as the redirect URL for this host. If using another origin, substitute its exact HTTPS origin. Permit <code>user:read</code> if the dashboard asks for scopes; no chat-write permission is needed.</li><li>Privately set KICK_CLIENT_ID and KICK_CLIENT_SECRET on the server, then restart the Nameflare service. Kick authorization uses PKCE automatically.</li></ol></details><details><summary>Operator: enable YouTube sign-in</summary><ol><li>Create/select a project in <a href=\"https://console.cloud.google.com/\" target=\"_blank\" rel=\"noopener\">Google Cloud Console</a>. Enable YouTube Data API v3.</li><li>Configure Google Auth Platform → Branding and Audience. Choose External for public users and add test users while the app is in Testing.</li><li>Under Data Access add <code>https://www.googleapis.com/auth/youtube.readonly</code>. Under Clients create a Web application OAuth client with redirect URI <code>https://nameflare.bxne.dev/auth/youtube/callback</code>.</li><li>Privately set YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET and restart Nameflare. An API key or service account is not a substitute.</li><li>Test with a Google account that owns a YouTube channel. Complete Google's required publication/verification before promising unrestricted public YouTube sign-up; Testing only permits approved test users.</li></ol></details><p>Keep all secrets outside source and screenshots. Restart only after credentials are saved. OAuth linking does not add Kick/YouTube live-chat connectors.</p></article>\n      <article id=\"guide-review\" class=\"panel\"><h2>5. Publication and moderation</h2><p>Pending designs and their images are private to the creator and reviewers. Reviewers check artwork permission, harmful content, readability, and flashing motion. A reviewer cannot approve their own submission. Rejection includes feedback; edit a copy to make a new submission.</p><p>Approved public Flares appear in Discover and the public API catalog. Approved unlisted Flares can be viewed and equipped through their shared link; their recipes and assets are not private. Revocation removes them and clears equipped assignments. Clients must revalidate within the 15-second cache window. Editing a copy does not inherit approval.</p><p>The owner role is bootstrapped by a configured numeric Twitch ID—not a username or the first signup. Only the owner can assign moderators from the accounts table or by numeric Twitch ID after that account signs in. The moderation board filters pending, approved, and disapproved Flares and shows full recipes, creator identities, submission/review times, reviewer names, and decision notes. Nameflare reviewer roles are separate from a channel's Twitch moderator status. Decisions and role changes are audited.</p></article>\n      <article id=\"guide-obs\" class=\"panel\"><h2>6. Add chat to OBS</h2><ol><li>Open <a href=\"#integrations\">Integrations & API</a>, enter a public Twitch channel login, and copy the OBS source URL.</li><li>In OBS, add a Browser Source, paste that URL, and choose your canvas dimensions (for example 700 × 900).</li><li>The overlay joins public Twitch chat anonymously. No streamer password or OAuth token belongs in the source URL.</li><li>Send a chat message from an account with an approved, equipped Flare. The overlay resolves its immutable Twitch user ID.</li></ol><p><a href=\"/overlay?demo=1\" target=\"_blank\" rel=\"noopener\">Sample overlay</a> uses labeled test messages. The direct Twitch overlay handles reconnects and message deletion. Kick and YouTube need authorized server-side chat connectors; these are not supplied. The Node host has a private relay, but the Cloudflare port does not yet implement relay streaming. Keep relay ingest tokens server-side and room URLs private.</p><p>Banechat's existing site is a separate project. Direct Nameflare integration and its redesign require that project's source; links alone do not install Flares into it. Stock Chatterino also requires a native adapter, which is not shipped.</p></article>\n      <article id=\"guide-api\" class=\"panel\"><h2>7. Integrate the public API</h2><p>Approved reads require no key and allow CORS. Discover the canonical API at <a href=\"/api/config\" target=\"_blank\" rel=\"noopener\">/api/config</a>. Do not key Flares by display names, and never render imported recipe data as HTML or arbitrary CSS.</p><pre>GET /api/v1/cosmetics?limit=60&amp;offset=0\nGET /api/v1/cosmetics/:id\nGET /api/v1/users/twitch/:numeric_id\nGET /api/v1/users/kick/:numeric_id\nGET /api/v1/users/youtube/:channel_id\nPOST /api/v1/resolve\n{\"provider\":\"twitch\",\"ids\":[\"123\",\"456\"]}</pre><p>Batch resolve accepts at most 100 string IDs. Unknown identities are omitted from the batch result; a single unknown identity returns 404. A linked user without an equipped paint has cosmetic: null. Fall back to the platform's normal name when resolution fails.</p><p>Browser clients can use <a href=\"/renderer.js\">the shared renderer</a> and <a href=\"/effects.css\">effect stylesheet</a>. Apply a returned cosmetic to a text-only name element and pass the canonical service origin for textures. Keep authoritative text intact, respect reduced motion, and revalidate instead of retaining a revoked paint indefinitely. Native clients need their own safe implementation of the bounded recipe format.</p><p>Never put a session cookie, OAuth secret, CSRF token, or relay ingest token in public client code. Private creator/moderation writes require a signed-in same-origin session and CSRF token.</p></article>\n      <article id=\"guide-hosting\" class=\"panel\"><h2>8. Operator setup: Cloudflare and OAuth</h2><p>The Cloudflare target uses Workers for API/OAuth, D1 for accounts and recipes, R2 for private image objects, and Workers assets for this site. The local Node/SQLite app remains available for development. Configuration alone is not a public deployment.</p><ol><li>Choose the Cloudflare account and canonical HTTPS hostname you control. Create a dedicated D1 database and private R2 bucket; never expose the bucket directly.</li><li>Apply the database migration and configure the resource bindings, canonical origin, numeric owner Twitch ID, and actual public source repository.</li><li>Register Twitch, Kick, and Google OAuth apps. Enable YouTube Data API v3, configure the consent screen/test users, and complete any verification required by Google.</li><li>Enter client secrets using Cloudflare's secret store. Never put them in this site, a repository, or a screenshot.</li><li>Deploy only after reviewing costs and domain ownership. Test new login, account linking, cancellation, expired state, owner bootstrap, moderation, and revoked textures on the live HTTPS origin.</li></ol><p>The deployment runbook is in the <a href=\"/source\">corresponding source bundle</a>. Public launch still needs upload decoding/frame limits, abuse reporting, privacy procedures, backups, and monitoring. Header validation is not malware scanning. No live login or internet deployment is implied by local test results.</p></article>\n      <article id=\"guide-troubleshooting\" class=\"panel\"><h2>9. Troubleshooting</h2><details open><summary>My GIF disappeared</summary><p>Check attached-file buttons. Select its layer, switch the type back to Image, and check opacity/order. An opaque upper layer can hide it without deleting the file. After a reload or JSON import, reattach the original file. Pause or reduced-motion mode intentionally uses a static gradient fallback.</p></details><details><summary>My preview looks different in chat</summary><p>Match the chat font size and font family. Inspection zoom is not the export size. Check that the client supports all recipe fields and has loaded the shared effect stylesheet. Missing/pending images are private; only approved assets are public.</p></details><details><summary>My equipped Flare does not appear</summary><p>Verify the correct platform account is linked, the Flare is approved and equipped, and the integration uses the official API and immutable IDs. Wait up to 15 seconds for revalidation. Banechat and stock Chatterino do not automatically gain support merely because a Flare is equipped.</p></details><details><summary>OAuth returns an error</summary><p>Check the exact callback hostname/path and client credentials. Restart after cancellation, expiry, switching accounts during login, or clearing cookies. YouTube needs a channel and any required test-user access. Do not share callback URLs containing authorization codes.</p></details><details><summary>Submission was blocked</summary><p>Sign in, confirm artwork rights, fill every image slot, and fix invalid numeric/hex fields. Limits are 10 pending submissions, 50 uploaded textures per account, six layers, eight extra shadows, and 12 gradient stops per layer.</p></details></article>\n      </div></div>\n    </section>\n    <section id=\"view-collection\" class=\"view\" hidden><div class=\"page-heading\"><div class=\"eyebrow\">MADE BY YOU</div><h1>My collection<span class=\"accent\">.</span></h1><p>Your submissions, review feedback, and publishing controls. Everyone can equip your approved Flares.</p></div><div class=\"collection-toolbar\"><div class=\"filters\" role=\"group\" aria-label=\"Collection status\"><button data-collection-filter=\"all\" class=\"active\">All submissions</button><button data-collection-filter=\"approved\">Approved</button><button data-collection-filter=\"pending\">In review</button><button data-collection-filter=\"rejected\">Needs changes</button></div><a href=\"#studio\" class=\"button primary\">＋ Create a Flare</a></div><p id=\"collection-status\" class=\"form-footnote\" role=\"status\"></p><div id=\"my-collection\" class=\"cosmetic-grid\"></div></section>\n    <section id=\"view-account\" class=\"view\" hidden><div class=\"page-heading\"><div class=\"eyebrow\">ONE IDENTITY, EVERY CHAT</div><h1>My account<span class=\"accent\">.</span></h1><p>Your profile, connected platforms, and Flares—all in one account.</p></div><div id=\"account-content\"></div></section>\n    <section id=\"view-moderation\" class=\"view\" hidden><div class=\"page-heading\"><div class=\"eyebrow\">KEEP THE COMMUNITY BRIGHT</div><h1>Moderation board<span class=\"accent\">.</span></h1><p>Review pending Flares, inspect past decisions, and manage your moderation team.</p></div><div id=\"moderation-content\"></div></section>\n    <section id=\"view-integrations\" class=\"view\" hidden><div class=\"page-heading\"><div class=\"eyebrow\">OPEN CODE. ONE SHARED HOME.</div><h1>Made to connect<span class=\"accent\">.</span></h1><p>One official API. Open-source software. Owned by B_X_N_E.</p></div><div class=\"integration-grid\">\n      <article class=\"panel\"><div class=\"integration-icon\">{ }</div><h2>The official API</h2><p>Public reads need no key. Approved designs only; resolve identities with immutable platform IDs.</p><code id=\"api-base\"></code><pre>GET /cosmetics\nGET /cosmetics/:id\nGET /users/twitch/:user_id\nGET /users/kick/:user_id\nGET /users/youtube/:channel_id\nPOST /resolve</pre><a class=\"button subtle\" href=\"/api/v1/cosmetics\" target=\"_blank\" rel=\"noopener\">Explore live JSON ↗</a></article>\n      <article class=\"panel\"><div class=\"integration-icon\"><svg class=\"platform-logo\" aria-hidden=\"true\"><use href=\"#logo-twitch\"></use></svg></div><h2>OBS & browser overlays</h2><p>Connect a public Twitch channel, or relay authorized Kick and YouTube messages from your server.</p><label>Twitch channel<input id=\"overlay-channel\" placeholder=\"yourchannel\" pattern=\"[a-zA-Z0-9_]+\"></label><button id=\"overlay-copy\" class=\"button primary\">Copy OBS source URL ↗</button><a href=\"/overlay?demo=1\" class=\"text-button\" target=\"_blank\" rel=\"noopener\">Preview sample messages →</a></article>\n      <article class=\"panel\"><div class=\"integration-icon\">⌘</div><h2>Flares & client compatibility</h2><p>Layered linear, radial, and conic gradients. Positioned RGBA stops, GIF fills, opacity, and multiple shadows. 7TV JSON can be converted into a new reviewable Nameflare design.</p><div class=\"status-note\">Stock Chatterino needs an adapter · not shipped</div><p>Native clients must implement this recipe format; importing a paint does not import 7TV entitlements or grant artwork rights.</p></article>\n      <article class=\"panel\"><div class=\"integration-icon\">✳</div><h2>A B_X_N_E project</h2><p>Owned and operated by <strong>B_X_N_E</strong>. Visit <a href=\"https://chat.bxne.dev\" target=\"_blank\" rel=\"noopener\">Banechat ↗</a>, the companion overlay studio. Its direct Nameflare integration is not shipped yet.</p><p>AGPL-3.0 licensed: inspect, contribute, and self-host. Secrets and moderation permissions stay private.</p><div class=\"status-note\">Public deployment & repository publishing pending</div><a href=\"/source\" class=\"button subtle\">Get the source ↗</a><p>Copyright © 2026 Nameflare contributors. No warranty. Redistribution permitted under the <a href=\"/license\" target=\"_blank\" rel=\"noopener\">AGPL-3.0 license</a>.</p></article>\n    </div></section>\n  </main>\n</div>\n<dialog id=\"signin-dialog\"><button class=\"dialog-close\" id=\"close-signin\" aria-label=\"Close sign in\">×</button><img class=\"dialog-symbol brand-mark\" src=\"/logo.svg\" width=\"56\" height=\"56\" alt=\"\"><h2 id=\"signin-title\">Create an account or sign in.</h2><p id=\"signin-description\">Your first provider sign-in creates a free account. No separate password or invitation.</p><div id=\"signin-options\"></div><small>Identity verification only. YouTube linking requests read-only channel access.</small></dialog>\n<dialog id=\"detail-dialog\"><button class=\"dialog-close\" id=\"close-detail\" aria-label=\"Close Flare details\">×</button><div id=\"detail-content\"></div></dialog>\n<div id=\"toast\" class=\"toast\" role=\"status\" hidden></div>\n</body></html>\n","public/logo.svg":"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" role=\"img\" aria-labelledby=\"title description\">\n  <title id=\"title\">Nameflare</title>\n  <desc id=\"description\">A crimson signal with an angular N and a bright flare spark.</desc>\n  <defs>\n    <linearGradient id=\"signal\" x1=\"8\" y1=\"56\" x2=\"56\" y2=\"8\" gradientUnits=\"userSpaceOnUse\">\n      <stop stop-color=\"#ff1a35\"/><stop offset=\"1\" stop-color=\"#ff7185\"/>\n    </linearGradient>\n  </defs>\n  <path fill=\"url(#signal)\" fill-rule=\"evenodd\" d=\"M18 8h28l10 10v28L46 56H18L8 46V18Zm1 38h8V31l10 15h8V18h-8v15L27 18h-8Z\"/>\n  <path fill=\"#ffd4dc\" d=\"m54 0 2.5 7.5L64 10l-7.5 2.5L54 20l-2.5-7.5L44 10l7.5-2.5Z\"/>\n</svg>\n","public/style.css":"@import url('/effects.css');\n:root {\n  color-scheme: dark;\n  --bg: #08080c; --panel: #111218; --raised: #191a23; --input: #090a0f;\n  --line: #282932; --muted: #a6a7b5; --text: #f5f5f8;\n  --accent: #ff7185; --crimson: #ff1a35; --accent-soft: #ff1a3512;\n  --sans: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;\n  --heading: 'Space Grotesk', 'Arial', system-ui, sans-serif;\n  --display: 'Syne', 'Arial Black', 'Segoe UI', sans-serif;\n  --mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;\n  font-family: var(--sans); font-size: 16px; line-height: 1.55;\n}\n/* Deliberately use local font stacks: no third-party font requests or tracking. */\n*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; color: var(--text); background: radial-gradient(ellipse at 80% 0%, #ff1a3509, transparent 50%), linear-gradient(#ffffff02 1px, transparent 1px), linear-gradient(90deg,#ffffff02 1px,transparent 1px), var(--bg); background-size: auto,48px 48px,48px 48px,auto; -webkit-font-smoothing: antialiased; }\na { color: inherit; text-decoration: none; }\nbutton, input, textarea, select { font: inherit; }\nbutton { cursor: pointer; touch-action: manipulation; }\nbutton:disabled { opacity: .4; cursor: not-allowed; }\n:where(a,button,input,textarea,select,summary,[tabindex]):focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }\n[hidden] { display: none !important; }\n::selection { background: #ff1a3560; color: white; }\nh1,h2,h3 { font-family: var(--heading); line-height: 1.15; letter-spacing: -.035em; }\nh2 { font-size: 1.65rem; margin: 0 0 12px; }\nh3 { font-size: 1.1rem; margin: 0 0 10px; }\np { margin: 0 0 16px; }\n.skip-link { position: fixed; z-index: 100; top: 10px; left: 10px; padding: 12px; background: var(--crimson); transform: translateY(-150%); }\n.skip-link:focus { transform: none; }\n.sidebar { position: fixed; inset: 0 auto 0 0; width: 260px; padding: 30px 20px; background: #0b0b10ed; border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow-y: auto; z-index: 10; }\n.brand { display: flex; gap: 8px; align-items: center; font-family: var(--display); font-size: 1.25rem; font-weight: 900; letter-spacing: -.06em; white-space: nowrap; }\n.brand-symbol { width:36px; height:36px; flex-shrink:0; filter:drop-shadow(0 0 12px #ff1a3533); }\n.brand-mark { display:block; flex-shrink:0; }\n.brand-dot { color: var(--crimson); margin-left: -6px; }\n.owner-credit { display: flex; justify-content: space-between; margin: 8px 7px 38px 41px; font: 600 .66rem var(--mono); letter-spacing: .1em; color: var(--accent); }\n.nav-label { font: 500 .63rem var(--mono); letter-spacing: .12em; color: #9698a8; margin: 0 12px 14px; }\n.spaced { margin-top: 33px; }\nnav { display: grid; gap: 5px; }\nnav a { display: flex; gap: 12px; align-items: center; padding: 13px 12px; border: 1px solid transparent; border-radius: 9px; color: #b8b9c5; font-size: .83rem; font-weight: 500; }\nnav a > span:first-child { font-size: 1.2rem; color: #8f91a1; }\nnav a:hover { background: #ffffff05; color: white; }\nnav a.active { color: white; border-color: #ff1a3540; background: linear-gradient(110deg,#ff1a351c,#ff1a3506); }\nnav a.active > span:first-child { color: var(--crimson); }\n.nav-new { margin-left: auto; font: 600 .5rem var(--mono); color: var(--accent); border: 1px solid #ff1a3540; padding: 4px 5px; border-radius: 4px; }\n.nav-count { margin-left: auto; border-radius: 4px; background: var(--crimson); color: white; padding: 1px 6px; font-size: .65rem; }\n.sidebar-bottom { margin-top: auto; padding-top: 36px; }\n.open-card { padding: 17px; border: 1px solid var(--line); border-radius: 10px; font-size: .75rem; background: #111117; }\n.open-dot,.live-label i,.eyebrow>span,.count-pill i { display: inline-block; width: 6px; height: 6px; background: var(--crimson); border-radius: 50%; margin-right: 7px; }\n.open-card p { margin: 10px 0 15px; color: var(--muted); line-height: 1.8; font-size: .72rem; }\n.open-card a { display: flex; justify-content: space-between; color: var(--accent); font-size: .72rem; }\n.account-button { background: none; border: 0; border-top: 1px solid var(--line); color: var(--text); display: flex; gap: 10px; align-items: center; width: 100%; margin-top: 24px; padding: 21px 0 0; text-align: left; font-size: .76rem; }\n.account-button small { display: block; margin-top: 4px; color: var(--muted); font-size: .65rem; }\n.account-button>span:last-child { margin-left: auto; }\n.avatar { width: 34px; height: 34px; flex-shrink: 0; border: 1px solid #ff1a3545; background: #ff1a3518; color: var(--accent); border-radius: 50%; display: grid; place-items: center; font-weight: 700; }\n.app-shell { margin-left: 260px; }\n.topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 40px; border-bottom: 1px solid var(--line); background: #08080ce0; font-size: .8rem; }\n.breadcrumb { color: var(--muted); }\n.crumb-slash { color: #555666; padding: 0 14px; }\n.top-actions { display: flex; gap: 22px; align-items: center; }\n.live-label { font: .65rem var(--mono); letter-spacing: .07em; color: #adaebb; }\n.button { display: inline-flex; align-items: center; justify-content: center; gap: 9px; min-height: 42px; padding: 10px 16px; border: 1px solid var(--line); border-radius: 8px; font-size: .8rem; font-weight: 650; line-height: 1.4; transition: background .15s,border-color .15s,transform .15s; }\n.button:hover:not(:disabled) { transform: translateY(-1px); }\n.primary { color: #fff; background: var(--crimson); border-color: var(--crimson); box-shadow: 0 4px 20px #ff1a351b; }\n.primary:hover:not(:disabled) { background: #e70d29; border-color: #e70d29; }\n.subtle { color: #e2e2eb; background: #181920; }\n.subtle:hover:not(:disabled) { border-color: #ff718560; background: #24202a; }\n.danger { color: #ffa2b1; background: #36141b; border-color: #6e2534; }\n.full { width: 100%; }\nmain { max-width: 1700px; padding: 36px 40px 24px; margin: auto; }\n.hero { display: grid; grid-template-columns: 1.15fr 1fr; align-items: center; padding: 48px 40px; min-height: 400px; position: relative; overflow: hidden; border: 1px solid #ff1a3530; border-radius: 18px; background: radial-gradient(ellipse at 80% 70%,#ff1a351c,transparent 65%),linear-gradient(120deg,#171016,#0e0d14); }\n.eyebrow { font: 500 .68rem var(--mono); letter-spacing: .16em; color: var(--accent); margin-bottom: 18px; }\n.hero h1 { font-family: var(--heading); font-size: clamp(2.5rem,3.7vw,4.4rem); font-weight: 800; letter-spacing: -.055em; line-height: 1.05; margin: 0 0 24px; }\n.hero-gradient { color: var(--accent); background: linear-gradient(100deg,#ff5068,#ff91a4,#ffc3cf); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; }\n.hero p { color: #b9b6c4; font-size: 1rem; line-height: 1.8; margin-bottom: 28px; }\n.hero-buttons { display: flex; gap: 10px; flex-wrap: wrap; }\n.platform-line { display: flex; align-items: center; gap: 17px; margin-top: 32px; font-size: .78rem; }\n.platform-line>span { font: .53rem var(--mono); letter-spacing: .08em; color: #a9a1af; }\n.twitch-word { color: #c6a7ff; }.kick-word { color: #a7eb82; }.youtube-word { color: #ffa0a8; }\n.hero-art { position: relative; display: grid; place-items: center; min-height: 295px; }\n.orbit { border: 1px solid #ff1a352a; position: absolute; border-radius: 50%; transform: rotate(-25deg); }\n.orbit-one { width: 340px; height: 220px; }.orbit-two { width: 265px; height: 310px; }\n.art-star { position: absolute; color: var(--crimson); }.star-one { right: 4px; top: 45px; font-size: 50px; }.star-two { left: 14px; bottom: 27px; font-size: 34px; }\n.floating-tag { position: absolute; top: 0; right: 30px; padding: 10px 13px; border-radius: 5px; font: .55rem var(--mono); background: #ff1a35; color: white; transform: rotate(7deg); }\n.chat-card { z-index: 1; width: min(340px,100%); background: #111017; border: 1px solid #64313e; border-radius: 12px; transform: rotate(-5deg); box-shadow: 0 25px 60px #0008; }\n.chat-card-top { display: flex; gap: 8px; align-items: center; border-bottom: 1px solid #33212b; padding: 13px 18px; font: .57rem var(--mono); color: #b6a1ac; letter-spacing: .08em; }\n.tiny-avatar { display: grid; place-items: center; background: #511d2b; color: #ffb2c0; border-radius: 50%; width: 20px; height: 20px; }\n.chat-card-top i { width: 5px; height: 5px; border-radius: 50%; background: var(--crimson); margin-left: auto; }\n.chat-message { padding: 26px 20px; }.chat-badge { color: #d8b0ff; }.chat-message strong { font-size: 1.6rem; margin-left: 5px; }.chat-timestamp { float: right; padding-top: 12px; font-size: .6rem; color: #aa8392; }.chat-message p { font-size: .77rem; color: #d2c1ce; margin: 12px 0 0; }\n.chat-card-bottom { display: flex; gap: 9px; align-items: center; padding: 13px 18px; border-top: 1px solid #33212b; color: #d2b6c1; font-size: .66rem; }.chat-card-bottom>span:last-child { margin-left: auto; border: 1px solid #ff1a354a; color: var(--accent); border-radius: 4px; padding: 3px 5px; font: .45rem var(--mono); }\n.color-dots { display: flex; }.color-dots i { width: 8px; height: 8px; margin-left: -2px; border-radius: 50%; background: #ff1a35; }.color-dots i:nth-child(2){background:#ff7185}.color-dots i:nth-child(3){background:#ffd4dc}\n.floating-bottom { position: absolute; bottom: -12px; right: 15px; color: #b79eac; font-size: .68rem; }.floating-bottom span { color: var(--accent); margin-right: 8px; }\n.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 42px 0 27px; }.section-heading .eyebrow { margin-bottom: 10px; }.section-heading p { color: var(--muted); font-size: .87rem; margin: 0; }.muted { color: #a9a5b5; }\n.count-pill { border: 1px solid #ff1a3536; background: #ff1a3510; color: #f3bcc7; border-radius: 30px; padding: 10px 14px; font-size: .69rem; white-space: nowrap; }\n.catalog-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 15px; margin-bottom: 24px; }.filters { display: flex; gap: 4px; flex-wrap: wrap; }.filters button { font-size: .75rem; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: 7px; padding: 9px 12px; }.filters button.active { color: white; background: #ff1a3516; border-color: #ff1a3545; }\n.search { display: flex; align-items: center; gap: 8px; margin: 0; padding: 7px 12px; border: 1px solid var(--line); border-radius: 8px; min-width: 210px; color: var(--muted); }.search input { padding: 4px!important; border: 0!important; background: none!important; margin: 0!important; font-size: .75rem!important; }.search>span { border: 1px solid var(--line); padding: 0 5px; border-radius: 3px; }\n.cosmetic-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 18px; }.cosmetic-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; transition: border-color .2s; }.cosmetic-card:hover { border-color: #70424f; }\n.card-preview { width: 100%; min-height: 155px; position: relative; display: grid; place-items: center; border: 0; border-bottom: 1px solid var(--line); color: white; background: radial-gradient(ellipse,var(--card-tint,#321c27),transparent 85%),#0b0b11; padding: 22px 14px; overflow: hidden; }.card-preview strong { font-size: 1.75rem; }.corner-label { position: absolute; top: 12px; right: 12px; font: .5rem var(--mono); letter-spacing: .06em; border: 1px solid #ffffff18; border-radius: 4px; padding: 4px 6px; color: #bbb4c5; background: #10101790; }\n.card-meta { padding: 18px 16px; }.card-meta h3 { font-size: .94rem; letter-spacing: -.02em; margin: 0 0 8px; }.card-meta p { color: var(--muted); font-size: .72rem; display: flex; align-items: center; gap: 6px; margin: 0; }.creator-dot { display: inline-block; width: 13px; height: 13px; border: 1px solid #71374a; background: #3a2030; border-radius: 50%; }.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 6px; color: #a8a1b2; font: .58rem var(--mono); margin-top: 17px; }.swatches { display: flex; gap: 4px; }.swatches i { width: 9px; height: 9px; border-radius: 50%; }\n.community-strip { display: flex; align-items: center; gap: 20px; padding: 25px; margin: 30px 0; border: 1px dashed #6e293a; border-radius: 12px; background: #ff1a3507; }.strip-symbol { color: var(--crimson); font-size: 2.3rem; }.community-strip h3 { font-size: 1rem; margin-bottom: 7px; }.community-strip p { font-size: .8rem; color: var(--muted); margin: 0; }.community-strip a { margin-left: auto; font-size: .8rem; color: var(--accent); }\nfooter { display: flex; justify-content: space-between; gap: 20px; font-size: .64rem; color: #a19aaa; padding: 15px 0; line-height: 1.8; }footer a { color: var(--accent); }\n.page-heading { margin: 10px 0 32px; }.page-heading h1 { font-size: clamp(2rem,3vw,3.2rem); letter-spacing: -.055em; margin: 0 0 14px; }.page-heading p { color: var(--muted); font-size: .95rem; max-width: 700px; line-height: 1.8; margin: 0; }.page-heading .eyebrow { margin-bottom: 14px; }.accent { color: var(--crimson); }.studio-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }\n.panel { background: linear-gradient(150deg,#15151e,#0f1016); border: 1px solid var(--line); border-radius: 16px; padding: 27px; min-width: 0; }.panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 23px; }.panel-heading h2 { font-size: 1.25rem; margin: 0; }.panel-heading>span { font: .56rem var(--mono); letter-spacing: .06em; color: #b5a7b9; text-align: right; }\nlabel { display: block; font-size: .8rem; font-weight: 550; color: #d1cfdd; margin: 16px 0; }input,select,textarea { min-width: 0; }input:not([type=color]):not([type=range]):not([type=checkbox]):not([type=file]),select,textarea { display: block; margin-top: 9px; width: 100%; padding: 13px 14px; background: var(--input); color: var(--text); border: 1px solid #30303b; border-radius: 8px; font-size: .86rem; }input::placeholder,textarea::placeholder { color: #9a93a5; }textarea { resize: vertical; }input[type=color] { display: block; width: 100%; height: 42px; margin-top: 9px; padding: 4px; background: #18151e; border: 1px solid #423241; border-radius: 7px; cursor: pointer; }input[type=range] { display: block; width: 100%; margin: 16px 0; accent-color: var(--crimson); }input[type=checkbox] { accent-color: var(--crimson); }\nlabel>output { float: right; font: .7rem var(--mono); color: var(--accent); }.form-row { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 18px; }.color-inputs { display: flex; gap: 12px; }.color-inputs label { flex: 1; }.form-divider { height: 1px; background: var(--line); margin: 25px 0; }.form-footnote { font-size: .73rem; font-weight: 400; color: #a6a0b0; line-height: 1.8; margin: 12px 0; }.effect-group { border: 1px solid #36303c; padding: 17px; border-radius: 10px; margin: 18px 0; }.effect-group summary { font-size: .9rem; color: #e5cdd7; font-weight: 650; cursor: pointer; }.checkbox-label { display: flex; gap: 10px; font-size: .78rem; font-weight: 400; line-height: 1.8; }.checkbox-label input { flex-shrink: 0; margin-top: 5px; }\n.studio-layout { display: grid; grid-template-columns: minmax(0,1.15fr) minmax(0,1fr); gap: 26px; align-items: start; }.editor-panel { padding: 0; overflow: hidden; }.studio-tabs { display: flex; gap: 5px; padding: 14px; border-bottom: 1px solid var(--line); background: #0e0e15; }.studio-tabs button { min-width: 0; flex: 1; padding: 13px 8px; font: 600 .76rem var(--mono); color: #ada5b8; background: transparent; border: 1px solid transparent; border-radius: 8px; }.studio-tabs button[aria-selected=true] { color: #fff; background: #ff1a351e; border-color: #ff1a354a; }.editor-section { padding: 27px; }.editor-utility { padding: 20px 27px; border-top: 1px solid var(--line); background: #0e0e15; }.editor-utility .form-footnote { margin-bottom: 0; }.section-next { margin-top: 18px; }.draft-actions { display: flex; flex-wrap: wrap; gap: 8px; }.draft-actions .button { min-height: 38px; font-size: .75rem; }.import-label { margin: 0; cursor: pointer; }\n.preset-gallery { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 9px; margin-top: 20px; }.preset-tile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 100px; padding: 14px 5px; border: 1px solid var(--line); border-radius: 9px; color: #bab2c4; background: #0c0b12; }.preset-tile strong { font-size: 1.65rem; }.preset-tile>span { font-size: .63rem; }.preset-tile.active,.preset-tile:hover { border-color: #ff1a3570; background: #ff1a350d; }\n.paint-layer { border: 1px solid #3d2e3a; border-radius: 11px; margin: 14px 0; background: #0e0d14; }.paint-layer>summary { display: flex; gap: 12px; align-items: center; cursor: pointer; padding: 15px; font-size: .83rem; font-weight: 650; list-style: none; }.paint-layer>summary::after { content: '＋'; color: var(--accent); margin-left: 3px; }.paint-layer[open]>summary::after { content:'−'; }.paint-layer[open]>summary { border-bottom: 1px solid var(--line); }.layer-number { color: var(--crimson); font: .68rem var(--mono); }.layer-opacity { color: #a9a0b1; font: .66rem var(--mono); margin-left: auto; }.layer-body { padding: 17px; }.layer-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }.layer-actions .button { min-height: 30px; font-size: .65rem; padding: 7px 10px; }.layer-sizing { margin-top: 19px; border-top: 1px solid var(--line); padding-top: 15px; }.layer-sizing>summary { color: #afa6bc; font-size: .72rem; cursor: pointer; }.gradient-stops { margin: 15px 0; }.stop-row { display: grid; grid-template-columns: minmax(0,1fr) 92px 30px; gap: 9px; align-items: center; border-top: 1px solid #ffffff08; }.stop-row label { font-size: .7rem; }.stop-row .button { padding: 5px; min-height: 30px; }.rgba-color { display: grid; grid-template-columns: 38px minmax(0,1fr); gap: 8px; align-items: center; margin-top: 9px; }.rgba-color input[type=color] { height: 38px; margin: 0; }.rgba-color input[type=text] { font: .7rem var(--mono)!important; padding: 10px!important; margin: 0!important; }.rgba-color input[type=range] { grid-column: 1/-1; margin: 6px 0; }.shadow-card { border-top: 1px solid var(--line); padding-top: 17px; margin-top: 20px; }.shadow-card h3 { font-size: .86rem; }.shadow-controls { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }\n.upload-zone { padding: 27px 18px; border: 1px dashed #a53d50; border-radius: 12px; background: #ff1a3509; margin-top: 24px; text-align: center; cursor: pointer; }.upload-zone.dragging { background: #ff1a3525; border-color: var(--accent); }.upload-icon { display: block; color: var(--accent); font-size: 2.2rem; margin-bottom: 10px; }.upload-zone h3 { font-size: 1rem; margin-bottom: 9px; }.upload-zone p { color: #c0adb8; font-size: .8rem; margin-bottom: 8px; }.upload-zone>span:last-of-type { color: #aaa0b0; font: .56rem var(--mono); }.upload-zone input { display: block; max-width: 100%; margin: 18px auto 0; font-size: .73rem; }.upload-zone input::file-selector-button { background: #3e1825; color: #ffd4dd; border: 1px solid #a34357; padding: 8px 12px; border-radius: 5px; margin-right: 10px; cursor: pointer; }.texture-preview-row { display: flex; align-items: center; gap: 14px; padding: 14px; background: #0d0d13; border: 1px solid var(--line); border-radius: 10px; margin-top: 14px; }.texture-preview-row img { width: 65px; height: 65px; object-fit: contain; border-radius: 6px; }.texture-preview-row div { min-width: 0; }.texture-preview-row strong { display: block; overflow-wrap: anywhere; font-size: .76rem; }.texture-preview-row p { font-size: .65rem; color: var(--muted); margin: 6px 0 0; }.texture-preview-row button { margin-left: auto; }\n.preview-column { position: sticky; top: 24px; }.live-chip { color: var(--accent)!important; }.preview-controls { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 15px; }.studio-name-stage { font-family:ui-sans-serif,system-ui,sans-serif; min-height: 230px; padding: 26px 15px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 32px; overflow: hidden; border: 1px solid #56303f; border-radius: 12px; background: radial-gradient(ellipse,#451a2e33,transparent 80%),#09080f; }.studio-name-stage strong { font-size: 18px; transform: scale(3); transform-origin:center; flex-shrink:0; }.studio-name-stage>span { font: .55rem var(--mono); color: #b49aab; letter-spacing: .09em; text-align: center; }.studio-name-stage.light { background: #f4eff2; }.studio-name-stage.light>span { color:#5e4456; }.studio-name-stage.transparent { background: repeating-conic-gradient(#27202b 0% 25%,#131017 0% 50%) 50%/24px 24px; }.preview-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 15px; }.preview-actions .button { font-size: .7rem; min-height: 34px; }.preview-actions>span { color: #b5a4ba; font: .61rem var(--mono); }.mini-chat { font-family:ui-sans-serif,system-ui,sans-serif; background: #090a10; border: 1px solid var(--line); border-radius: 10px; padding: 20px; margin-top: 22px; font-size: .8rem; color: #c6bdcf; line-height: 1.7; }.mini-chat-title { font: .56rem var(--mono); color: #a796b1; letter-spacing: .1em; margin-bottom: 20px; }.mini-chat p { margin: 13px 0; }.mini-chat b { color: #96879e; margin-right: 7px; }.mini-chat strong { margin-right: 7px; }.info-card { border: 1px solid #ff1a3530; background: #ff1a3507; padding: 26px; margin-top: 20px; border-radius: 12px; }.info-card>span { font-size: 1.8rem; color: var(--crimson); }.info-card h3 { margin-top: 15px; }.info-card p { font-size: .83rem; line-height: 1.9; color: #b5a6b9; }.info-card a { color: var(--accent); font-size: .75rem; }\n.text-button { display: block; background: none; border: 0; color: var(--accent); padding: 9px 0; font-size: .76rem; cursor: pointer; }.integration-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 24px; }.integration-grid h2 { font-size: 1.4rem; }.integration-grid p { font-size: .89rem; color: #b7aebf; line-height: 1.9; }.integration-grid p a { color: var(--accent); }.integration-icon { font-size: 2rem; color: var(--crimson); margin-bottom: 21px; }code,pre { font: .75rem/1.9 var(--mono); background: #09090e; color: #f0a1b1; display: block; padding: 14px; border: 1px solid #3b2633; border-radius: 8px; overflow: auto; }.status-note { padding: 12px 15px; margin: 20px 0; background: #ff1a3510; border-left: 2px solid var(--crimson); font-size: .74rem; color: #e4b2c0; }\n.empty-state { grid-column: 1/-1; padding: 60px 25px; border: 1px dashed #623347; border-radius: 12px; text-align: center; }.empty-state p { color: var(--muted); font-size: .87rem; line-height: 1.9; }.empty-state .button { margin-top: 16px; }.badge { display: inline-block; border: 1px solid #704056; background: #ff1a3509; color: #efb0c2; font: .61rem var(--mono); text-transform: uppercase; padding: 5px 8px; border-radius: 4px; }.badge.pending { color: #edc18b; border-color: #765731; }.badge.rejected { color: #f0a5a5; border-color: #733f44; }.card-note { color: #d4b8c6!important; font-size: .75rem!important; line-height: 1.8; margin-top: 12px!important; }.card-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 15px; }.card-actions .button { min-height: 32px; font-size: .68rem; padding: 8px 11px; }\n.account-summary { display: flex; align-items: center; gap: 15px; margin-bottom: 26px; }.account-summary>.button { margin-left: auto; }.identity-card { display: flex; align-items: center; gap: 18px; border: 1px solid var(--line); border-radius: 12px; padding: 24px; margin: 16px 0; background: var(--panel); }.identity-icon { width: 40px; font-size: 1.6rem; color: var(--crimson); }.identity-card h3 { font-size: 1.05rem; margin-bottom: 8px; }.identity-card p { color: var(--muted); font-size: .85rem; margin: 0; }.identity-card small { display: block; color: #b5a1bd; font: .64rem var(--mono); margin-top: 10px; }.identity-card .button,.identity-card .badge,.identity-card .status-note { margin: 0 0 0 auto; }.equipped-panel { margin-top: 28px; }\n.mod-stats { display: flex; gap: 18px; margin-bottom: 27px; }.mod-stats .panel { flex: 1; padding: 20px; color: var(--muted); font-size: .76rem; }.mod-stats strong { display: block; color: var(--text); font: 700 2rem var(--heading); margin-bottom: 8px; }.review-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 18px; }.review-card { border: 1px solid var(--line); background: var(--panel); border-radius: 12px; overflow: hidden; }.review-card label { font-size: .75rem; }.moderator-panel,.audit-panel { margin-top: 30px; }.moderator-form { display: flex; align-items: end; gap: 14px; }.moderator-form label { flex: 1; }.moderator-form .button { margin-bottom: 16px; }.mod-row { display: flex; align-items: center; gap: 15px; padding: 17px 0; border-top: 1px solid var(--line); font-size: .83rem; }.mod-row small { display: block; color: var(--muted); font: .64rem var(--mono); margin-top: 7px; }.mod-row .button { margin-left: auto; }.audit-row { border-bottom: 1px solid var(--line); padding: 17px 0; font-size: .8rem; line-height: 1.8; }.audit-row small { display: block; color: var(--muted); font-size: .68rem; overflow-wrap: anywhere; }\n.demo-banner { display: flex; justify-content: space-between; align-items: center; gap: 15px; padding: 12px 40px; font: .61rem var(--mono); color: #e1b3c1; border-bottom: 1px solid #ff1a3530; background: #ff1a350b; }.demo-banner button { background: none; border: 0; color: var(--accent); font-size: .65rem; }.toast { position: fixed; right: 25px; bottom: 25px; z-index: 50; width: max-content; max-width: min(450px,calc(100% - 40px)); color: #ffe5ec; background: #381c2a; border: 1px solid #be465f; padding: 17px 20px; border-radius: 10px; box-shadow: 0 10px 40px #0007; font-size: .83rem; }.toast.error { color: #ffd7cd; background: #3e1d1d; border-color: #ae5750; }\ndialog { position: fixed; width: min(480px,calc(100% - 32px)); max-height: calc(100dvh - 40px); overflow: auto; color: var(--text); background: #131019; border: 1px solid #683449; border-radius: 18px; padding: 35px; box-shadow: 0 40px 100px #0008; }dialog::backdrop { background: #030308c7; backdrop-filter: blur(6px); }.dialog-close { position: absolute; top: 10px; right: 16px; font-size: 1.7rem; background: none; border: 0; color: var(--muted); }.dialog-symbol { font-size: 2.7rem; color: var(--crimson); }dialog h2 { margin-top: 20px; font-size: 1.8rem; }dialog p { color: var(--muted); font-size: .87rem; line-height: 1.8; }dialog small { display: block; font-size: .7rem; color: #afa1b8; margin-top: 24px; line-height: 1.8; }#signin-options { display: grid; gap: 12px; margin-top: 25px; }.detail-stage { padding: 40px 12px; background: #0c0911; border: 1px solid #503045; border-radius: 12px; text-align: center; margin-top: 15px; overflow: hidden; }.detail-stage strong { font-size: 2.5rem; }.detail-actions { display: flex; gap: 9px; flex-wrap: wrap; margin-top: 22px; }.load-more { display: flex; margin: 25px auto; }\n.guide-layout { display:grid; grid-template-columns:190px minmax(0,1fr); gap:30px; align-items:start; }.guide-index { position:sticky; top:24px; }.guide-index a { border-radius:3px; font-size:.85rem; padding:10px 12px; }.guide-content { display:grid; gap:24px; max-width:880px; }.guide-content .panel { scroll-margin-top:24px; border-radius:6px; background:var(--panel); }.guide-content p,.guide-content li { color:#c5c5cf; font-size:.95rem; line-height:1.85; }.guide-content li { margin:10px 0; }.guide-content a { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }.guide-content summary { cursor:pointer; font-weight:650; padding:12px 0; }.guide-content code { white-space:pre-wrap; }.guide-content ol,.guide-content ul { padding-left:24px; }\n.avatar { overflow:hidden; }.avatar img { width:100%; height:100%; object-fit:cover; }.avatar-large { width:76px; height:76px; font-size:1.8rem; }.top-actions .avatar { width:25px; height:25px; }.profile-heading { min-width:0; }.profile-heading h2 { overflow-wrap:anywhere; }.profile-heading p { margin:0; }.account-steps { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin:24px 0; }.account-steps .panel { padding:20px; }.account-steps h3 { font-size:.95rem; }.account-steps p { margin:0; }.profile-settings { margin-bottom:30px; }.profile-settings select { max-width:520px; }.account-section-title { margin-top:32px; }.identity-actions { margin-left:auto; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }.identity-card .identity-actions .button,.identity-card .identity-actions .badge { margin:0; }.equipped-panel>.button { margin:16px 10px 0 0; }\n.moderation-toolbar { display:flex; gap:16px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }.moderation-toolbar .review-search { flex:1; min-width:200px; margin:0; }.moderation-toolbar input { margin-bottom:0; }.mod-stats { flex-wrap:wrap; }.mod-stats .panel { min-width:130px; }.review-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }.review-preview strong { font-size:18px; transform:scale(1.5); }.review-preview.light { background:#f4eff2; color:#352536; }.review-preview-controls { display:flex; gap:8px; flex-wrap:wrap; padding:12px; border-bottom:1px solid var(--line); }.review-preview-controls .button { font-size:.68rem; min-height:32px; padding:7px 10px; }.review-card h3 { margin-top:16px; overflow-wrap:anywhere; }.review-creator { display:flex; gap:10px; align-items:center; margin:14px 0; font-size:.85rem; }.review-facts { margin:18px 0; font-size:.73rem; }.review-facts>div { display:grid; grid-template-columns:100px minmax(0,1fr); gap:12px; padding:8px 0; border-bottom:1px solid var(--line); }.review-facts dt { color:var(--muted); }.review-facts dd { margin:0; overflow-wrap:anywhere; }.review-feedback { background:#ff1a350c; border-left:2px solid var(--accent); padding:12px; font-size:.8rem; white-space:pre-wrap; overflow-wrap:anywhere; }.recipe-details { margin:16px 0; }.recipe-details summary { cursor:pointer; color:var(--accent); font-size:.8rem; }.recipe-details pre { max-height:300px; font-size:.65rem; }.dashboard-pagination { display:flex; align-items:center; flex-wrap:wrap; gap:12px; margin:20px 0; }.dashboard-pagination .form-footnote { margin:0 auto 0 0; }.table-scroll { overflow-x:auto; }.admin-table { border-collapse:collapse; width:100%; min-width:780px; font-size:.76rem; }.admin-table th { text-align:left; color:var(--muted); font-weight:600; }.admin-table th,.admin-table td { padding:16px 12px; border-bottom:1px solid var(--line); vertical-align:top; }.table-person { display:flex; gap:10px; align-items:center; }.table-person small { display:block; margin-top:5px; color:var(--muted); font:.58rem var(--mono); overflow-wrap:anywhere; }.table-identity { margin-bottom:8px; overflow-wrap:anywhere; }.admin-table .button { white-space:nowrap; font-size:.68rem; }.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }\n@media(max-width:700px) { .account-steps { grid-template-columns:1fr; }.avatar-large { width:60px; height:60px; }.identity-actions { width:100%; margin-left:0; }.review-grid { grid-template-columns:1fr; }.moderation-toolbar .filters { width:100%; }.review-facts>div { grid-template-columns:85px minmax(0,1fr); }.account-summary>.button { margin-left:0; }.top-actions { gap:8px; }.top-actions .button { font-size:.68rem; } }\n@media(max-width:1000px) { .guide-layout { grid-template-columns:minmax(0,1fr); }.guide-index { position:static; display:flex; flex-wrap:wrap; overflow:visible; }.guide-index a { white-space:normal; }.guide-content .panel { padding:22px; } }\n@media (min-width:1600px) { .hero { min-height: 450px; }.card-preview { min-height: 175px; } }\n@media (max-width:1250px) { .sidebar { width: 235px; padding: 25px 16px; }.brand { font-size: 1.13rem; }.app-shell { margin-left:235px; }main { padding: 30px; }.topbar { padding: 16px 30px; }.hero { padding: 36px 28px; }.hero-art { transform: scale(.88); }.cosmetic-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }.live-label { display: none; }.platform-line { gap: 10px; }.platform-line>span { display: none; }.studio-layout { grid-template-columns: minmax(0,1.15fr) minmax(0,.9fr); gap: 18px; }.panel,.editor-section { padding: 22px; }.editor-panel { padding: 0; }.preset-gallery { grid-template-columns: repeat(2,minmax(0,1fr)); }.panel-heading>span { font-size: .5rem; } }\n@media (max-width:1000px) { .hero { grid-template-columns: 1fr; min-height: 340px; }.hero-art { display: none; }.hero h1 { font-size: 3.3rem; }.cosmetic-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }.catalog-toolbar { flex-wrap: wrap; }.studio-layout { grid-template-columns: minmax(0,1fr); }.preview-column { position: static; grid-row: 1; }.preview-panel { padding: 23px; }.studio-name-stage { min-height: 170px; }.preview-column .info-card { display: none; }.preset-gallery { grid-template-columns: repeat(3,minmax(0,1fr)); }.integration-grid { grid-template-columns: 1fr; }.review-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }.studio-heading { align-items: start; flex-wrap: wrap; }.community-strip { flex-wrap: wrap; }.community-strip a { margin-left: 58px; }.section-heading { align-items: start; }.count-pill { white-space: normal; max-width: 150px; font-size: .64rem; }footer { flex-direction: column; gap: 8px; } }\n@media (max-width:700px) { .sidebar { position: relative; width: auto; padding: 17px; border-right: 0; border-bottom: 1px solid var(--line); overflow: visible; }.brand { font-size: 1.35rem; }.owner-credit { margin: 3px 0 18px 42px; max-width: 180px; }.nav-label,.sidebar-bottom { display: none; }nav { display: flex; overflow-x: auto; gap: 4px; padding-bottom: 4px; }nav a { white-space: nowrap; font-size: .75rem; padding: 10px; }.sidebar nav:last-of-type { margin-top: 5px; }.nav-new { display: none; }.app-shell { margin-left: 0; }.topbar { min-height: 59px; padding: 12px 18px; font-size: .75rem; }.topbar .button { min-height: 34px; padding: 8px 12px; }.demo-banner { padding: 12px 18px; font-size: .55rem; }main { padding: 24px 17px; }.hero { min-height: 350px; padding: 34px 24px; border-radius: 14px; }.hero h1 { font-size: clamp(2.5rem,9vw,3.5rem); }.hero p { font-size: .91rem; }.hero-buttons .button { font-size: .74rem; padding: 10px 13px; }.platform-line { gap: 20px; font-size: .76rem; }.section-heading { margin-top: 33px; gap: 12px; }.section-heading h2 { font-size: 1.55rem; }.section-heading p { font-size: .78rem; }.count-pill { max-width: 130px; padding: 8px 10px; font-size: .6rem; }.eyebrow { font-size: .59rem; }.filters button { font-size: .69rem; padding: 9px; }.search { width: 100%; }.cosmetic-grid { gap: 12px; }.card-preview { min-height: 135px; padding: 30px 9px 20px; }.card-preview strong { font-size: 1.45rem; }.card-meta { padding: 15px 12px; }.card-meta h3 { font-size: .84rem; }.card-meta p { font-size: .66rem; }.card-footer { font-size: .5rem; }.corner-label { font-size: .44rem; top: 9px; right: 8px; }.community-strip { padding: 20px; }.community-strip p { line-height: 1.8; }.page-heading h1 { font-size: 2.1rem; }.page-heading p { font-size: .87rem; }.panel,.editor-section { padding: 20px; }.editor-panel { padding: 0; }.panel-heading h2 { font-size: 1.12rem; }.panel-heading>span { font-size: .48rem; max-width: 110px; }.studio-tabs { padding: 10px; gap: 4px; }.studio-tabs button { font-size: .65rem; padding: 12px 6px; }.editor-utility { padding: 18px 20px; }.preset-gallery { gap: 7px; }.preset-tile>span { font-size: .57rem; }.layer-body { padding: 14px; }.stop-row { grid-template-columns: minmax(0,1fr) 76px 26px; gap: 7px; }.rgba-color { gap: 5px; grid-template-columns: 32px minmax(0,1fr); }.rgba-color input[type=text] { font-size: .64rem!important; padding: 8px 6px!important; }.texture-preview-row { flex-wrap: wrap; }.upload-zone { padding: 23px 14px; }.studio-name-stage strong { font-size: 18px; }.preview-controls { gap: 13px; }.form-row { gap: 14px; }.review-grid { grid-template-columns: 1fr; }.mod-stats { gap: 9px; }.mod-stats .panel { padding: 15px 10px; font-size: .64rem; }.mod-stats strong { font-size: 1.6rem; }.identity-card { flex-wrap: wrap; gap: 13px; padding: 20px; }.identity-card .button,.identity-card .status-note { margin-left: 53px; }.identity-card small { overflow-wrap: anywhere; }.moderator-form { flex-wrap: wrap; }.moderator-form label { min-width: 100%; }.moderator-form .button { margin: 0; }.account-summary { flex-wrap: wrap; }.mod-row { flex-wrap: wrap; }.mod-row .button { font-size: .7rem; } }\n@media (max-width:380px) { .preset-gallery { grid-template-columns: repeat(2,minmax(0,1fr)); }.cosmetic-grid { grid-template-columns: 1fr; }.form-row { gap: 10px; }.hero-buttons { flex-direction: column; }.studio-tabs button { font-size: .61rem; }.stop-row { grid-template-columns: minmax(0,1fr) 65px 24px; } }\n@media (max-width:300px) { .form-row,.preview-controls,.shadow-controls { grid-template-columns:minmax(0,1fr); }.studio-tabs { flex-wrap:wrap; }.studio-tabs button { flex-basis:100%; }.panel-heading,.topbar { flex-wrap:wrap; }.brand { font-size:1rem; }.count-pill { display:none; }.stop-row { grid-template-columns:minmax(0,1fr); }.stop-row .button { justify-self:end; }.preview-actions { flex-wrap:wrap; }.studio-name-stage strong { font-size:18px; }.panel,.editor-section { padding:16px; }.editor-panel { padding:0; }.editor-utility { padding:16px; } }\n.platform-logo { width:22px; height:22px; flex-shrink:0; fill:currentColor; display:inline-block; vertical-align:middle; }\n.platform-line b,.identity-card h3 { display:flex; align-items:center; gap:8px; }\n.logo-twitch { color:#9146ff; }.logo-kick { color:#53fc18; }.logo-youtube { color:#ff0033; }\n.identity-card>.platform-logo { width:34px; height:34px; }\n.identity-card h3 .platform-logo { width:17px; height:17px; }\n.integration-icon .platform-logo { width:36px; height:36px; }\n.browse-options { display:flex; align-items:end; gap:14px; flex-wrap:wrap; margin:8px 0 20px; padding:18px; background:var(--panel); border:1px solid var(--line); border-radius:12px; }\n.browse-options label { margin:0; flex:1; min-width:160px; }\n.browse-options select,.browse-options input { padding:10px!important; }\n.flow-strip { display:flex; flex-wrap:wrap; gap:20px; font-size:.76rem; color:var(--muted); margin:22px 0; }\n.flow-strip b { color:var(--accent); font: .7rem var(--mono); margin-right:8px; }\n.collection-toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:20px; }\n.card-meta .avatar { width:20px; height:20px; font-size:.6rem; }\n.card-meta h3 { overflow-wrap:anywhere; }\n.card-preview strong { max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n.detail-stage.light { background:#f4eff2; color:#352536; }\n#detail-dialog { width:min(580px,calc(100% - 32px)); }\n@media(max-width:700px) { .browse-options { gap:10px; padding:14px; }.browse-options label { min-width:100%; }.browse-options .button { flex:1; }.flow-strip { gap:12px; flex-direction:column; }.collection-toolbar>.button { width:100%; }.identity-card .identity-actions .button,.identity-card .identity-actions .status-note { margin-left:0; }.card-actions .button { flex:1; }.detail-stage strong { font-size:1.8rem; } }\n@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior: auto!important; transition: none!important; }.button:hover { transform: none!important; } }\n","public/effects.css":"/* SPDX-License-Identifier: MIT OR AGPL-3.0-only\n * Copyright (c) 2026 Nameflare contributors. See SDK-LICENSE.md for the MIT option. */\n.nameflare-name{position:relative;display:inline-block;isolation:isolate;background-clip:text;-webkit-background-clip:text;color:transparent;-webkit-text-fill-color:transparent;font-weight:800;line-height:1.4;white-space:pre}\n.nameflare-name::before{content:attr(data-text);position:absolute;inset:0;z-index:-1;background:none;color:transparent;-webkit-text-fill-color:transparent;-webkit-text-stroke:0;text-shadow:var(--nameflare-shadows,none);pointer-events:none}\n.nameflare-complex{background-image:none!important}\n.nameflare-paint-layer{position:absolute;inset:0;pointer-events:none;background-clip:text;-webkit-background-clip:text;color:transparent;-webkit-text-fill-color:transparent;white-space:inherit;line-height:inherit;font:inherit}\n.nameflare-paint-layer::after{content:attr(data-text);white-space:inherit}\n.nameflare-name[data-animation=flow],.nameflare-name[data-animation=flow]>.nameflare-paint-layer{animation:nameflare-flow var(--nameflare-speed,4s) linear infinite}\n.nameflare-name[data-animation=shimmer],.nameflare-name[data-animation=shimmer]>.nameflare-paint-layer{animation:nameflare-flow var(--nameflare-speed,4s) ease-in-out infinite alternate}\n.nameflare-name[data-animation=pulse]{animation:nameflare-pulse var(--nameflare-speed,4s) ease-in-out infinite}\n.nameflare-name[data-paused=true],.nameflare-name[data-paused=true]>.nameflare-paint-layer{animation-play-state:paused}\n@keyframes nameflare-flow{from{background-position:0% 50%}to{background-position:100% 50%}}\n@keyframes nameflare-pulse{0%,100%{opacity:1;background-position:0% 50%}50%{opacity:.7;background-position:100% 50%}}\n@media(prefers-reduced-motion:reduce){.nameflare-name,.nameflare-paint-layer{animation:none!important}}\n","public/app.js":"import { applyCosmetic, updateCosmeticText, createFrameUpdate } from './renderer.js';\nimport { initializeEditor, defaultRecipe } from './editor.js';\nconst $ = selector => document.querySelector(selector);\nconst state = { config:null,user:null,csrf:null,cosmetics:[],next:null,filter:'all',view:'discover',catalogPaused:false,collectionFilter:'all',mine:[],pendingEquip:null,reviewStatus:'pending',reviewQuery:'',reviewOffset:0,userQuery:'',userOffset:0 };\nfunction platformLogo(provider) {\n  const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.classList.add('platform-logo',`logo-${provider}`);svg.setAttribute('aria-hidden','true');\n  const use=document.createElementNS('http://www.w3.org/2000/svg','use');use.setAttribute('href',`#logo-${provider}`);svg.append(use);return svg;\n}\nconst providerNames = { twitch:'Twitch', kick:'Kick', youtube:'YouTube' };\nconst avatarHosts = { twitch:['static-cdn.jtvnw.net'], kick:['files.kick.com','kick.com','www.kick.com','pfp.kick.com'], youtube:['yt3.ggpht.com','yt3.googleusercontent.com','lh3.googleusercontent.com'] };\nfunction avatar(profile, cls='') {\n  const fallback=(profile?.name || profile?.display_name || 'N').slice(0,1).toUpperCase();\n  const element=node('span',`avatar ${cls}`,fallback);\n  try {\n    const url=new URL(profile.avatar_url);\n    if(url.protocol==='https:' && !url.username && !url.password && !url.port && avatarHosts[profile.profile_provider || profile.provider]?.includes(url.hostname)) {\n      const image=node('img');image.alt='';image.referrerPolicy='no-referrer';image.loading='lazy';\n      image.addEventListener('error',()=>element.replaceChildren(document.createTextNode(fallback)),{once:true});image.src=url.href;element.replaceChildren(image);\n    }\n  } catch {}\n  return element;\n}\nfunction date(value) { return value ? new Date(value).toLocaleString() : 'Not reviewed yet'; }\nfunction fact(label, value) { const item=node('div');item.append(node('dt','',label),node('dd','',value));return item; }\nlet editor;\nconst hasTexture = cosmetic => Boolean(cosmetic.recipe.textureId || cosmetic.recipe.layers?.some(layer => layer.type === 'image'));\nfunction node(tag, cls, content) {\n  const el = document.createElement(tag); if (cls) el.className = cls; if (content !== undefined) el.textContent = content; return el;\n}\nfunction action(label, fn, cls='button subtle') {\n  const button = node('button',cls,label); button.type='button'; button.addEventListener('click',() => run(async()=>{if(button.disabled)return;button.disabled=true;try{await fn();}finally{if(button.isConnected)button.disabled=false;}})); return button;\n}\nfunction link(label,href,cls='button primary') { const el=node('a',cls,label);el.href=href;return el; }\nfunction empty(container,title,description,href='#studio',label='Create a Flare ↗') {\n  const el=node('div','empty-state'); el.append(node('h3','',title),node('p','',description)); if(href) el.append(link(label,href));container.replaceChildren(el);\n}\nlet toastTimer;\nfunction toast(message,error=false) {\n  $('#toast').textContent=message; $('#toast').classList.toggle('error',error); $('#toast').hidden=false;\n  clearTimeout(toastTimer);toastTimer=setTimeout(() => $('#toast').hidden=true,5500);\n}\nasync function run(fn) { try { await fn(); } catch(error) { toast(error.message,true); } }\nasync function api(path,data) {\n  const options = data === undefined ? {cache:'no-store'} : {method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':state.csrf || ''},body:JSON.stringify(data)};\n  const response=await fetch(path,options); const result=await response.json();\n  if(!response.ok) throw new Error(result.error || 'Request failed.'); return result;\n}\nasync function refreshAccount() {\n  const result=await api('/api/me'); state.user=result.user;state.csrf=result.csrf;\n  $('#signin-button').replaceChildren(...(state.user ? [avatar(state.user),document.createTextNode(`${state.user.name} ↗`)] : [document.createTextNode('Sign in / Create account ↗')]));\n  $('[data-nav=\"moderation\"]').hidden=!['owner','moderator'].includes(state.user?.role);\n  if(!['owner','moderator'].includes(state.user?.role))$('#mod-badge').hidden=true;\n  const label=$('#account-label');label.replaceChildren(document.createTextNode(state.user?.name || 'Find your Nameflare'),node('small','',state.user ? `${state.user.role} account`:'Sign in to get started'));\n  $('.account-button .avatar').replaceWith(avatar(state.user));\n}\nfunction openSignIn() {\n  const options=$('#signin-options');options.replaceChildren();\n  $('#signin-title').textContent='Create an account or sign in.';\n  $('#signin-description').textContent='Your first provider sign-in creates a free Nameflare account automatically. No separate password or invitation.';\n  if(state.config.demo) {\n    options.append(node('p','','Demo accounts only. No real OAuth or production access.'));\n    for(const [role,label] of [['creator','Creator · lumi'],['owner','Owner · orbit'],['mod','Moderator · nova']]) options.append(action(label,async() => {\n      await api('/api/demo/login',{role});await refreshAccount();$('#signin-dialog').close();editor.accountChanged();await renderView();toast(`Signed in as ${state.user.name} (demo)`);if(state.pendingEquip){const id=state.pendingEquip;state.pendingEquip=null;if(await equip(id)){$('#detail-dialog').close();location.hash='account';}}\n    },'button primary'));\n  } else {\n    for(const [provider,title] of [['twitch','Twitch'],['kick','Kick'],['youtube','YouTube']]) {\n      if(state.config.providers[provider]) {const option=link(`Continue with ${title} ↗`,`/auth/${provider}`,'button subtle');option.prepend(platformLogo(provider));options.append(option);}\n      else { const button=node('button','button subtle',`${title} · setup required`);button.prepend(platformLogo(provider));button.disabled=true;options.append(button); }\n    }\n    if(!Object.values(state.config.providers).some(Boolean)) options.append(node('p','','The operator needs to configure OAuth credentials first. You can still try the editor without signing in.'));\n  }\n  $('#signin-dialog').showModal();\n}\nfunction accountClick() { if(state.user) location.hash='account';else openSignIn(); }\n$('#signin-button').addEventListener('click',accountClick);$('#sidebar-account').addEventListener('click',accountClick);\n$('#demo-switch').addEventListener('click',openSignIn);$('#close-signin').addEventListener('click',() => $('#signin-dialog').close());\n$('#close-detail').addEventListener('click',() => $('#detail-dialog').close());\n$('#detail-dialog').addEventListener('close',()=>{if(location.hash.startsWith('#discover/'))location.hash='discover';});\nfor(const dialog of document.querySelectorAll('dialog')) dialog.addEventListener('click',event => {if(event.target===dialog){const r=dialog.getBoundingClientRect();if(event.clientX<r.left || event.clientX>r.right || event.clientY<r.top || event.clientY>r.bottom)dialog.close();}});\n\nconst catalogPreviews = new Map();\nconst updateCatalogNames = createFrameUpdate(() => {\n  const input = $('#catalog-name').value.trim();\n  for (const [element, cosmetic] of catalogPreviews) updateCosmeticText(element, input || state.user?.name || cosmetic.creator || 'yourname');\n});\nfunction card(cosmetic, collection=false) {\n  const card=node('article','cosmetic-card');const preview=node('button','card-preview');preview.type='button';preview.setAttribute('aria-label',`View ${cosmetic.name}`);\n  preview.style.setProperty('--card-tint',`${cosmetic.recipe.colors[0]}18`);\n  const name=node('strong','',collection ? (state.user?.name || 'yourname'):($('#catalog-name').value.trim() || state.user?.name || cosmetic.creator || 'yourname'));applyCosmetic(name,cosmetic,location.origin,{paused:state.catalogPaused});\n  if(!collection)catalogPreviews.set(name,cosmetic);\n  preview.append(name,node('span','corner-label',hasTexture(cosmetic) ? 'GIF / TEXTURE': cosmetic.recipe.depth ? '3D STYLE':cosmetic.recipe.animation === 'none' ? 'STATIC':'ANIMATED'));\n  preview.addEventListener('click',() => run(() => openDetails(cosmetic)));\n  const meta=node('div','card-meta');meta.append(node('h3','',cosmetic.name));const creator=node('p','');creator.append(avatar({name:cosmetic.creator,avatar_url:cosmetic.creator_avatar,profile_provider:cosmetic.creator_provider}),document.createTextNode(`by ${cosmetic.creator || state.user?.name || 'creator'}`));meta.append(creator);\n  const bottom=node('div','card-footer'),swatches=node('span','swatches');for(const color of cosmetic.recipe.colors){const dot=node('i');dot.style.backgroundColor=color;swatches.append(dot);}\n  bottom.append(swatches,node('span','',`${cosmetic.recipe.speed}s · ${cosmetic.recipe.animation}`));meta.append(bottom);\n  if(collection){\n    const badge=node('span',`badge ${cosmetic.status}`,({pending:'In review',rejected:'Needs changes',approved:'Approved'}[cosmetic.status]));badge.style.marginTop='13px';badge.style.display='inline-block';meta.append(badge);\n    meta.append(node('p','card-note',`Submitted ${date(cosmetic.created_at)}`));\n    if(cosmetic.reviewed_at)meta.append(node('p','card-note',`${cosmetic.reviewer || 'Reviewer'} · ${date(cosmetic.reviewed_at)}`));\n    if(cosmetic.reason)meta.append(node('p','card-note',cosmetic.reason));\n    const buttons=node('div','card-actions');\n    if(cosmetic.status==='approved') buttons.append(action(state.user?.equipped===cosmetic.id ? 'Equipped ✓':'Equip ↗',() => equip(cosmetic.id),'button primary'),action('Share link',()=>copyFlareLink(cosmetic.id)));\n    buttons.append(action('Edit a copy',() => editor.editCopy(cosmetic)));\n    buttons.append(action(cosmetic.listed ? 'Make unlisted':'List publicly',async()=>{await api('/api/cosmetics/publication',{id:cosmetic.id,listed:!Boolean(cosmetic.listed)});await loadCatalog();await renderCollection();toast(cosmetic.listed?'Flare is unlisted. Existing links and equips still work.':'Flare will appear in Discover once approved.');}));\n    meta.append(node('p','card-note',`${cosmetic.listed?'Public listing':'Unlisted · link only'}${cosmetic.status==='pending'?' after approval':''}`));meta.append(buttons);\n  } else {\n    const buttons=node('div','card-actions');buttons.append(action(state.user?.equipped===cosmetic.id?'Equipped ✓':'Equip Flare',()=>equip(cosmetic.id),'button primary'),action('Details →',()=>openDetails(cosmetic)));meta.append(buttons);\n  }\n  if(cosmetic.status==='approved' || !cosmetic.status)meta.append(node('p','card-note',`${cosmetic.equipped_count || 0} equipped${state.user?.equipped===cosmetic.id?' · Your current Flare':''}`));\n  card.append(preview,meta);return card;\n}\nfunction renderCatalog() {\n  catalogPreviews.clear();\n  if(!state.cosmetics.length) empty($('#catalog'),'No Flares found.','Try a different search or filter, or create the next community favorite.');\n  else $('#catalog').replaceChildren(...state.cosmetics.map(c => card(c)));\n  $('#load-more').hidden=state.next===null;\n  $('#catalog-status').textContent=`${state.cosmetics.length} Flares shown${state.next!==null?' · More available':''}`;\n  if(state.config)$('#approved-count').textContent=state.config.stats.public ?? state.config.stats.approved;\n}\nlet catalogRequest=0;\nasync function loadCatalog(append=false) {\n  const request=++catalogRequest;\n  const query=$('#catalog-search').value.trim();\n  $('#catalog-status').textContent='Loading Flares…';$('#catalog').setAttribute('aria-busy','true');$('#load-more').disabled=true;\n  try {\n    const result=await api(`/api/v1/cosmetics?limit=24&offset=${append ? state.next || 0:0}&q=${encodeURIComponent(query)}&effect=${state.filter}&sort=${$('#catalog-sort').value}`);\n    if(request!==catalogRequest)return;\n    state.config.stats.public=result.total;\n    state.cosmetics=append ? [...state.cosmetics,...result.cosmetics]:result.cosmetics;state.next=result.next_offset;renderCatalog();\n  } catch(error) {if(request===catalogRequest)$('#catalog-status').textContent='Could not load Flares. Use Refresh to retry.';throw error;}\n  finally {if(request===catalogRequest){$('#catalog').setAttribute('aria-busy','false');$('#load-more').disabled=false;}}\n}\nfor(const button of document.querySelectorAll('[data-filter]')) button.addEventListener('click',() => {\n  state.filter=button.dataset.filter;for(const b of document.querySelectorAll('[data-filter]')){b.classList.toggle('active',b===button);b.setAttribute('aria-pressed',String(b===button));}run(()=>loadCatalog());\n});\nlet searchTimer;$('#catalog-search').addEventListener('input',() => {clearTimeout(searchTimer);searchTimer=setTimeout(() => run(() => loadCatalog()),250);});\n$('#load-more').addEventListener('click',() => run(() => loadCatalog(true)));\n$('#catalog-sort').addEventListener('change',()=>run(()=>loadCatalog()));\n$('#catalog-name').addEventListener('input',updateCatalogNames);\n$('#catalog-refresh').addEventListener('click',()=>run(()=>loadCatalog()));\n$('#catalog-pause').addEventListener('click',()=>{state.catalogPaused=!state.catalogPaused;$('#catalog-pause').textContent=state.catalogPaused?'▶ Resume previews':'Ⅱ Pause previews';$('#catalog-pause').setAttribute('aria-pressed',String(state.catalogPaused));renderCatalog();});\nasync function equip(id) {\n  if(!state.user){state.pendingEquip=id;try{sessionStorage.setItem('nameflare.equip',id);}catch{}openSignIn();return false;}\n  await api('/api/equip',{cosmeticId:id});await refreshAccount();try{sessionStorage.removeItem('nameflare.equip');}catch{}\n  state.pendingEquip=null;toast(id ? 'Flare equipped across your connected accounts.':'Flare removed.');\n  await loadCatalog();if(state.view==='collection')await renderCollection();if(state.view==='account')await renderAccount();return true;\n}\nasync function copyFlareLink(id) {\n  await navigator.clipboard.writeText(`${location.origin}/#discover/${encodeURIComponent(id)}`);\n  toast('Flare link copied. Anyone can view and equip after signing in.');\n}\nfunction openDetails(cosmetic) {\n  if(!cosmetic.status || cosmetic.status==='approved'){location.hash=`discover/${encodeURIComponent(cosmetic.id)}`;return;}\n  return showDetails(cosmetic);\n}\nasync function showDetails(cosmetic) {\n  const container=$('#detail-content');container.replaceChildren();const stage=node('div','detail-stage');const previewText=state.user?.name || cosmetic.creator || 'yourname',name=node('strong','',previewText);stage.append(name);\n  let paused=false;const update=()=>applyCosmetic(name,cosmetic,location.origin,{paused});update();\n  container.append(stage,node('h2','',cosmetic.name),node('p','',cosmetic.description || 'An original community Flare.'));\n  const creator=node('div','review-creator');creator.append(avatar({name:cosmetic.creator,avatar_url:cosmetic.creator_avatar,profile_provider:cosmetic.creator_provider}),node('span','',`by ${cosmetic.creator || 'creator'}`));container.append(creator);\n  const previewLabel=node('label','','Try your name'),input=node('input');input.maxLength=30;input.value=previewText;input.addEventListener('input',createFrameUpdate(()=>{if(name.isConnected)updateCosmeticText(name,input.value || 'yourname');}));previewLabel.append(input);container.append(previewLabel);\n  const controls=node('div','detail-actions');const pause=action('Pause motion',()=>{paused=!paused;pause.textContent=paused?'Resume motion':'Pause motion';pause.setAttribute('aria-pressed',String(paused));update();});pause.setAttribute('aria-pressed','false');\n  const backdrop=action('Light backdrop',()=>{const light=stage.classList.toggle('light');backdrop.textContent=light?'Dark backdrop':'Light backdrop';backdrop.setAttribute('aria-pressed',String(light));});backdrop.setAttribute('aria-pressed','false');controls.append(pause,backdrop);container.append(controls);\n  const published=!cosmetic.status || cosmetic.status==='approved';\n  container.append(node('p','form-footnote',published?`${cosmetic.listed===false || cosmetic.listed===0?'Unlisted · share by link':'Public community Flare'} · ${cosmetic.equipped_count || 0} equipped · Approved ${date(cosmetic.updated_at || cosmetic.reviewed_at)}`:'Not public yet · only you and reviewers can see this submission.'));\n  const actions=node('div','detail-actions');\n  if(!cosmetic.status || cosmetic.status==='approved')actions.append(action(state.user?.equipped===cosmetic.id ? 'Equipped ✓':'Equip Flare ↗',async() => {if(await equip(cosmetic.id)){$('#detail-dialog').close();location.hash='account';}},'button primary'));\n  if(published)actions.append(action('Copy share link',()=>copyFlareLink(cosmetic.id)));\n  actions.append(action('Remix in studio',() => {editor.editCopy(cosmetic);$('#detail-dialog').close();}));\n  if((!cosmetic.status || cosmetic.status==='approved') && ['owner','moderator'].includes(state.user?.role) && cosmetic.owner_id!==state.user.id) {\n    const label=node('label','','Revocation reason');const input=node('input');input.placeholder='Required if removing from the API';input.maxLength=400;label.append(input);container.append(label);\n    actions.append(action('Revoke approval',async() => {if(input.value.trim().length<3)throw new Error('Enter a revocation reason.');await api('/api/admin/review',{id:cosmetic.id,status:'rejected',reason:input.value});$('#detail-dialog').close();await loadCatalog();toast('Approval revoked. Equipped Flares cleared.');},'button danger'));\n  }\n  container.append(actions,node('small','','Remixing does not grant artwork rights. Only reuse textures you own or have permission to use.'));\n  $('#detail-dialog').showModal();\n}\n\neditor = initializeEditor({ api, getUser:() => state.user, signIn:openSignIn, toast, onSubmitted:() => { location.hash='collection'; } });\n\nfunction renderMyFlares() {\n  const flares=state.mine.filter(c=>state.collectionFilter==='all' || c.status===state.collectionFilter);\n  $('#collection-status').textContent=`${state.mine.length} submissions · ${state.mine.filter(c=>c.status==='approved').length} approved · ${state.mine.filter(c=>c.status==='pending').length} in review`;\n  if(flares.length)$('#my-collection').replaceChildren(...flares.map(c=>card(c,true)));else empty($('#my-collection'),'No Flares in this view.','Create a Flare or select another review status.');\n}\nfor(const b of document.querySelectorAll('[data-collection-filter]'))b.addEventListener('click',()=>{state.collectionFilter=b.dataset.collectionFilter;for(const item of document.querySelectorAll('[data-collection-filter]')){item.classList.toggle('active',item===b);item.setAttribute('aria-pressed',String(item===b));}if(state.user)renderMyFlares();});\nasync function renderCollection() {\n  if(!state.user){$('#collection-status').textContent='';empty($('#my-collection'),'Your collection starts here.','Sign in to create, track reviews, and publish Flares.','#account','Connect an account ↗');return;}\n  const userId=state.user.id,result=await api('/api/my/cosmetics');if(state.view!=='collection' || state.user?.id!==userId)return;\n  state.mine=result.cosmetics;renderMyFlares();\n}\nasync function renderAccount() {\n  const container=$('#account-content');container.replaceChildren();\n  if(!state.user){\n    empty(container,'Your account starts with one sign-in.','Anyone can join through an enabled provider. Your first sign-in creates your account; returning sign-ins reopen it. No separate password or invitation.',null);\n    container.firstChild.append(action('Create account / Sign in ↗',openSignIn,'button primary'),node('p','form-footnote','Already have an account? Use the same provider. Link other platforms while signed in to keep one account.'));return;\n  }\n  const user=state.user,summary=node('div','panel account-summary');\n  const identity=node('div','profile-heading');identity.append(node('h2','',user.name),node('p','form-footnote',`Joined ${date(user.created_at)} · ${user.role} account`));\n  summary.append(avatar(user,'avatar-large'),identity,action('Sign out',async()=>{await api('/api/logout',{});await refreshAccount();editor.accountChanged();await renderView();toast('Signed out. Your account and Flares are saved.');}));container.append(summary);\n  const steps=node('div','account-steps');\n  for(const [title,description,href] of [['01 · Connect','Link your platforms below.','#account'],['02 · Create','Name a Flare and choose public or unlisted.','#studio'],['03 · Equip','Choose any approved Flare in Discover.','#discover']]){const step=node('a','panel',undefined);step.href=href;step.append(node('h3','',title),node('p','form-footnote',description));steps.append(step);}container.append(steps);\n  const settings=node('form','panel profile-settings');settings.append(node('h2','','Your profile'),node('p','form-footnote','Choose which connected platform supplies your Nameflare name and avatar. Profile images refresh when you sign in with that platform again. This does not rename any platform account.'));\n  const label=node('label','','Name and avatar source'),select=node('select');select.name='provider';\n  for(const item of user.identities){const option=node('option','',`${providerNames[item.provider]} · ${item.display_name}`);option.value=item.provider;select.append(option);}\n  select.value=user.profile_provider || user.identities[0]?.provider;label.append(select);const save=node('button','button primary','Save profile');save.type='submit';settings.append(label,save);\n  settings.addEventListener('submit',event=>{event.preventDefault();run(async()=>{save.disabled=true;try{await api('/api/account/profile',{provider:select.value});await refreshAccount();await renderAccount();toast('Profile updated.');}finally{save.disabled=false;}});});container.append(settings);\n  container.append(node('h2','account-section-title','Connected platforms'));\n  for(const [provider,title] of Object.entries(providerNames)){\n    const connected=user.identities.find(item=>item.provider===provider),row=node('div','identity-card'),detail=node('div');\n    detail.append(node('h3','',title),node('p','',connected ? connected.display_name:'Not connected'));\n    if(connected)detail.append(node('small','',`Verified platform ID: ${connected.provider_id}`));detail.querySelector('h3').prepend(platformLogo(provider));\n    row.append(connected ? avatar(connected) : platformLogo(provider),detail);\n    const actions=node('div','identity-actions');\n    if(connected){actions.append(node('span','badge','Connected ✓'));if(!state.config.demo)actions.append(link('Refresh profile ↗',`/auth/${provider}`,'button subtle'));}\n    else if(!state.config.demo && state.config.providers[provider])actions.append(link(`Link ${title} ↗`,`/auth/${provider}`,'button subtle'));\n    else actions.append(node('span','status-note',state.config.demo ? 'Real linking disabled in demo':'Operator setup required'));\n    row.append(actions);container.append(row);\n  }\n  container.append(node('p','form-footnote','Link while signed in to avoid separate accounts. Refresh must use the same linked identity. Automatic merging and unlinking are not supported.'));\n  const equipped=node('div','panel equipped-panel');equipped.append(node('h2','','Your equipped Flare'));\n  if(user.equipped){\n    try{const result=await api(`/api/v1/cosmetics/${encodeURIComponent(user.equipped)}`);if(state.user?.id!==user.id || state.view!=='account')return;const stage=node('div','detail-stage'),name=node('strong','',user.name);applyCosmetic(name,result.cosmetic);stage.append(name);equipped.append(stage,node('p','',result.cosmetic.name),action('Remove Flare',()=>equip(null)));}catch{equipped.append(node('p','','Your Flare could not be loaded. Try refreshing the page.'));}\n  }else equipped.append(node('p','form-footnote','Nothing equipped yet. Equip any approved community Flare or create your own.'));\n  equipped.append(link('Explore community Flares ↗','#discover','button subtle'),link('My submissions →','#collection','button subtle'));container.append(equipped);\n}\nfunction reviewCard(cosmetic) {\n  const article=node('article','review-card');article.dataset.paintId=cosmetic.id;\n  const preview=node('div','card-preview review-preview'),name=node('strong','',cosmetic.creator);applyCosmetic(name,cosmetic);preview.append(name);\n  const controls=node('div','review-preview-controls');let paused=false;\n  const pause=action('Pause motion',()=>{paused=!paused;pause.textContent=paused?'Resume motion':'Pause motion';pause.setAttribute('aria-pressed',String(paused));applyCosmetic(name,cosmetic,location.origin,{paused});});pause.setAttribute('aria-pressed','false');\n  const backdrop=action('Light backdrop',()=>{const light=preview.classList.toggle('light');backdrop.textContent=light?'Dark backdrop':'Light backdrop';backdrop.setAttribute('aria-pressed',String(light));});backdrop.setAttribute('aria-pressed','false');controls.append(pause,backdrop);\n  const meta=node('div','card-meta');meta.append(node('span',`badge ${cosmetic.status}`,cosmetic.status),node('h3','',cosmetic.name));\n  const creator=node('div','review-creator');creator.append(avatar({name:cosmetic.creator,avatar_url:cosmetic.creator_avatar,profile_provider:cosmetic.creator_provider}),node('strong','',cosmetic.creator));meta.append(creator,node('p','card-note',cosmetic.description || 'No description supplied.'));\n  const facts=node('dl','review-facts');facts.append(fact('Submitted',date(cosmetic.created_at)),fact('Reviewed',date(cosmetic.reviewed_at)),fact('Reviewer',cosmetic.reviewer || 'Awaiting review'),fact('Flare ID',cosmetic.id),fact('Creator account',cosmetic.owner_id),fact('Publication',cosmetic.listed?'Public · Discover after approval':'Unlisted · link only after approval'));\n  for(const identity of cosmetic.creator_identities || [])facts.append(fact(providerNames[identity.provider],`${identity.display_name} · ${identity.provider_id}`));meta.append(facts);\n  if(cosmetic.reason)meta.append(node('p','review-feedback',`Decision note: ${cosmetic.reason}`));\n  const recipe=node('details','recipe-details');recipe.append(node('summary','','Inspect full Flare recipe'),node('pre','',JSON.stringify(cosmetic.recipe,null,2)));meta.append(recipe);\n  if(cosmetic.status!=='rejected'){\n    const label=node('label','',cosmetic.status==='approved'?'Revocation reason (required)':'Review note (required for disapproval)'),note=node('textarea');note.rows=3;note.maxLength=400;note.placeholder='Artwork rights, readability, flashing, or other feedback…';label.append(note);meta.append(label);\n    const actions=node('div','card-actions');\n    const decisions=cosmetic.status==='pending'?[['approved','Approve ✓','button primary'],['rejected','Disapprove','button danger']]:[['rejected','Revoke approval','button danger']];\n    for(const [status,title,cls] of decisions){\n      const button=action(title,async()=>{\n        if(status==='rejected'&&note.value.trim().length<3)throw new Error('Add a reason of at least 3 characters.');\n        if(cosmetic.status==='approved'&&!window.confirm('Revoke this Flare? It will leave the public API and be unequipped for all users.'))return;\n        for(const item of actions.querySelectorAll('button'))item.disabled=true;\n        try{await api('/api/admin/review',{id:cosmetic.id,status,reason:note.value.trim()});toast(status==='approved'?`Flare approved${cosmetic.listed?' and listed in Discover':' · unlisted link ready'}.`:'Flare disapproved with feedback.');await loadReviews();await loadAudit();await loadCatalog();}\n        catch(error){for(const item of actions.querySelectorAll('button'))item.disabled=cosmetic.owner_id===state.user?.id;throw error;}\n      },cls);button.disabled=cosmetic.owner_id===state.user.id;actions.append(button);\n    }\n    if(cosmetic.owner_id===state.user.id)meta.append(node('p','form-footnote','Another reviewer must decide on your own Flare.'));meta.append(actions);\n  }\n  article.append(preview,controls,meta);return article;\n}\nlet reviewRequest=0,userRequest=0;\nasync function loadReviews() {\n  const grid=$('#review-results');if(!grid)return;\n  const request=++reviewRequest,userId=state.user?.id;\n  const result=await api(`/api/admin/queue?status=${state.reviewStatus}&q=${encodeURIComponent(state.reviewQuery)}&offset=${state.reviewOffset}`);\n  if(request!==reviewRequest || state.view!=='moderation' || state.user?.id!==userId || $('#review-results')!==grid)return;\n  const stats=$('#review-stats');stats.replaceChildren();\n  for(const [key,title] of [['pending','Awaiting review'],['approved','Approved Flares'],['rejected','Disapproved Flares'],['creators','Published creators']]){const panel=node('div','panel');panel.append(node('strong','',String(result.stats[key])),document.createTextNode(title));stats.append(panel);}\n  $('#mod-badge').textContent=result.stats.pending;$('#mod-badge').hidden=result.stats.pending===0;\n  state.config.stats.approved=result.stats.approved;state.config.stats.public=result.stats.public;$('#approved-count').textContent=result.stats.public;\n  if(result.cosmetics.length)grid.replaceChildren(...result.cosmetics.map(reviewCard));else empty(grid,'No Flares in this view.','Try another status or search. Refresh to check new submissions.',null);\n  for(const button of document.querySelectorAll('[data-review-status]')){const selected=button.dataset.reviewStatus===state.reviewStatus;button.classList.toggle('active',selected);button.setAttribute('aria-pressed',String(selected));}\n  const paging=$('#review-pagination');paging.replaceChildren(node('span','form-footnote',`Page ${Math.floor(state.reviewOffset/50)+1} · ${result.cosmetics.length} shown`));\n  if(state.reviewOffset>0)paging.append(action('← Previous',()=>{state.reviewOffset=Math.max(0,state.reviewOffset-50);return loadReviews();}));\n  if(result.next_offset!==null)paging.append(action('Next →',()=>{state.reviewOffset=result.next_offset;return loadReviews();}));\n}\nasync function changeModerator(twitchId,enabled) {\n  if(!enabled&&!window.confirm('Remove this user’s moderation access? This takes effect immediately.'))return;\n  await api('/api/admin/moderators',{twitchId,enabled});toast(enabled?'Moderator added.':'Moderator access removed.');await loadUsers();await loadAudit();\n}\nasync function loadUsers() {\n  const container=$('#admin-user-results');if(!container)return;\n  const request=++userRequest,userId=state.user?.id,result=await api(`/api/admin/users?q=${encodeURIComponent(state.userQuery)}&offset=${state.userOffset}`);\n  if(request!==userRequest || state.view!=='moderation' || state.user?.id!==userId || $('#admin-user-results')!==container)return;\n  const table=node('table','admin-table');table.append(node('caption','sr-only','Registered accounts and moderation access'));\n  const head=node('thead'),headers=node('tr');for(const title of ['Account','Connected platforms','Joined','Role','Access']){const cell=node('th','',title);cell.scope='col';headers.append(cell);}head.append(headers);table.append(head);const body=node('tbody');\n  for(const user of result.users){\n    const row=node('tr'),profile=node('td'),person=node('div','table-person'),detail=node('div');detail.append(node('strong','',user.name),node('small','',user.id));person.append(avatar(user),detail);profile.append(person);\n    const platforms=node('td');for(const identity of user.identities)platforms.append(node('div','table-identity',`${providerNames[identity.provider]} · ${identity.login} · ${identity.provider_id}`));\n    const access=node('td');if(user.role==='owner')access.append(node('span','form-footnote','Protected owner'));else if(user.twitch_id)access.append(action(user.role==='moderator'?'Remove moderator':'Make moderator',()=>changeModerator(user.twitch_id,user.role!=='moderator'),user.role==='moderator'?'button danger':'button subtle'));else access.append(node('span','form-footnote','Link Twitch to grant reviewer access'));\n    const role=node('td');role.append(node('span','badge',user.role));row.append(profile,platforms,node('td','',date(user.created_at)),role,access);body.append(row);\n  }\n  table.append(body);container.replaceChildren(table);if(!result.users.length)container.append(node('p','form-footnote','No matching registered accounts.'));\n  const paging=$('#user-pagination');paging.replaceChildren(node('span','form-footnote',`Page ${Math.floor(state.userOffset/50)+1} · ${result.users.length} accounts shown`));\n  if(state.userOffset>0)paging.append(action('← Previous',()=>{state.userOffset=Math.max(0,state.userOffset-50);return loadUsers();}));\n  if(result.next_offset!==null)paging.append(action('Next →',()=>{state.userOffset=result.next_offset;return loadUsers();}));\n}\nasync function loadAudit() {\n  const panel=$('#moderation-audit');if(!panel)return;const userId=state.user?.id,audit=await api('/api/admin/audit');\n  if(state.view!=='moderation' || state.user?.id!==userId || $('#moderation-audit')!==panel)return;\n  panel.replaceChildren(node('h2','','Decision & access history'));\n  if(!audit.entries.length)panel.append(node('p','form-footnote','Review decisions and moderator changes appear here.'));\n  for(const entry of audit.entries){const row=node('div','audit-row',`${entry.actor} · ${entry.action}`);row.append(node('small','',`${date(entry.created_at)} · ${entry.target}`));if(entry.detail)row.append(node('div','',entry.detail));panel.append(row);}\n}\nasync function renderModeration() {\n  ++reviewRequest;++userRequest;\n  const container=$('#moderation-content');\n  if(!['owner','moderator'].includes(state.user?.role)){empty(container,'This space is for reviewers.','The owner grants moderation access to registered, verified Twitch accounts.','#account','Go to your account ↗');return;}\n  container.replaceChildren();const stats=node('div','mod-stats');stats.id='review-stats';container.append(stats);\n  const toolbar=node('div','moderation-toolbar'),tabs=node('div','filters');tabs.setAttribute('role','group');tabs.setAttribute('aria-label','Review status');\n  for(const [status,title] of [['pending','Pending'],['approved','Approved'],['rejected','Disapproved']]){const button=action(title,()=>{state.reviewStatus=status;state.reviewOffset=0;return loadReviews();});button.dataset.reviewStatus=status;tabs.append(button);}\n  const label=node('label','review-search','Search paints or creators'),search=node('input');search.type='search';search.value=state.reviewQuery;search.placeholder='Paint name or creator';label.append(search);let timer;\n  search.addEventListener('input',()=>{clearTimeout(timer);state.reviewQuery=search.value;state.reviewOffset=0;timer=setTimeout(()=>run(loadReviews),250);});\n  toolbar.append(tabs,label,action('Refresh',async()=>{await refreshAccount();if(!['owner','moderator'].includes(state.user?.role))return renderModeration();await loadReviews();await loadAudit();}));container.append(toolbar,node('p','form-footnote','Inspect artwork rights, readability at chat size, impersonation, hateful content, and flashing. Another reviewer must decide on your own designs. Disapproval requires a reason; revoking an approved paint also clears equipped assignments.'));\n  const grid=node('div','review-grid');grid.id='review-results';grid.setAttribute('aria-live','polite');const paging=node('div','dashboard-pagination');paging.id='review-pagination';container.append(grid,paging);\n  if(state.user.role==='owner'){\n    const panel=node('section','panel moderator-panel');panel.append(node('h2','','Accounts & moderation team'),node('p','form-footnote','Only you can grant or remove reviewer access. Anyone can create an account through an enabled provider; moderators must link Twitch first. Owner privileges cannot be changed here.'));\n    const form=node('form','moderator-form'),label=node('label','','Add moderator by Twitch ID'),input=node('input');input.required=true;input.pattern='[0-9]{1,30}';input.inputMode='numeric';input.placeholder=state.config.demo?'1002 (demo creator)':'Numeric Twitch user ID';label.append(input);\n    const add=node('button','button primary','Add moderator');add.type='submit';form.append(label,add);form.addEventListener('submit',event=>{event.preventDefault();run(async()=>{add.disabled=true;try{await changeModerator(input.value.trim(),true);input.value='';}finally{add.disabled=false;}});});panel.append(form);\n    const findLabel=node('label','','Find registered users'),find=node('input');find.type='search';find.value=state.userQuery;find.placeholder='Name, platform login, or exact platform ID';findLabel.append(find);let userTimer;\n    find.addEventListener('input',()=>{clearTimeout(userTimer);state.userQuery=find.value;state.userOffset=0;userTimer=setTimeout(()=>run(loadUsers),250);});panel.append(findLabel);\n    const table=node('div','table-scroll');table.id='admin-user-results';const userPaging=node('div','dashboard-pagination');userPaging.id='user-pagination';panel.append(table,userPaging);container.append(panel);\n  }\n  const audit=node('section','panel audit-panel');audit.id='moderation-audit';container.append(audit);\n  await Promise.all([loadReviews(),loadAudit(),...(state.user.role==='owner'?[loadUsers()]:[])]);\n}\nasync function renderView() {\n  const available=['discover','studio','collection','account','moderation','integrations','guide'];const [requested,chapter]=(location.hash.slice(1) || (location.pathname==='/guide'?'guide':'')).split('/');state.view=available.includes(requested) ? requested:'discover';\n  for(const view of document.querySelectorAll('.view'))view.hidden=view.id!==`view-${state.view}`;\n  for(const nav of document.querySelectorAll('[data-nav]')){const current=nav.dataset.nav===state.view;nav.classList.toggle('active',current);if(current)nav.setAttribute('aria-current','page');else nav.removeAttribute('aria-current');}\n  $('#page-label').textContent={discover:'Discover',studio:'Flare studio',collection:'My collection',account:'My account',moderation:'Moderation',integrations:'Integrations & API',guide:'Guide'}[state.view];\n  if(!chapter){if($('#detail-dialog').open)$('#detail-dialog').close();window.scrollTo({top:0});}\n  if(state.view==='discover') {\n    await loadCatalog();\n    if(chapter){const hash=location.hash;try{const result=await api(`/api/v1/cosmetics/${encodeURIComponent(chapter)}`);if(location.hash===hash)await showDetails(result.cosmetic);}catch(error){if(location.hash===hash){$('#detail-dialog').close();location.hash='discover';toast('This Flare is unavailable or not approved.',true);}}}\n  }\n  if(state.view!=='discover' && $('#detail-dialog').open)$('#detail-dialog').close();\n  if(state.view==='guide' && chapter)document.getElementById(`guide-${chapter}`)?.scrollIntoView({block:'start'});\n  if(state.view==='collection')await renderCollection();if(state.view==='account')await renderAccount();if(state.view==='moderation')await renderModeration();\n}\nwindow.addEventListener('hashchange',() => run(renderView));\n$('#overlay-copy').addEventListener('click',() => run(async() => {const channel=$('#overlay-channel').value.trim().toLowerCase();if(!/^[a-z0-9_]{1,25}$/.test(channel))throw new Error('Enter a valid Twitch channel name.');const url=`${location.origin}/overlay?channel=${encodeURIComponent(channel)}`;await navigator.clipboard.writeText(url);toast('OBS browser-source URL copied.');}));\ndocument.addEventListener('keydown',event => {if(event.key==='/'&&state.view==='discover'&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)&&!document.querySelector('dialog[open]')){event.preventDefault();$('#catalog-search').focus();}});\nasync function init(){\n  state.config=await api('/api/config');$('#demo-banner').hidden=!state.config.demo;$('#approved-count').textContent=state.config.stats.public ?? state.config.stats.approved;$('#api-base').textContent=state.config.officialApi;\n  applyCosmetic($('#hero-name'),{recipe:defaultRecipe});\n  await refreshAccount();await renderView();\n  try{const id=sessionStorage.getItem('nameflare.equip');if(state.user && id){sessionStorage.removeItem('nameflare.equip');await equip(id);location.hash='account';}}catch(error){toast(error.message,true);}\n}\nrun(init);\n","public/editor.js":"import { applyCosmetic, updateCosmeticText, createFrameUpdate } from './renderer.js';\nimport { normalizeLayers, normalizeShadows, legacyLayer, convert7TVPaint, MAX_TEXTURE_BYTES, isColor } from './paints.js';\nconst $ = s => document.querySelector(s);\nconst clone = value => JSON.parse(JSON.stringify(value));\nexport const defaultRecipe = { colors:['#ff1a35','#ff7185','#ffd4dc'],animation:'shimmer',speed:5,angle:120,glow:5,glowColor:'#ff1a35',shadowX:0,shadowY:0,shadowBlur:0,shadowOpacity:0,shadowColor:'#000000',outline:0,outlineColor:'#ffffff',depth:0,depthAngle:45,depthColor:'#4b1423',fontWeight:800 };\nconst defaults = defaultRecipe;\n// Keep dormant settings while changing type; publishing still strips fields through normalizeLayers.\nexport function changeLayerType(layer, type, fallback = defaultRecipe) {\n  return { ...legacyLayer({...fallback,textureId:undefined}), ...layer, type, color:layer.color || fallback.colors[0] };\n}\nexport function moveLayer(layers, layer, direction) {\n  const from=layers.indexOf(layer),to=from+direction;\n  if(from<0 || to<0 || to>=layers.length)return from;\n  [layers[from],layers[to]]=[layers[to],layers[from]];\n  return to;\n}\nconst presets = {\n  crimson:{title:'Crimson signal',recipe:{...defaults}},\n  aurora:{title:'Aurora drift',recipe:{...defaults,colors:['#71f5bd','#82b8ff','#b798ff'],animation:'flow',glowColor:'#71f5bd'}},\n  neon:{title:'Neon arcade',recipe:{...defaults,colors:['#fd75d6','#83c5ff','#d79cff'],glow:12,glowColor:'#df75ff',outline:.3,outlineColor:'#ffbef2',animation:'pulse',shadows:[{x:2,y:2,blur:8,color:'#ff1a3580'},{x:-2,y:-1,blur:6,color:'#866bff90'}]}},\n  gold:{title:'Golden dimension',recipe:{...defaults,colors:['#fff3bd','#e0ac56','#fff5d8'],glow:3,glowColor:'#f2c779',depth:6,depthColor:'#705123',depthAngle:70,animation:'shimmer',speed:7}},\n  shadow:{title:'After hours',recipe:{...defaults,colors:['#b0a4ff','#9290ff','#e5b3ff'],glow:0,shadowX:3,shadowY:4,shadowBlur:5,shadowOpacity:85,shadowColor:'#6b4194',depth:2,depthColor:'#463465'}},\n  ice:{title:'Ice chrome',recipe:{...defaults,colors:['#c4f6ff','#73adef','#f3ffff'],glow:5,glowColor:'#b4edff',outline:.5,outlineColor:'#d1faff',depth:3,depthColor:'#395c88',animation:'shimmer'}}\n};\nconst numericKeys=['speed','angle','glow','shadowX','shadowY','shadowBlur','shadowOpacity','outline','depth','depthAngle','fontWeight'];\nconst colorKeys=['glowColor','shadowColor','outlineColor','depthColor'];\nconst el=(tag,cls,text) => {const e=document.createElement(tag);if(cls)e.className=cls;if(text!==undefined)e.textContent=text;return e;};\nexport function initializeEditor({api,getUser,signIn,toast,onSubmitted}) {\n  const form=$('#studio-form'),history=[],assets=new Map();let index=-1,paused=false,layers=[legacyLayer(defaults)],shadows=[],activeLayer=0,uploadBusy=false;\n  const run=fn => Promise.resolve().then(fn).catch(error=>toast(error.message,true));\n  const button=(text,fn,cls='button subtle')=>{const b=el('button',cls,text);b.type='button';b.addEventListener('click',()=>run(fn));return b;};\n  const label=(text,input)=>{const l=el('label','',text);l.append(input);return l;};\n  function range(value,min,max,step,update){const input=el('input');input.type='range';input.min=min;input.max=max;input.step=step;input.value=value;input.addEventListener('input',()=>update(Number(input.value)));input.addEventListener('change',remember);return input;}\n  function number(value,min,max,step,update){const input=el('input');input.type='number';input.min=min;input.max=max;input.step=step;input.value=value;input.addEventListener('input',()=>{if(input.value!==''&&input.validity.valid)update(Number(input.value));});input.addEventListener('change',remember);return input;}\n  function select(value,options,update){const input=el('select');for(const [v,t]of options){const option=el('option','',t);option.value=v;input.append(option);}input.value=value;input.addEventListener('change',()=>{update(input.value);remember();});return input;}\n  function colorControl(value,update){\n    const box=el('div','rgba-color');const picker=el('input');picker.type='color';picker.value=value.slice(0,7);\n    const hex=el('input');hex.type='text';hex.value=value;hex.maxLength=9;hex.setAttribute('aria-label','Hex color including optional alpha');hex.pattern='#[a-fA-F0-9]{6}([a-fA-F0-9]{2})?';\n    const alpha=range(value.length===9?parseInt(value.slice(7),16):255,0,255,1,a=>{const next=picker.value+Math.round(a).toString(16).padStart(2,'0');hex.value=next;update(next);});alpha.setAttribute('aria-label','Color alpha');\n    picker.addEventListener('input',()=>{const next=picker.value+(hex.value.length===9?hex.value.slice(7):'');hex.value=next;update(next);});picker.addEventListener('change',remember);\n    hex.addEventListener('input',()=>{if(isColor(hex.value)){picker.value=hex.value.slice(0,7);alpha.value=hex.value.length===9?parseInt(hex.value.slice(7),16):255;update(hex.value);}});hex.addEventListener('change',remember);\n    box.append(picker,hex,alpha);return box;\n  }\n  function design(){return {recipe:recipe(),layers:clone(layers)};}\n  function recipe(){const r={colors:[1,2,3].map(n=>form.elements[`color${n}`].value),animation:form.elements.animation.value};for(const key of numericKeys)r[key]=Number(form.elements[key].value);for(const key of colorKeys)r[key]=form.elements[key].value;r.layers=normalizeLayers(layers,{allowEmptyImages:true});r.shadows=clone(shadows);return r;}\n  function remember(){const serialized=JSON.stringify(design());if(history[index]===serialized)return;history.splice(index+1);history.push(serialized);if(history.length>50)history.shift();index=history.length-1;historyButtons();}\n  function historyButtons(){$('#undo-design').disabled=index<=0;$('#redo-design').disabled=index>=history.length-1;}\n  function textureMap(){return Object.fromEntries([...assets].map(([id,a])=>[id,a.url]));}\n  const previewNames = ['#studio-name','#studio-chat-name'].map(selector => $(selector));\n  const updatePreviewName = createFrameUpdate(() => {\n    const name = $('#preview-username').value || 'yourname';\n    for (const target of previewNames) updateCosmeticText(target, name);\n  });\n  function update(){\n    const r=recipe(),name=$('#preview-username').value || 'yourname';\n    // Missing imported image layers use a gradient for preview, but cannot be published.\n    const safeLayers=layers.map(l=>l.type==='image'&&!l.textureId ? legacyLayer({...r,textureId:undefined}):l);\n    for(const selector of ['#studio-name','#studio-chat-name']){const target=$(selector);target.textContent=name;applyCosmetic(target,{recipe:{...r,layers:safeLayers}},location.origin,{previewTextures:textureMap(),paused});}\n    const units={speed:'s',angle:'°',glow:'px',shadowX:'px',shadowY:'px',shadowBlur:'px',shadowOpacity:'%',outline:'px',depth:' layers',depthAngle:'°',fontWeight:''};\n    for(const key of numericKeys)$(`#${key}-value`).textContent=`${r[key]}${units[key]}`;\n    $('#layer-count').textContent=`${layers.length} layer${layers.length===1?'':'s'} · ${paused?'paused':'live'}`;\n    $('#add-layer').disabled=layers.length>=6;$('#add-shadow').disabled=shadows.length>=8;\n    syncPreview();updateTextureDisplay();\n  }\n  function syncPreview(){\n    const size=Number($('#preview-chat-size').value),zoom=Number($('#preview-size').value);\n    for(const selector of ['#studio-name','#studio-chat-name'])$(selector).style.fontSize=`${size}px`;\n    $('.mini-chat').style.fontSize=`${size}px`;\n    $('#studio-name').style.transform=`scale(${zoom})`;\n    $('#preview-chat-size-value').textContent=`${size}px`;$('#preview-size-value').textContent=`${zoom}×`;\n  }\n  function setRecipe(input,record=true,{clearTextures=true}={}){\n    if(!input||typeof input!=='object'||!Array.isArray(input.colors)||input.colors.length<2||input.colors.length>5||!input.colors.every(c=>/^#[a-f0-9]{6}$/i.test(c)))throw new Error('Design needs 2–5 hex fallback colors.');\n    if(!['flow','pulse','shimmer','none'].includes(input.animation))throw new Error('Unknown animation.');\n    const merged={...defaults,...input};\n    for(const key of numericKeys){const v=merged[key],control=form.elements[key];if(!Number.isFinite(v)||v<Number(control.min)||v>Number(control.max)||(key==='depth'&&!Number.isInteger(v)))throw new Error(`Invalid ${key}.`);}\n    for(const key of colorKeys)if(!/^#[a-f0-9]{6}$/i.test(merged[key]))throw new Error(`Invalid ${key}.`);\n    const normalized=normalizeLayers(input.layers || [legacyLayer(input)],{allowEmptyImages:true});const normalizedShadows=normalizeShadows(input.shadows || []);\n    layers=normalized.map((l,i)=>{const layer=clone(l);if(clearTextures&&layer.type==='image')delete layer.textureId;else if(!clearTextures)Object.assign(layer,clone(input.layers?.[i] || l));return layer;});shadows=normalizedShadows;activeLayer=0;\n    for(let i=1;i<=3;i++)form.elements[`color${i}`].value=input.colors[i-1] || input.colors.at(-1);\n    for(const key of [...numericKeys,...colorKeys,'animation'])form.elements[key].value=merged[key];\n    renderLayers();renderShadows();update();if(record)remember();\n  }\n  function renderLayers(){\n    const list=$('#layer-list');list.replaceChildren();\n    layers.forEach((layer,i)=>{\n      const card=el('details','paint-layer');card.open=i===activeLayer;\n      const summary=el('summary');summary.append(el('span','layer-number',String(i+1).padStart(2,'0')),el('span','',({linear:'Linear gradient',radial:'Radial gradient',conic:'Conic gradient',solid:'Solid color',image:'GIF / image'}[layer.type])),el('span','layer-opacity',`${Math.round(layer.opacity*100)}%`));card.append(summary);\n      card.addEventListener('toggle',()=>{if(card.isConnected&&card.open&&layers.includes(layer)){activeLayer=layers.indexOf(layer);updateTextureDisplay();}});\n      const body=el('div','layer-body'),actions=el('div','layer-actions');\n      actions.append(button('↑',()=>{if(i===0)return;activeLayer=moveLayer(layers,layer,-1);renderLayers();update();remember();}),button('↓',()=>{if(i===layers.length-1)return;activeLayer=moveLayer(layers,layer,1);renderLayers();update();remember();}),button('Duplicate',()=>{if(layers.length>=6)throw new Error('Maximum 6 layers.');layers.splice(i,0,clone(layer));activeLayer=i;renderLayers();update();remember();}),button('Remove',()=>{if(layers.length===1)throw new Error('Keep at least one paint layer.');layers.splice(i,1);activeLayer=Math.max(0,i-1);renderLayers();update();remember();},'button danger'));\n      actions.querySelector('button').disabled=i===0;actions.querySelectorAll('button')[1].disabled=i===layers.length-1;body.append(actions);\n      body.append(label('Layer type',select(layer.type,[['linear','Linear gradient'],['radial','Radial gradient'],['conic','Conic gradient'],['solid','Solid color'],['image','GIF / image']],type=>{const replacement=changeLayerType(layer,type,recipe());layers[i]=replacement;activeLayer=i;renderLayers();update();})),label('Opacity',range(layer.opacity,0,1,.01,v=>{layer.opacity=v;summary.querySelector('.layer-opacity').textContent=`${Math.round(v*100)}%`;update();})));\n      if(layer.type==='solid')body.append(label('Fill color',colorControl(layer.color,v=>{layer.color=v;update();})));\n      if(['linear','radial','conic'].includes(layer.type)){\n        const row=el('div','form-row');row.append(label('Angle',number(layer.angle,0,360,1,v=>{layer.angle=v;update();})),label('Repeat gradient',select(String(layer.repeat),[['false','Smooth fill'],['true','Repeating bands']],v=>{layer.repeat=v==='true';update();})));body.append(row);\n        if(layer.type==='radial')body.append(label('Shape',select(layer.shape,[['ellipse','Ellipse'],['circle','Circle']],v=>{layer.shape=v;update();})));\n        const stops=el('div','gradient-stops');\n        layer.stops.forEach((stop,j)=>{\n          const row=el('div','stop-row');row.append(label(`Stop ${j+1}`,colorControl(stop.color,v=>{stop.color=v;update();})),label('Position %',number(Math.round(stop.at*100),0,100,1,v=>{stop.at=v/100;update();})),button('×',()=>{if(layer.stops.length<=2)throw new Error('A gradient needs at least two stops.');layer.stops.splice(j,1);activeLayer=i;renderLayers();update();remember();}));row.lastChild.setAttribute('aria-label',`Remove stop ${j+1}`);stops.append(row);\n        });body.append(stops,button('＋ Add color stop',()=>{if(layer.stops.length>=12)throw new Error('Maximum 12 stops per gradient.');layer.stops.push({at:.5,color:'#ffffff80'});layer.stops.sort((a,b)=>a.at-b.at);activeLayer=i;renderLayers();update();remember();}));\n      }\n      if(layer.type==='image')body.append(el('p','form-footnote',layer.textureId?'Image selected. Choose a new file below to replace it.':'Select this layer, then drop a GIF below. This image layer must be filled before submission.'));\n      const sizing=el('details','layer-sizing');sizing.append(el('summary','','Scale, position & tiling'));\n      const sizeRow=el('div','form-row'),positionRow=el('div','form-row');\n      for(const [axis,title]of [[0,'Width %'],[1,'Height %']])sizeRow.append(label(title,number(layer.size[axis],1,500,1,v=>{layer.size[axis]=v;update();})));\n      for(const [axis,title]of [[0,'Position X %'],[1,'Position Y %']])positionRow.append(label(title,number(layer.at[axis],0,100,1,v=>{layer.at[axis]=v;update();})));\n      sizing.append(sizeRow,positionRow,label('Canvas repeat',select(layer.canvasRepeat,[['no-repeat','No repeat'],['repeat','Tile both'],['repeat-x','Tile horizontal'],['repeat-y','Tile vertical']],v=>{layer.canvasRepeat=v;update();})));body.append(sizing);card.append(body);list.append(card);\n    });\n  }\n  function renderShadows(){const list=$('#shadow-list');list.replaceChildren();shadows.forEach((s,i)=>{const card=el('div','shadow-card');card.append(el('h3','',`Shadow ${i+1}`),label('RGBA color',colorControl(s.color,v=>{s.color=v;update();})));const row=el('div','shadow-controls');for(const [key,title,min,max]of [['x','X',-20,20],['y','Y',-20,20],['blur','Blur',0,30]])row.append(label(title,number(s[key],min,max,1,v=>{s[key]=v;update();})));card.append(row,button('Remove shadow',()=>{shadows.splice(i,1);renderShadows();update();remember();},'text-button'));list.append(card);});}\n  function switchTab(tab){\n    const tabs=[...document.querySelectorAll('[data-editor-tab]')];for(const b of tabs){const active=b.dataset.editorTab===tab;b.setAttribute('aria-selected',String(active));b.tabIndex=active?0:-1;}\n    for(const panel of document.querySelectorAll('.editor-section'))panel.hidden=panel.id!==`editor-${tab}`;\n  }\n  for(const b of document.querySelectorAll('[data-editor-tab]')){b.addEventListener('click',()=>switchTab(b.dataset.editorTab));b.addEventListener('keydown',event=>{const tabs=[...document.querySelectorAll('[data-editor-tab]')];let next=tabs.indexOf(b);if(event.key==='ArrowRight')next=(next+1)%tabs.length;else if(event.key==='ArrowLeft')next=(next+tabs.length-1)%tabs.length;else if(event.key==='Home')next=0;else if(event.key==='End')next=tabs.length-1;else return;event.preventDefault();switchTab(tabs[next].dataset.editorTab);tabs[next].focus();});}\n  for(const b of document.querySelectorAll('[data-next-tab]'))b.addEventListener('click',()=>{switchTab(b.dataset.nextTab);$('#studio-form').scrollIntoView({block:'start',behavior:'smooth'});});\n  $('#add-layer').addEventListener('click',()=>run(()=>{if(layers.length>=6)return;layers.push({...legacyLayer(recipe()),opacity:.65});activeLayer=layers.length-1;renderLayers();update();remember();}));\n  $('#add-shadow').addEventListener('click',()=>{if(shadows.length>=8)return;shadows.push({x:0,y:0,blur:8,color:'#ff1a3580'});renderShadows();update();remember();});\n  function preset(key){setRecipe(presets[key].recipe);$('#preset-select').value=key;for(const b of $('#preset-gallery').children)b.classList.toggle('active',b.dataset.preset===key);}\n  for(const [key,p]of Object.entries(presets)){const b=button('',()=>preset(key),'preset-tile');b.dataset.preset=key;const name=el('strong','','Aa');applyCosmetic(name,{recipe:p.recipe});b.append(name,el('span','',p.title));$('#preset-gallery').append(b);}\n  $('#preset-select').addEventListener('change',event=>run(()=>preset(event.target.value)));\n  $('#randomize-design').addEventListener('click',()=>run(()=>{const palettes=[['#ff1a35','#ff9c6b','#ffc6df'],['#ff74d3','#8e79ff','#e9aaff'],['#82e9ff','#487bff','#e0f9ff'],['#f4b649','#ff655f','#fff0b6']];const colors=palettes[Math.floor(Math.random()*palettes.length)];setRecipe({...defaults,colors,glowColor:colors[0],angle:Math.floor(Math.random()*360),depth:Math.floor(Math.random()*5),animation:['flow','shimmer','pulse'][Math.floor(Math.random()*3)]});toast('A fresh starting point. Make it yours.');}));\n  function restore(direction){const next=index+direction;if(next<0||next>=history.length)return;const saved=JSON.parse(history[next]);setRecipe({...saved.recipe,layers:saved.layers},false,{clearTextures:false});index=next;historyButtons();}\n  $('#undo-design').addEventListener('click',()=>run(()=>restore(-1)));$('#redo-design').addEventListener('click',()=>run(()=>restore(1)));\n  form.addEventListener('input',event=>{if(event.target.name&&[...numericKeys,...colorKeys,'animation','color1','color2','color3'].includes(event.target.name))update();});\n  form.addEventListener('change',event=>{if(event.target.name&&[...numericKeys,...colorKeys,'animation','color1','color2','color3'].includes(event.target.name))remember();});\n  $('#preview-username').addEventListener('input',updatePreviewName);$('#preview-size').addEventListener('input',syncPreview);$('#preview-chat-size').addEventListener('input',syncPreview);\n  $('#preview-background').addEventListener('change',event=>{$('#preview-stage').classList.remove('light','transparent');if(event.target.value!=='dark')$('#preview-stage').classList.add(event.target.value);});\n  $('#pause-preview').addEventListener('click',()=>{paused=!paused;$('#pause-preview').setAttribute('aria-pressed',String(paused));$('#pause-preview').textContent=paused?'▶ Resume motion':'Ⅱ Pause motion';$('#preview-caption').textContent=paused?'MOTION PAUSED · GIF FILL USES STATIC FALLBACK':'CHAT RENDER · MAGNIFIED, NOT RE-STYLED';update();});\n  $('#save-draft').addEventListener('click',()=>run(()=>{localStorage.setItem('nameflare.draft.v1',JSON.stringify({...design(),name:form.elements.name.value,description:form.elements.description.value,listed:form.elements.listed.value==='public'}));$('#draft-status').textContent=`Saved at ${new Date().toLocaleTimeString()}. Reattach image files after reload.`;toast('Draft saved in this browser.');}));\n  $('#export-design').addEventListener('click',()=>run(()=>{const url=URL.createObjectURL(new Blob([JSON.stringify({format:'nameflare-design',version:1,name:form.elements.name.value,description:form.elements.description.value,listed:form.elements.listed.value==='public',...design()},null,2)],{type:'application/json'}));const a=el('a');a.href=url;a.download='nameflare-design.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('Design exported. Image files are separate.');}));\n  $('#import-design').addEventListener('change',event=>run(async()=>{const file=event.target.files[0];if(!file)return;if(file.size>128000)throw new Error('Paint JSON must be under 128 KB.');const imported=JSON.parse(await file.text());let converted;if(imported.format==='nameflare-design'){if(imported.version!==1)throw new Error('Unsupported Nameflare design version.');converted={recipe:{...imported.recipe,layers:imported.layers || imported.recipe.layers},name:imported.name,warnings:[]};}else converted=convert7TVPaint(imported);setRecipe(converted.recipe);form.elements.name.value=String(converted.name||'Imported Flare').slice(0,40);form.elements.description.value=String(imported.description||'').slice(0,400);form.elements.listed.value=imported.listed===false?'unlisted':'public';form.elements.rights.checked=false;const images=layers.filter(l=>l.type==='image').length;$('#import-status').textContent=[`Imported ${layers.length} layers and ${shadows.length} shadows.`,...converted.warnings,images?`Reattach ${images} image file(s) in the Paint tab.`:''].filter(Boolean).join(' ');switchTab('fill');event.target.value='';if(converted.warnings.length)$('#upload-feedback').textContent=converted.warnings.join(' ');toast(converted.warnings.length ? `Paint imported with ${converted.warnings.length} notice(s). See the import notes.`:'Flare imported as a new editable design.');}));\n  function updateTextureDisplay(){\n    const layer=layers[activeLayer],asset=assets.get(layer?.textureId),attached=layers.filter(l=>assets.has(l.textureId));\n    $('#texture-preview-row').hidden=!asset;\n    if(asset){$('#texture-preview').src=asset.url;$('#texture-filename').textContent=asset.file.name;$('#texture-status').textContent=`${(asset.file.size/1024).toFixed(0)} KB · layer ${activeLayer+1} · ${layer.type==='image'?'active image':'saved for image type'} · ${asset.uploadedId?'uploaded privately':'local preview only'}`;}\n    $('#texture-attachments').replaceChildren(...attached.map(l=>button(`${assets.get(l.textureId).file.name} · layer ${layers.indexOf(l)+1}${l.type==='image'?'':' (saved)'}`,()=>{activeLayer=layers.indexOf(l);renderLayers();updateTextureDisplay();})));\n  }\n  async function attachFile(file){\n    if(!file)return;if(file.size>MAX_TEXTURE_BYTES)throw new Error('Choose a GIF, WebP, or PNG under 2 MB.');\n    const extension=file.name.split('.').pop().toLowerCase(),mime={gif:'image/gif',webp:'image/webp',png:'image/png'}[extension];\n    if(!mime)throw new Error('Choose a GIF, WebP, or PNG file.');\n    if(layers[activeLayer]?.type!=='image'&&layers.length>=6)throw new Error('Six layers already. Select an image layer or remove a layer first.');\n    if(assets.size>=30)throw new Error('Local preview limit reached. Save your draft and reload before adding more image files.');\n    const selectedLayer=layers[activeLayer];\n    const bytes=await file.arrayBuffer(),view=new Uint8Array(bytes);\n    const signature=new TextDecoder().decode(view.slice(0,6));\n    if(extension==='gif'&&!['GIF87a','GIF89a'].includes(signature))throw new Error('This is not a valid GIF file.');\n    const url=URL.createObjectURL(new Blob([bytes],{type:mime}));\n    try{const image=new Image();image.src=url;await image.decode();if(image.naturalWidth>2048||image.naturalHeight>2048)throw new Error('Image canvas must be at most 2048 × 2048.');}\n    catch(error){URL.revokeObjectURL(url);throw new Error(error.message.includes('2048')?error.message:'The browser could not decode this image.');}\n    // File bytes are hashed locally; the authoritative server independently revalidates on upload.\n    const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');const id=`${hash}.${extension}`;\n    const old=assets.get(id);if(old)URL.revokeObjectURL(old.url);assets.set(id,{file,url,mime,uploadedId:old?.uploadedId});\n    const selectedIndex=layers.indexOf(selectedLayer);\n    if(selectedIndex<0)throw new Error('The selected layer was removed while the image was loading. Choose a layer and retry.');\n    if(selectedLayer.type!=='image'){\n      if(layers.length>=6)throw new Error('Six layers already. Select an image layer or remove a layer first.');\n      layers.unshift({type:'image',textureId:id,opacity:1,size:[100,100],at:[50,50],canvasRepeat:'no-repeat'});activeLayer=0;\n    }else {selectedLayer.textureId=id;activeLayer=selectedIndex;}\n    renderLayers();update();remember();$('#upload-feedback').textContent='GIF/image is previewing locally. Sign in and submit when you’re ready.';toast('Texture added. Keep shaping your paint.');\n  }\n  $('#texture-file').addEventListener('change',event=>run(()=>attachFile(event.target.files[0])));\n  const drop=$('#texture-drop');drop.addEventListener('click',event=>{if(event.target.id!=='texture-file')$('#texture-file').click();});drop.addEventListener('keydown',event=>{if(event.target===drop&&['Enter',' '].includes(event.key)){event.preventDefault();$('#texture-file').click();}});\n  for(const type of ['dragenter','dragover'])drop.addEventListener(type,event=>{event.preventDefault();drop.classList.add('dragging');});for(const type of ['dragleave','drop'])drop.addEventListener(type,event=>{event.preventDefault();drop.classList.remove('dragging');if(type==='drop')run(()=>attachFile(event.dataTransfer.files[0]));});\n  $('#clear-texture').addEventListener('click',()=>{const layer=layers[activeLayer];if(layer?.textureId){delete layer.textureId;renderLayers();update();remember();$('#texture-file').value='';}});\n  form.addEventListener('submit',event=>{event.preventDefault();run(async()=>{\n    if(uploadBusy)return;if(!getUser()){signIn();return;}switchTab('publish');\n    for(const key of ['name','description','rights'])if(!form.elements[key].reportValidity())return;\n    const submitted=recipe();normalizeLayers(submitted.layers);normalizeShadows(submitted.shadows);\n    if([...form.querySelectorAll('input')].some(input=>!input.validity.valid))throw new Error('Fix invalid hex colors, stop positions, or numeric fields in the Paint/Effects tabs.');\n    for(const layer of submitted.layers)if(layer.type==='image'&&!assets.has(layer.textureId))throw new Error('Reattach every image file before submitting.');\n    uploadBusy=true;const submit=$('#submit-cosmetic');submit.disabled=true;\n    try{\n      for(const id of new Set(submitted.layers.filter(l=>l.type==='image').map(l=>l.textureId))){const asset=assets.get(id);if(asset.uploadedId){for(const layer of submitted.layers)if(layer.textureId===id)layer.textureId=asset.uploadedId;continue;}submit.textContent='Uploading texture…';const base64=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.onerror=()=>reject(new Error('Could not read texture.'));reader.readAsDataURL(asset.file);});const result=await api('/api/textures',{data:`data:${asset.mime};base64,${base64}`});asset.uploadedId=result.id;for(const layer of submitted.layers)if(layer.textureId===id)layer.textureId=result.id;}\n      submit.textContent='Submitting for review…';await api('/api/cosmetics',{name:form.elements.name.value,description:form.elements.description.value,listed:form.elements.listed.value==='public',recipe:submitted});toast(form.elements.listed.value==='public'?'Flare submitted! It will appear in Discover after approval.':'Flare submitted! Share its link after approval.');onSubmitted();\n    }finally{uploadBusy=false;submit.disabled=false;submit.textContent='Submit for review ↗';updateTextureDisplay();}\n  });});\n  window.addEventListener('pagehide',()=>{for(const a of assets.values())URL.revokeObjectURL(a.url);});\n  preset('crimson');\n  try{const draft=JSON.parse(localStorage.getItem('nameflare.draft.v1')||'null');if(draft){setRecipe({...draft.recipe,layers:draft.layers || draft.recipe.layers});form.elements.name.value=String(draft.name||'').slice(0,40);form.elements.description.value=String(draft.description||'').slice(0,400);form.elements.listed.value=draft.listed===false?'unlisted':'public';$('#draft-status').textContent='Local draft restored. Reattach any image files.';}}catch{$('#draft-status').textContent='Unable to restore draft. Start fresh or import a design.';}\n  return { editCopy(cosmetic){setRecipe(cosmetic.recipe);form.elements.name.value=`${cosmetic.name.slice(0,33)} remix`;form.elements.description.value=cosmetic.description||'';form.elements.listed.value='public';form.elements.rights.checked=false;switchTab('fill');location.hash='studio';toast('New editable copy. Artwork rights and approval are not inherited.');}, accountChanged(){for(const a of assets.values())delete a.uploadedId;update();}, update };\n}\n","public/renderer.js":"// SPDX-License-Identifier: MIT OR AGPL-3.0-only\n// Copyright (c) 2026 Nameflare contributors. See SDK-LICENSE.md for the MIT option.\nimport { normalizeLayers, normalizeShadows, legacyLayer, layerBackground, rgbaColor, isTextureId } from './paints.js';\nconst hex = value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value);\nconst bounded = (value, fallback, min, max) => Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;\nconst visualLayers = new WeakMap();\n// Text-only edits must not recreate fills, reset animations, or reload GIFs.\nexport function updateCosmeticText(element, text) {\n  const value = String(text), children = visualLayers.get(element) || [];\n  if (element.firstChild?.nodeType === 3) {\n    if (element.firstChild.data !== value) element.firstChild.data = value;\n  } else if (children.length) element.insertBefore(document.createTextNode(value), element.firstChild);\n  else if (element.textContent !== value) element.textContent = value;\n  if (element.dataset.text !== undefined && element.dataset.text !== value) element.dataset.text = value;\n  for (const child of children) if (child.dataset.text !== value) child.dataset.text = value;\n}\n// Read the latest input at paint time; multiple input events share one render.\nexport function createFrameUpdate(update, requestFrame = callback => requestAnimationFrame(callback)) {\n  let pending = false;\n  return () => {\n    if (pending) return;\n    pending = true;\n    requestFrame(() => { pending = false; update(); });\n  };\n}\nexport function applyCosmetic(element, cosmetic, apiOrigin = location.origin, { previewTextures = {}, paused = false } = {}) {\n  for (const child of visualLayers.get(element) || []) child.remove();\n  visualLayers.delete(element);\n  element.classList.remove('nameflare-name','nameflare-complex');\n  for (const key of ['background-image','background-size','background-repeat','background-position','filter','font-weight','-webkit-text-stroke','--nameflare-speed','--nameflare-shadows']) element.style.removeProperty(key);\n  delete element.dataset.animation; delete element.dataset.text; delete element.dataset.paused;\n  if (!cosmetic?.recipe) return;\n  const r = cosmetic.recipe;\n  if (!Array.isArray(r.colors) || r.colors.length < 2 || r.colors.length > 5 || !r.colors.every(hex)) return;\n  if (!['flow','pulse','shimmer','none'].includes(r.animation) || ![r.angle,r.glow,r.speed].every(Number.isFinite)) return;\n  let layers, extraShadows;\n  try { layers=normalizeLayers(r.layers || [legacyLayer(r)]);extraShadows=normalizeShadows(r.shadows || []); }\n  catch { return; }\n  const text=element.textContent;\n  element.classList.add('nameflare-name');element.dataset.text=text;element.dataset.animation=r.animation;\n  element.dataset.paused=String(paused);element.style.setProperty('--nameflare-speed',`${bounded(r.speed,4,1,20)}s`);\n  if(r.fontWeight!==undefined)element.style.fontWeight=bounded(r.fontWeight,800,400,900);\n  const filters=[],glow=bounded(r.glow,0,0,20),glowColor=hex(r.glowColor) ? r.glowColor:r.colors[0];\n  if(glow)filters.push(`drop-shadow(0 0 ${glow}px ${glowColor})`);\n  for(const s of extraShadows)filters.push(`drop-shadow(${s.x}px ${s.y}px ${s.blur}px ${rgbaColor(s.color)})`);\n  if(filters.length)element.style.filter=filters.join(' ');\n  element.style.webkitTextStroke=`${bounded(r.outline,0,0,2)}px ${hex(r.outlineColor) ? r.outlineColor:'#ffffff'}`;\n  const shadows=[],depth=Math.floor(bounded(r.depth,0,0,8)),angle=bounded(r.depthAngle,45,0,360)*Math.PI/180,depthColor=hex(r.depthColor) ? r.depthColor:'#303047';\n  for(let i=1;i<=depth;i++)shadows.push(`${(Math.cos(angle)*i).toFixed(2)}px ${(Math.sin(angle)*i).toFixed(2)}px 0 ${depthColor}`);\n  if(bounded(r.shadowOpacity,0,0,100))shadows.push(`${bounded(r.shadowX,0,-12,12)}px ${bounded(r.shadowY,0,-12,12)}px ${bounded(r.shadowBlur,0,0,20)}px ${rgbaColor(hex(r.shadowColor) ? r.shadowColor:'#000000',bounded(r.shadowOpacity,0,0,100)/100)}`);\n  element.style.setProperty('--nameflare-shadows',shadows.join(',') || 'none');\n  const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n  const fallback=`linear-gradient(${bounded(r.angle,120,0,360)}deg, ${r.colors.join(', ')}, ${r.colors[0]})`;\n  const textureUrl=id => {\n    if(!isTextureId(id) || reduced || paused)return null;\n    const local=previewTextures[id];\n    if(typeof local==='string' && /^blob:/.test(local))return local;\n    try{const base=new URL(apiOrigin);if(['http:','https:'].includes(base.protocol))return `${base.origin}/textures/${id}`;}catch{}\n    return null;\n  };\n  const styleLayer=(target,layer) => {\n    target.style.backgroundImage=layerBackground(layer,textureUrl(layer.textureId)) || fallback;\n    target.style.backgroundSize=`${layer.size[0]}% ${layer.size[1]}%`;\n    target.style.backgroundRepeat=layer.canvasRepeat;\n    target.style.backgroundPosition=`${layer.at[0]}% ${layer.at[1]}%`;\n  };\n  if(r.layers && typeof document!=='undefined' && typeof element.append==='function') {\n    element.classList.add('nameflare-complex');element.style.backgroundImage=fallback;\n    const children=[];\n    for(const [i,layer] of layers.entries()){\n      const visual=document.createElement('span');visual.className='nameflare-paint-layer';visual.setAttribute('aria-hidden','true');visual.dataset.text=text;visual.style.zIndex=String(layers.length-i);visual.style.opacity=layer.opacity;styleLayer(visual,layer);element.append(visual);children.push(visual);\n    }\n    visualLayers.set(element,children);\n  } else styleLayer(element,layers[0]);\n}\n","public/paints.js":"// SPDX-License-Identifier: MIT OR AGPL-3.0-only\n// Copyright (c) 2026 Nameflare contributors. See SDK-LICENSE.md for the MIT option.\nexport const MAX_TEXTURE_BYTES = 2 * 1024 * 1024;\nexport const isColor = value => typeof value === 'string' && /^#[a-f0-9]{6}([a-f0-9]{2})?$/i.test(value);\nexport const isTextureId = value => typeof value === 'string' && /^[a-f0-9]{64}\\.(gif|webp|png)$/.test(value);\nconst object = value => value && typeof value === 'object' && !Array.isArray(value);\nconst check = (condition, message) => { if (!condition) throw new Error(message); };\nconst number = (value, min, max, label) => { check(typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max, `${label} must be between ${min} and ${max}.`); return value; };\nconst color = (value, label) => { check(isColor(value), `${label} must be a hex color (#RRGGBB or #RRGGBBAA).`); return value; };\nexport function normalizeLayers(layers, { allowEmptyImages = false } = {}) {\n  check(Array.isArray(layers) && layers.length >= 1 && layers.length <= 6, 'Use 1–6 paint layers.');\n  return layers.map((layer, i) => {\n    check(object(layer), 'Invalid paint layer.');\n    check(['solid','linear','radial','conic','image'].includes(layer.type), 'Unknown paint layer type.');\n    const result = { type:layer.type, opacity:number(layer.opacity ?? 1,0,1,'Layer opacity') };\n    if (layer.type === 'image') {\n      check(isTextureId(layer.textureId) || (allowEmptyImages && !layer.textureId), `Layer ${i+1} needs an uploaded GIF, WebP, or PNG.`);\n      if (layer.textureId) result.textureId = layer.textureId;\n    } else if (layer.type === 'solid') result.color = color(layer.color, 'Layer color');\n    else {\n      check(Array.isArray(layer.stops) && layer.stops.length >= 2 && layer.stops.length <= 12, 'Each gradient needs 2–12 stops.');\n      result.stops = layer.stops.map(stop => {\n        check(object(stop), 'Invalid gradient stop.');\n        return { at:number(stop.at,0,1,'Stop position'), color:color(stop.color,'Stop color') };\n      }).sort((a,b) => a.at-b.at);\n      check(result.stops[0].at < result.stops.at(-1).at, 'Gradient stops must span more than one position.');\n      check(layer.repeat === undefined || typeof layer.repeat === 'boolean', 'Gradient repeat must be boolean.');\n      result.repeat = layer.repeat ?? false;\n      result.angle = number(layer.angle ?? 90,0,360,'Gradient angle');\n      check(['circle','ellipse'].includes(layer.shape ?? 'ellipse'), 'Radial shape must be circle or ellipse.');\n      result.shape = layer.shape ?? 'ellipse';\n    }\n    const size = layer.size ?? [100,100], at = layer.at ?? [50,50];\n    check(Array.isArray(size) && size.length === 2 && Array.isArray(at) && at.length === 2, 'Layer size and position must be pairs.');\n    result.size = size.map(n => number(n,1,500,'Layer size (%)'));\n    result.at = at.map(n => number(n,0,100,'Layer position (%)'));\n    check(['no-repeat','repeat','repeat-x','repeat-y'].includes(layer.canvasRepeat ?? 'no-repeat'), 'Invalid canvas repeat.');\n    result.canvasRepeat = layer.canvasRepeat ?? 'no-repeat';\n    return result;\n  });\n}\nexport function normalizeShadows(shadows) {\n  check(Array.isArray(shadows) && shadows.length <= 8, 'Use up to 8 additional shadows.');\n  return shadows.map(shadow => {\n    check(object(shadow), 'Invalid shadow.');\n    return { x:number(shadow.x,-20,20,'Shadow X'), y:number(shadow.y,-20,20,'Shadow Y'), blur:number(shadow.blur,0,30,'Shadow blur'), color:color(shadow.color,'Shadow color') };\n  });\n}\nexport function legacyLayer(recipe) {\n  if (recipe.textureId) return { type:'image',textureId:recipe.textureId,opacity:1,size:[100,100],at:[50,50],canvasRepeat:'no-repeat' };\n  return {type:'linear',opacity:1,angle:recipe.angle,repeat:false,shape:'ellipse',size:[300,100],at:[50,50],canvasRepeat:'no-repeat',stops:recipe.colors.map((color,i) => ({color,at:i/(recipe.colors.length-1)}))};\n}\nexport function rgbaColor(hex, opacity=1) {\n  if (!isColor(hex)) return 'rgba(255,255,255,1)';\n  const rgb = [1,3,5].map(i => parseInt(hex.slice(i,i+2),16));\n  const alpha = hex.length === 9 ? parseInt(hex.slice(7,9),16)/255:1;\n  return `rgba(${rgb.join(',')},${(alpha*opacity).toFixed(4)})`;\n}\nexport function layerBackground(layer, textureUrl) {\n  if (layer.type === 'image') return textureUrl ? `url(\"${textureUrl}\")`:null;\n  if (layer.type === 'solid') { const c=rgbaColor(layer.color);return `linear-gradient(${c},${c})`; }\n  const stops=layer.stops.map(s => `${rgbaColor(s.color)} ${Number((s.at*100).toFixed(3))}%`).join(', ');\n  const repeat=layer.repeat ? 'repeating-':'';\n  if(layer.type==='linear')return `${repeat}linear-gradient(${layer.angle}deg, ${stops})`;\n  if(layer.type==='radial')return `${repeat}radial-gradient(${layer.shape} at ${layer.at[0]}% ${layer.at[1]}%, ${stops})`;\n  return `${repeat}conic-gradient(from ${layer.angle}deg at ${layer.at[0]}% ${layer.at[1]}%, ${stops})`;\n}\nexport function convert7TVPaint(input) {\n  check(object(input), 'Choose a 7TV paint JSON object.');\n  const envelope = input.data?.paint?.data || input.data?.paint || input.paint || input.data?.data || input.data || input;\n  const paint = envelope?.data?.layers ? envelope.data : envelope;\n  check(object(paint), 'Invalid paint data.');\n  const warnings = [], layers = [];\n  const decode = value => {\n    if(isColor(value))return value;\n    check(Number.isInteger(value) && value >= -2147483648 && value <= 4294967295,'Invalid 7TV packed RGBA color.');\n    return `#${(value >>> 0).toString(16).padStart(8,'0')}`;\n  };\n  const readLayer = (source, opacity=1) => {\n    const raw=source.ty?.data ?? source.data ?? source;\n    const type=source.ty?.type || source.type || source.function;\n    check(object(raw) || type==='single_color' || type==='SingleColor', 'Invalid 7TV layer.');\n    const map={LINEAR_GRADIENT:'linear',RADIAL_GRADIENT:'radial',CONIC_GRADIENT:'conic',URL:'image',linear_gradient:'linear',radial_gradient:'radial',single_color:'solid',image:'image'};\n    const kind=map[type];check(kind,`Unsupported 7TV layer: ${type || 'unknown'}.`);\n    const at=raw.at || [50,50];\n    if(raw.canvas_repeat && !['no-repeat','repeat','repeat-x','repeat-y'].includes(raw.canvas_repeat))warnings.push(`Canvas repeat “${raw.canvas_repeat}” is not supported; imported as no-repeat.`);\n    const layer={type:kind,opacity,size:raw.size || [100,100],at,canvasRepeat:['no-repeat','repeat','repeat-x','repeat-y'].includes(raw.canvas_repeat) ? raw.canvas_repeat:'no-repeat'};\n    if(kind==='image') {warnings.push('Image layer needs a local GIF/WebP/PNG upload. Remote images are not fetched or trusted.');}\n    else if(kind==='solid')layer.color=decode(raw);\n    else {\n      layer.angle=((raw.angle ?? 90)%360+360)%360;layer.repeat=raw.repeat ?? raw.repeating ?? false;layer.shape=String(raw.shape || 'ellipse').toLowerCase();\n      check(Array.isArray(raw.stops),'7TV gradient stops are missing.');\n      layer.stops=raw.stops.map(stop => ({at:stop.at,color:decode(stop.color)}));\n    }\n    layers.push(layer);\n  };\n  if(Array.isArray(paint.layers))for(const layer of paint.layers)readLayer(layer,layer.opacity ?? 1);\n  else if(Array.isArray(paint.gradients))for(const gradient of paint.gradients)readLayer(gradient,1);\n  else if(paint.function)readLayer(paint);\n  else if(paint.color !== undefined && paint.color !== null)layers.push({type:'solid',color:decode(paint.color),opacity:1});\n  check(layers.length>0,'No supported paint layers were found.');\n  if(paint.color !== undefined && paint.color !== null && !layers.some(l=>l.type==='solid'))layers.push({type:'solid',color:decode(paint.color),opacity:1});\n  const sourceShadows=paint.shadows || [], textShadows=paint.text?.shadows || [];\n  check(Array.isArray(sourceShadows)&&Array.isArray(textShadows),'Invalid 7TV shadows.');\n  const shadows=normalizeShadows([...sourceShadows,...textShadows].map(s => ({x:s.x_offset ?? s.offset_x ?? 0,y:s.y_offset ?? s.offset_y ?? 0,blur:s.radius ?? s.blur ?? 0,color:decode(s.color)})));\n  if(paint.flairs?.length)warnings.push('Decorative flairs are not imported: Nameflare currently styles names only (no badges, vectors, or extra text).');\n  if(paint.text?.transform)warnings.push('Text transformation is not imported; chat names remain authoritative.');\n  const normalized=normalizeLayers(layers,{allowEmptyImages:true});\n  const palette=normalized.flatMap(l => l.stops?.map(s=>s.color.slice(0,7)) || (l.color ? [l.color.slice(0,7)]:[])).slice(0,5);\n  while(palette.length<2)palette.push('#ffffff');\n  const recipe={colors:palette,animation:'none',speed:4,angle:normalized[0].angle ?? 90,glow:0,layers:normalized,shadows};\n  if(paint.text?.weight !== undefined)recipe.fontWeight=number(paint.text.weight,400,900,'Text weight');\n  if(paint.text?.stroke) {recipe.outline=number(paint.text.stroke.width,0,2,'Stroke width');recipe.outlineColor=decode(paint.text.stroke.color).slice(0,7);if(decode(paint.text.stroke.color).length===9 && !decode(paint.text.stroke.color).endsWith('ff'))warnings.push('Outline alpha is not supported; stroke imported as an opaque color.');}\n  return { recipe,warnings,name:input.name || envelope?.name || input.data?.name || 'Imported paint' };\n}\n","public/client.js":"// SPDX-License-Identifier: MIT OR AGPL-3.0-only\n/*! Nameflare browser SDK — Copyright (c) 2026 Nameflare contributors.\n * MIT License: Permission is hereby granted, free of charge, to any person obtaining\n * a copy of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights to use,\n * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,\n * and to permit persons to whom the Software is furnished to do so, subject to the following\n * conditions: The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,\n * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR\n * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE\n * FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\n * DEALINGS IN THE SOFTWARE. Alternatively available under AGPL-3.0-only; see LICENSE.\n */\nimport { normalizeLayers, normalizeShadows, isTextureId } from './paints.js';\nexport { applyCosmetic } from './renderer.js';\nexport const OFFICIAL_ORIGIN='https://nameflare.bxne.dev';\nexport function serviceOrigin(value=OFFICIAL_ORIGIN) {\n  try {\n    const url=new URL(value),local=['localhost','127.0.0.1','[::1]'].includes(url.hostname);\n    if(url.username || url.password || url.search || url.hash || !['','/'].includes(url.pathname))return null;\n    if(url.protocol!=='https:' && !(url.protocol==='http:' && local))return null;\n    return url.origin;\n  }catch{return null;}\n}\nexport function identityKey(provider,id) {\n  if(!['twitch','kick','youtube'].includes(provider) || typeof id!=='string')return null;\n  if(provider==='youtube'?!/^UC[a-zA-Z0-9_-]{22}$/.test(id):!/^\\d{1,30}$/.test(id))return null;\n  return `${provider}:${id}`;\n}\nexport function normalizeCosmetic(value) {\n  if(!value || typeof value.id!=='string' || value.id.length>100 || !value.recipe)return null;\n  const input=value.recipe,recipe={};\n  if(!Array.isArray(input.colors) || input.colors.length<2 || input.colors.length>5 || !input.colors.every(c=>typeof c==='string' && /^#[a-f0-9]{6}$/i.test(c)))return null;\n  if(!['flow','shimmer','pulse','none'].includes(input.animation))return null;\n  recipe.colors=[...input.colors];recipe.animation=input.animation;\n  const bounds={speed:[1,20],angle:[0,360],glow:[0,20],shadowX:[-12,12],shadowY:[-12,12],shadowBlur:[0,20],shadowOpacity:[0,100],outline:[0,2],depth:[0,8],depthAngle:[0,360],fontWeight:[400,900]};\n  for(const [key,[min,max]]of Object.entries(bounds)) {\n    if(input[key]===undefined && !['speed','angle','glow'].includes(key))continue;\n    if(!Number.isFinite(input[key]) || input[key]<min || input[key]>max || (key==='depth' && !Number.isInteger(input[key])))return null;\n    recipe[key]=input[key];\n  }\n  for(const key of ['glowColor','shadowColor','outlineColor','depthColor'])if(input[key]!==undefined){if(typeof input[key]!=='string' || !/^#[a-f0-9]{6}$/i.test(input[key]))return null;recipe[key]=input[key];}\n  try {\n    if(input.layers!==undefined)recipe.layers=normalizeLayers(input.layers);\n    if(input.shadows!==undefined)recipe.shadows=normalizeShadows(input.shadows);\n  }catch{return null;}\n  if(input.textureId!==undefined){if(!isTextureId(input.textureId))return null;recipe.textureId=input.textureId;}\n  return {id:value.id,recipe,updated_at:typeof value.updated_at==='string'?value.updated_at:''};\n}\nasync function readJSON(response) {\n  if(!response.ok || !(response.headers.get('Content-Type') || '').includes('application/json'))throw new Error('Nameflare unavailable.');\n  const reader=response.body.getReader(),chunks=[];let length=0;\n  while(true){const {done,value}=await reader.read();if(done)break;length+=value.length;if(length>1000000){await reader.cancel();throw new Error('Nameflare response too large.');}chunks.push(value);}\n  const bytes=new Uint8Array(length);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}\n  return JSON.parse(new TextDecoder().decode(bytes));\n}\nexport class NameflareClient {\n  constructor({origin=OFFICIAL_ORIGIN,fetchImpl=globalThis.fetch,now=Date.now,onChange=()=>{},batchDelay=80}={}) {\n    this.origin=serviceOrigin(origin);if(!this.origin)throw new Error('Use an HTTPS service origin, or HTTP on localhost.');\n    this.fetch=fetchImpl;this.now=now;this.onChange=onChange;this.batchDelay=batchDelay;\n    this.cache=new Map();this.pending=new Map();this.inflight=new Set();this.controllers=new Set();this.timer=null;this.generation=0;this.closed=false;\n  }\n  get(provider,id) {\n    const key=identityKey(provider,id);if(!key || this.closed)return null;\n    const cached=this.cache.get(key);\n    if(cached && cached.expires>this.now())return cached.cosmetic;\n    if(cached){this.cache.delete(key);this.onChange(key,null);}\n    if(!this.inflight.has(key))this.pending.set(key,{provider,id});\n    if(this.timer===null)this.timer=setTimeout(()=>{this.timer=null;void this.flush();},this.batchDelay);\n    return null;\n  }\n  expire() {\n    for(const [key,value]of this.cache)if(value.expires<=this.now()){this.cache.delete(key);this.onChange(key,null);}\n  }\n  async flush() {\n    if(this.closed)return;\n    const generation=this.generation;\n    const queued=[...this.pending.entries()];this.pending.clear();\n    for(const [key]of queued)this.inflight.add(key);\n    for(const provider of ['twitch','kick','youtube']) {\n      const group=queued.filter(([,value])=>value.provider===provider);\n      for(let i=0;i<group.length;i+=100) {\n        const chunk=group.slice(i,i+100),started=this.now(),controller=new AbortController();this.controllers.add(controller);\n        const timeout=setTimeout(()=>controller.abort(),4000);let users=null;\n        try {\n          const response=await this.fetch(`${this.origin}/api/v1/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider,ids:chunk.map(([,value])=>value.id)}),credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',signal:controller.signal});\n          const data=await readJSON(response);\n          if(!Array.isArray(data.users) || data.users.length>100)throw new Error('Invalid Nameflare identities.');\n          users=new Map();\n          for(const user of data.users){const key=identityKey(user.provider,user.provider_id);if(user.provider===provider && chunk.some(([wanted])=>wanted===key))users.set(key,normalizeCosmetic(user.cosmetic));}\n        }catch{ /* A failed refresh must never resurrect an expired paint. */ }\n        finally{clearTimeout(timeout);this.controllers.delete(controller);}\n        if(generation!==this.generation || this.closed)return;\n        for(const [key]of chunk) {\n          this.inflight.delete(key);\n          const cosmetic=users?.get(key) || null,expires=started+(users?12000:5000);\n          this.cache.set(key,{cosmetic:expires>this.now()?cosmetic:null,expires});\n          this.onChange(key,this.cache.get(key).cosmetic);\n        }\n        while(this.cache.size>1000){const key=this.cache.keys().next().value;this.cache.delete(key);this.onChange(key,null);}\n      }\n    }\n  }\n  clear() {\n    this.generation++;if(this.timer!==null)clearTimeout(this.timer);this.timer=null;\n    for(const controller of this.controllers)controller.abort();this.controllers.clear();this.pending.clear();this.inflight.clear();\n    const keys=[...this.cache.keys()];this.cache.clear();for(const key of keys)this.onChange(key,null);\n  }\n  close(){this.closed=true;this.clear();}\n}\n","public/SDK-LICENSE.md":"# Nameflare browser SDK licensing\n\nCopyright (c) 2026 Nameflare contributors.\n\nOnly `client.js`, `renderer.js`, `paints.js`, and `effects.css` in this directory are available under **MIT OR AGPL-3.0-only**, at your option. The rest of Nameflare, including the editor, application UI, API, storage and OAuth service, remains AGPL-3.0-only. Artwork, trademarks, service ownership and designation of an official API are not granted by either software license.\n\nThis separate grant permits the standalone SDK to be vendored in MIT-licensed Banechat. Preserve this notice when distributing those files. The grant covers copyright-controlled Nameflare code, not independently licensed third-party contributions.\n\n## MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n## AGPL option\n\nThe full GNU Affero General Public License version 3 is in the service repository's `LICENSE` and at https://www.gnu.org/licenses/agpl-3.0.html.\n","test/client.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { NameflareClient,serviceOrigin,identityKey,normalizeCosmetic } from '../public/client.js';\nconst cosmetic={id:'paint-1',recipe:{colors:['#ff1a35','#ffffff'],animation:'shimmer',speed:4,angle:120,glow:3}};\nconst user=(provider,id,paint=cosmetic)=>({provider,provider_id:id,cosmetic:paint});\nconst response=users=>Response.json({users});\ntest('SDK trusts only explicit HTTPS/loopback origins and immutable provider IDs',()=>{\n  assert.equal(serviceOrigin('https://nameflare.bxne.dev/'),'https://nameflare.bxne.dev');assert.equal(serviceOrigin('http://127.0.0.1:3100'),'http://127.0.0.1:3100');\n  for(const value of ['http://example.com','https://example.com/api','https://user:pass@example.com','https://example.com?secret=x','javascript:alert(1)'])assert.equal(serviceOrigin(value),null);\n  assert.equal(identityKey('twitch','123'),'twitch:123');assert.equal(identityKey('kick','123'),'kick:123');assert.equal(identityKey('youtube',`UC${'a'.repeat(22)}`),`youtube:UC${'a'.repeat(22)}`);\n  for(const [provider,id]of [['twitch','display_name'],['youtube','video-id'],['twitch',123],['other','123'],['kick','message-uuid']])assert.equal(identityKey(provider,id),null);\n});\ntest('SDK strips executable recipe fields and rejects invalid images/layers/effects',()=>{\n  const clean=normalizeCosmetic({...cosmetic,recipe:{...cosmetic.recipe,css:'url(javascript:x)',html:'<img>',image_url:'https://evil/image'}});assert.deepEqual(clean.recipe,cosmetic.recipe);\n  for(const patch of [{glow:Infinity},{depth:1.5},{outlineColor:'red'},{textureId:'https://evil/file.gif'},{layers:[]},{shadows:[{x:90,y:0,blur:0,color:'#ffffff'}]}])assert.equal(normalizeCosmetic({...cosmetic,recipe:{...cosmetic.recipe,...patch}}),null);\n});\ntest('SDK separates platforms, batches deduplicated identities, omits credentials and negative-caches misses',async()=>{\n  const requests=[],changes=[];let time=0;\n  const client=new NameflareClient({now:()=>time,batchDelay:60000,onChange:(...v)=>changes.push(v),fetchImpl:async(url,options)=>{\n    const data=JSON.parse(options.body);requests.push({url,options,data});return response(data.ids.filter(id=>id==='123').map(id=>user(data.provider,id)));\n  }});\n  try{\n    client.get('twitch','123');client.get('twitch','123');client.get('kick','123');client.get('twitch','456');client.get('youtube','video-id');await client.flush();\n    assert.equal(requests.length,2);assert.deepEqual(requests[0].data.ids,['123','456']);assert.equal(requests[0].options.credentials,'omit');assert.equal(requests[0].options.cache,'no-store');assert.equal(requests[0].options.redirect,'error');\n    assert.equal(client.get('twitch','123').id,'paint-1');assert.equal(client.get('twitch','456'),null);assert.equal(client.pending.size,0);\n    time=12001;client.expire();assert.ok(changes.some(([key,value])=>key==='twitch:123' && value===null));assert.equal(client.get('twitch','123'),null);\n  }finally{client.close();}\n});\ntest('SDK expires revoked and unreachable paints rather than serving stale; unknown response IDs cannot inject',async()=>{\n  let time=0,mode='approved';const client=new NameflareClient({now:()=>time,batchDelay:60000,fetchImpl:async()=>{\n    if(mode==='offline')throw new Error('offline');return response(mode==='approved'?[user('twitch','1')]:mode==='injected'?[user('twitch','2'),user('kick','1')]:[]);\n  }});\n  try{\n    client.get('twitch','1');await client.flush();assert.ok(client.get('twitch','1'));\n    time=12001;mode='offline';assert.equal(client.get('twitch','1'),null);await client.flush();assert.equal(client.get('twitch','1'),null);\n    time=18002;mode='approved';client.get('twitch','1');await client.flush();assert.ok(client.get('twitch','1'));\n    time=31000;mode='injected';client.get('twitch','1');await client.flush();assert.equal(client.get('twitch','1'),null);assert.equal(client.cache.has('twitch:2'),false);\n  }finally{client.close();}\n});\ntest('SDK batches at most 100 IDs and prevents cancelled/old origin responses from restoring paints',async()=>{\n  const sizes=[];const client=new NameflareClient({batchDelay:60000,fetchImpl:async(url,options)=>{sizes.push(JSON.parse(options.body).ids.length);return response([]);}});\n  try{for(let i=0;i<205;i++)client.get('twitch',String(i));await client.flush();assert.deepEqual(sizes,[100,100,5]);}finally{client.close();}\n  let resolve;const race=new NameflareClient({batchDelay:60000,fetchImpl:()=>new Promise(r=>resolve=r)});\n  race.get('twitch','1');const pending=race.flush();race.clear();resolve(response([user('twitch','1')]));await pending;assert.equal(race.cache.size,0);race.close();\n});\n","public/overlay.html":"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Nameflare chat overlay</title><link rel=\"stylesheet\" href=\"/overlay.css\"><script type=\"module\" src=\"/overlay.js\"></script></head><body><div id=\"overlay-status\" role=\"status\"></div><main id=\"messages\" aria-label=\"Live chat\" aria-live=\"polite\"></main></body></html>\n","public/overlay.css":"@import url('/effects.css');\n*{box-sizing:border-box}html,body{background:transparent!important;margin:0;font-family:ui-sans-serif,system-ui,sans-serif;color:#f2f4ed}body{padding:16px}#messages{display:flex;flex-direction:column;gap:10px;max-width:700px;margin-top:20px}.message{padding:12px 16px;border-radius:8px;background:#101810d9;border:1px solid #ffffff16;line-height:1.8;overflow-wrap:anywhere;font-size:18px}.message strong{margin-right:10px;font-size:18px;white-space:pre}.platform{font-size:10px;color:#aab59b;margin-right:9px}.message time{font-size:10px;color:#899c7e;float:right}.message .text{display:block;font-size:16px}#overlay-status{font-size:11px;display:inline-block;background:#101810d9;padding:7px 10px;border-radius:5px;color:#b7d699}#overlay-status:empty{display:none}\n","public/overlay.js":"import { applyCosmetic } from './renderer.js';\nconst params=new URLSearchParams(location.search),status=document.querySelector('#overlay-status'),messages=document.querySelector('#messages');\nconst cache=new Map();let queue=Promise.resolve(),stopped=false;\nconst lifetime=Math.max(5,Math.min(300,Number(params.get('lifetime')) || 45))*1000;\nconst effects=params.get('effects')!=='0';\nfunction report(text){status.textContent=text;}\nasync function resolveCosmetic(provider,id){\n  const key=`${provider}:${id}`,entry=cache.get(key);\n  if(entry && entry.expires>Date.now())return entry.cosmetic;\n  try{\n    const response=await fetch(`/api/v1/users/${provider}/${encodeURIComponent(id)}`);\n    if(!response.ok && response.status!==404)throw new Error('API unavailable');\n    const data=await response.json(),cosmetic=data.user?.cosmetic || null;\n    if(cache.size>1000)cache.clear();cache.set(key,{cosmetic,expires:Date.now()+15000});return cosmetic;\n  }catch{return null;}\n}\nfunction enqueue(data,recipe){queue=queue.then(() => showMessage(data,recipe)).catch(() => report('Could not render a chat message.'));}\nasync function showMessage(data,recipe){\n  if(!['twitch','kick','youtube'].includes(data.provider)||typeof data.name!=='string'||typeof data.text!=='string'||typeof data.provider_id!=='string')return;\n  const cosmetic=recipe || await resolveCosmetic(data.provider,data.provider_id);\n  const row=document.createElement('article');row.className='message';row.dataset.providerId=data.provider_id;if(data.id)row.dataset.messageId=data.id;\n  const platform=document.createElement('span');platform.className='platform';platform.textContent={twitch:'TW',kick:'K',youtube:'YT'}[data.provider];\n  const name=document.createElement('strong');name.textContent=data.name.slice(0,80);if(effects)applyCosmetic(name,cosmetic);\n  const time=document.createElement('time');time.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});\n  const text=document.createElement('span');text.className='text';text.textContent=data.text.slice(0,1000);row.append(platform,name,time,text);messages.append(row);\n  while(messages.children.length>30)messages.firstChild.remove();setTimeout(() => row.remove(),lifetime);\n}\nfunction unescapeTag(value){return value.replace(/\\\\s/g,' ').replace(/\\\\:/g,';').replace(/\\\\r/g,'\\r').replace(/\\\\n/g,'\\n').replace(/\\\\\\\\/g,'\\\\');}\nfunction parseIrc(line){\n  let rest=line,tags={};\n  if(rest.startsWith('@')){const split=rest.indexOf(' ');for(const tag of rest.slice(1,split).split(';')){const i=tag.indexOf('=');if(i>=0)tags[tag.slice(0,i)]=unescapeTag(tag.slice(i+1));}rest=rest.slice(split+1);}\n  let prefix='';if(rest.startsWith(':')){const split=rest.indexOf(' ');prefix=rest.slice(1,split);rest=rest.slice(split+1);}\n  const split=rest.indexOf(' :'),trailing=split<0 ? '':rest.slice(split+2),parts=(split<0 ? rest:rest.slice(0,split)).split(' ');\n  return {tags,prefix,command:parts[0],trailing};\n}\nfunction directTwitch(channel){\n  let attempts=0,socket,timer;\n  const connect=() => {\n    if(stopped)return;report(`Connecting to Twitch · ${channel}`);socket=new WebSocket('wss://irc-ws.chat.twitch.tv:443');\n    socket.addEventListener('open',() => {socket.send('CAP REQ :twitch.tv/tags twitch.tv/commands\\r\\n');socket.send('PASS SCHMOOPIIE\\r\\n');socket.send(`NICK justinfan${Math.floor(Math.random()*90000)+10000}\\r\\n`);socket.send(`JOIN #${channel}\\r\\n`);});\n    socket.addEventListener('message',event => {\n      for(const line of String(event.data).split('\\r\\n').filter(Boolean)){\n        if(line.startsWith('PING')){socket.send(line.replace(/^PING/,'PONG')+'\\r\\n');continue;}\n        const irc=parseIrc(line);\n        if(irc.command==='JOIN' || irc.command==='366'){attempts=0;report('');}\n        if(irc.command==='PRIVMSG' && irc.tags['user-id'])enqueue({provider:'twitch',provider_id:irc.tags['user-id'],name:irc.tags['display-name'] || irc.prefix.split('!')[0],text:irc.trailing,id:irc.tags.id});\n        if(irc.command==='CLEARMSG'){const id=irc.tags['target-msg-id'];queue=queue.then(() => {for(const row of messages.children)if(row.dataset.messageId===id)row.remove();});}\n        if(irc.command==='CLEARCHAT'){const id=irc.tags['target-user-id'];queue=queue.then(() => {for(const row of [...messages.children])if(!id || row.dataset.providerId===id)row.remove();});}\n        if(irc.command==='RECONNECT')socket.close();\n        if(irc.command==='NOTICE')report(irc.trailing.slice(0,150));\n      }\n    });\n    socket.addEventListener('close',() => {if(stopped)return;report('Twitch disconnected. Reconnecting…');timer=setTimeout(connect,Math.min(30000,1000*2**Math.min(attempts++,5)));});\n    socket.addEventListener('error',() => socket.close());\n  };\n  connect();window.addEventListener('pagehide',() => {stopped=true;clearTimeout(timer);socket?.close();});\n}\nif(params.get('demo')==='1'){\n  report('SAMPLE CHAT · not a live connection');\n  let i=0;const sample=['okay this name is a whole vibe ✨','open source, main character energy','the glow is so good','made this in the Nameflare studio'];\n  const tick=async() => {let cosmetic;try{const response=await fetch('/api/v1/cosmetics?limit=8');const result=await response.json();cosmetic=result.cosmetics?.[i%Math.max(1,result.cosmetics.length)];}catch{}\n    enqueue({provider:'twitch',provider_id:'1002',name:['lumi','nova','orbit','moonchild'][i%4],text:sample[i%4]},cosmetic || {recipe:{colors:['#71f5bd','#b798ff'],animation:'flow',speed:4,angle:120,glow:4}});i++;};\n  tick();const timer=setInterval(tick,4500);window.addEventListener('pagehide',() => clearInterval(timer));\n}else if(params.has('room')){\n  const stream=new EventSource(`/api/overlay/events?room=${encodeURIComponent(params.get('room'))}`);stream.onopen=() => report('');stream.onmessage=event => {try{enqueue(JSON.parse(event.data));}catch{report('Invalid relay message.');}};stream.onerror=() => report('Relay unavailable. Check room configuration.');window.addEventListener('pagehide',() => stream.close());\n}else if(/^[a-zA-Z0-9_]{1,25}$/.test(params.get('channel') || ''))directTwitch(params.get('channel').toLowerCase());\nelse report('Add ?channel=your_twitch_channel, ?room=your_private_room, or ?demo=1.');\n","test/nameflare.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { Store, digest } from '../lib/store.js';\nimport { validateRecipe, validateTexture } from '../lib/cosmetics.js';\nimport { beginOAuth, finishOAuth, safeAvatar } from '../lib/oauth.js';\nimport { createApp } from '../server.js';\nimport { applyCosmetic } from '../public/renderer.js';\n\nconst recipe = () => ({ colors:['#71f5bd','#b798ff'],animation:'flow',speed:4,angle:120,glow:5 });\nconst profile = (id,name=id,provider='twitch') => ({provider,id,login:name,name});\nconst fails = (fn,status) => assert.throws(fn,error => error.status === status);\nconst gif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';\n\ntest('recipe validates bounded effects, supplies legacy defaults, and strips executable properties',() => {\n  const r=validateRecipe({...recipe(),depth:6,outline:.5,shadowOpacity:75,glowColor:'#ff00aa',css:'url(javascript:alert(1))'});\n  assert.equal(r.depth,6);assert.equal(r.glowColor,'#ff00aa');assert.equal(r.shadowX,0);assert.equal(r.css,undefined);\n  for(const invalid of [{depth:9},{depth:1.5},{shadowX:13},{shadowOpacity:101},{outline:3},{glowColor:'red;display:none'},{speed:NaN},{colors:['red','blue']},{animation:'flash'},{textureId:'https://evil.test/a.gif'}])fails(() => validateRecipe({...recipe(),...invalid}),400);\n  assert.equal(validateRecipe(recipe()).depth,0);\n});\ntest('raster upload validation rejects SVG, wrong signatures, and oversized dimensions',() => {\n  assert.match(validateTexture(gif).id,/^[a-f0-9]{64}\\.gif$/);\n  fails(() => validateTexture('data:image/svg+xml;base64,PHN2Zz4='),400);\n  fails(() => validateTexture('data:image/gif;base64,aW52YWxpZC1pbWFnZS1oZWFkZXI='),400);\n  const bytes=validateTexture(gif).bytes;bytes.writeUInt16LE(4096,6);\n  fails(() => validateTexture(`data:image/gif;base64,${bytes.toString('base64')}`),400);\n});\ntest('safe renderer keeps separate 3D/shadow layers and rejects untrusted recipe fills',() => {\n  globalThis.window={matchMedia:() => ({matches:false})};\n  const values=new Map();const classes=new Set();\n  const element={textContent:'myname',dataset:{},classList:{add:k => classes.add(k),remove:k => classes.delete(k)},style:{removeProperty:k => values.delete(k),setProperty:(k,v) => values.set(k,v)}};\n  applyCosmetic(element,{recipe:validateRecipe({...recipe(),depth:4,shadowOpacity:60,shadowY:3,outline:1})},'https://api.example.com');\n  assert.equal(element.dataset.text,'myname');assert.equal(element.dataset.animation,'flow');assert.equal(values.get('--nameflare-shadows').split('px 0 #303047').length,5);assert.match(element.style.filter,/drop-shadow/);assert.equal(element.style.webkitTextStroke,'1px #ffffff');\n  applyCosmetic(element,{recipe:{...recipe(),colors:['url(evil)','red']}},'https://api.example.com');assert.equal(classes.has('nameflare-name'),false);\n  const textureId=`${'a'.repeat(64)}.gif`;window.matchMedia=() => ({matches:true});applyCosmetic(element,{recipe:{...recipe(),textureId}},'https://api.example.com');assert.match(element.style.backgroundImage,/linear-gradient/);\n  delete globalThis.window;\n});\ntest('SQLite survives restart; owner bootstrap is explicit and identity takeover is blocked',() => {\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-store-'));const path=join(dir,'db.sqlite');\n  let store=new Store(path);\n  try{\n    const owner=store.linkIdentity(profile('1','owner'),null,'1');assert.equal(store.user(owner).role,'owner');\n    const user=store.linkIdentity(profile('2','user'),null,'1');assert.equal(store.user(user).role,'user');\n    const kick=store.linkIdentity(profile('k1','kick-user','kick'),user,'1');assert.equal(kick,user);\n    fails(() => store.linkIdentity(profile('2'),owner,'1'),409);\n    fails(() => store.linkIdentity(profile('3'),user,'1'),409);\n    const session=store.newSession(user);assert.equal(store.session(session.raw).id,user);assert.notEqual(store.db.prepare('SELECT hash FROM sessions').get().hash,session.raw);\n    const c=store.submit(user,'Original','',validateRecipe(recipe()));assert.equal(store.publicCosmetic(c),null);\n    fails(() => store.equip(user,c.id),400);fails(() => store.review(store.user(user),c.id,'approved',''),403);\n    store.review(store.user(owner),c.id,'approved','');store.equip(user,c.id);\n    assert.equal(store.resolve('kick','k1').cosmetic.id,c.id);\n    store.close();store=new Store(path);assert.equal(store.resolve('twitch','2').cosmetic.id,c.id);\n    store.review(store.user(owner),c.id,'rejected','Policy violation');assert.equal(store.resolve('twitch','2').cosmetic,null);assert.equal(store.user(user).equipped,null);\n    assert.equal(store.auditLog().length,2);store.revokeSession(session.raw);assert.equal(store.session(session.raw),null);\n  }finally{store.close();rmSync(dir,{recursive:true,force:true});}\n});\ntest('OAuth state is bound to browser/provider, expires, and cannot be replayed',() => {\n  const store=new Store();try{\n    const raw=store.state('kick',null,'browser','verifier');fails(() => store.consumeState(raw,'kick','attacker'),400);\n    assert.equal(store.consumeState(raw,'kick','browser').verifier,'verifier');fails(() => store.consumeState(raw,'kick','browser'),400);\n    const old=store.state('twitch',null,'browser','v');store.db.prepare('UPDATE oauth_states SET expires=0 WHERE hash=?').run(digest(old));fails(() => store.consumeState(old,'twitch','browser'),400);\n    const config={KICK_CLIENT_ID:'client',KICK_CLIENT_SECRET:'secret'};const started=beginOAuth('kick',store,null,'https://example.com',config);const url=new URL(started.url);assert.equal(url.searchParams.get('code_challenge_method'),'S256');assert.ok(url.searchParams.get('state'));assert.equal(url.searchParams.has('client_secret'),false);\n  }finally{store.close();}\n});\ntest('OAuth provider adapters verify immutable platform identities (mocked providers)',async() => {\n  const original=globalThis.fetch;\n  try{\n    for(const provider of ['twitch','kick','youtube']){\n      const seen=[];globalThis.fetch=async(url,options) => {\n        seen.push({url,options});if(String(url).includes('/token'))return Response.json({access_token:'provider-token'});\n        return Response.json(provider==='twitch' ? {data:[{id:'42',login:'name',display_name:'Name',profile_image_url:'https://static-cdn.jtvnw.net/avatar.png'}]}:provider==='kick' ? {data:[{user_id:42,name:'Name',profile_picture:'https://files.kick.com/avatar.png'}]}:{items:[{id:'UC42',snippet:{title:'Name',customUrl:'@name',thumbnails:{medium:{url:'https://yt3.ggpht.com/avatar.png'}}}}]});\n      };\n      const env={[`${provider.toUpperCase()}_CLIENT_ID`]:'client',[`${provider.toUpperCase()}_CLIENT_SECRET`]:'secret'};\n      const result=await finishOAuth(provider,'code','verifier','https://example.com',env);\n      assert.equal(result.id,provider==='youtube' ? 'UC42':'42');assert.equal(result.provider,provider);assert.ok(result.avatar_url.endsWith('/avatar.png'));\n      if(provider==='kick')assert.equal(seen[0].options.body.get('code_verifier'),'verifier');\n      if(provider==='twitch')assert.equal(seen[1].options.headers['Client-Id'],'client');\n      if(provider==='youtube')assert.match(seen[1].url,/mine=true/);\n    }\n  }finally{globalThis.fetch=original;}\n});\n\ntest('HTTP moderation, CSRF, API publication, texture privacy, and overlay guards',async() => {\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-http-'));const store=new Store();store.seedDemo();\n  const env={OVERLAY_INGEST_TOKEN:'private-ingest',OVERLAY_ROOM_KEY:'private-room'};\n  const app=createApp({store,dataDir:dir,baseUrl:'http://localhost',env});await new Promise(resolve => app.server.listen(0,'127.0.0.1',resolve));\n  const base=`http://127.0.0.1:${app.server.address().port}`;\n  const creatorSession=store.newSession('demo-creator'),ownerSession=store.newSession('demo-owner'),modSession=store.newSession('demo-mod');\n  const sessions={creator:creatorSession,owner:ownerSession,mod:modSession};\n  const call=async(path,data,role,extras={}) => {\n    const headers={...extras};if(data!==undefined)headers['Content-Type']='application/json';\n    if(role){headers.Cookie=`nameflare_session=${sessions[role].raw}`;headers['X-CSRF-Token']=sessions[role].csrf;headers.Origin='http://localhost';}\n    return fetch(base+path,{method:data===undefined ? 'GET':'POST',headers,body:data===undefined ? undefined:JSON.stringify(data)});\n  };\n  try{\n    assert.equal((await call('/api/demo/login',{role:'owner'},'owner')).status,404);\n    assert.equal((await call('/api/admin/queue')).status,401);assert.equal((await call('/api/admin/queue',undefined,'creator')).status,403);\n    const csrfResponse=await fetch(base+'/api/equip',{method:'POST',headers:{Cookie:`nameflare_session=${creatorSession.raw}`,Origin:'http://localhost','Content-Type':'application/json','X-CSRF-Token':'wrong'},body:JSON.stringify({cosmeticId:'demo-0'})});assert.equal(csrfResponse.status,403);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'1002',enabled:true},'mod')).status,403);\n    assert.equal((await call('/api/admin/moderators',{twitchId:1002,enabled:true},'owner')).status,400);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'1002',enabled:true},'owner')).status,200);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'1002',enabled:false},'owner')).status,200);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'1001',enabled:false},'owner')).status,403);\n    const upload=await call('/api/textures',{data:gif},'creator');assert.equal(upload.status,201);const textureId=(await upload.json()).id;\n    assert.equal((await call(`/textures/${textureId}`)).status,401);assert.equal((await call(`/textures/${textureId}`,undefined,'creator')).status,200);\n    const submit=await call('/api/cosmetics',{name:'Shadow test',description:'Layered depth',recipe:{...recipe(),textureId,depth:5,shadowOpacity:75}},'creator');assert.equal(submit.status,201);const cosmetic=(await submit.json()).cosmetic;\n    assert.equal((await call(`/api/v1/cosmetics/${cosmetic.id}`)).status,404);\n    let catalog=await (await call('/api/v1/cosmetics')).json();assert.equal(catalog.cosmetics.some(c => c.id===cosmetic.id),false);\n    assert.equal((await call('/api/equip',{cosmeticId:cosmetic.id},'creator')).status,400);\n    assert.equal((await call('/api/admin/review',{id:cosmetic.id,status:'approved'},'creator')).status,403);\n    assert.equal((await call('/api/admin/review',{id:cosmetic.id,status:'approved',reason:''},'mod')).status,200);\n    const publicResponse=await call(`/api/v1/cosmetics/${cosmetic.id}`);assert.equal(publicResponse.status,200);assert.equal(publicResponse.headers.get('Access-Control-Allow-Origin'),'*');const published=(await publicResponse.json()).cosmetic;assert.equal(published.recipe.depth,5);assert.equal(published.owner_id,undefined);\n    assert.equal((await call(`/textures/${textureId}`)).status,200);\n    assert.equal((await call('/api/equip',{cosmeticId:cosmetic.id},'creator')).status,200);\n    const resolution=await (await call('/api/v1/resolve',{provider:'twitch',ids:['1002']})).json();assert.equal(resolution.users[0].cosmetic.id,cosmetic.id);\n    assert.equal((await call('/api/admin/review',{id:cosmetic.id,status:'rejected',reason:'Flashing artwork'},'owner')).status,200);\n    assert.equal((await call(`/api/v1/cosmetics/${cosmetic.id}`)).status,404);assert.equal((await call(`/textures/${textureId}`)).status,401);\n    assert.equal((await call('/api/v1/cosmetics?limit=NaN')).status,400);assert.equal((await call('/api/v1/resolve',{provider:'twitch',ids:[123]})).status,400);\n    assert.equal((await call('/api/overlay/messages',{provider:'twitch',provider_id:'1002',name:'lumi',text:'hi'})).status,401);\n    assert.equal((await call('/api/overlay/messages',{provider:'kick',provider_id:'5',name:'viewer',text:'hello'},undefined,{Authorization:'Bearer private-ingest'})).status,202);\n    assert.equal((await call('/api/overlay/events?room=bad')).status,403);\n    const streamResponse=await call('/api/overlay/events?room=private-room');assert.equal(streamResponse.status,200);const reader=streamResponse.body.getReader();await reader.read();\n    await call('/api/overlay/messages',{provider:'youtube',provider_id:'UC123',name:'viewer',text:'stream event'},undefined,{Authorization:'Bearer private-ingest'});\n    const event=await reader.read();assert.match(new TextDecoder().decode(event.value),/stream event/);await reader.cancel();\n    const pendingOwn=store.submit('demo-owner','Owner design','',validateRecipe(recipe()));\n    assert.equal((await call('/api/admin/review',{id:pendingOwn.id,status:'approved',reason:''},'owner')).status,403);\n    assert.equal((await call('/api/admin/review',{id:pendingOwn.id,status:'rejected',reason:''},'mod')).status,400);\n    const crossOrigin=await fetch(base+'/api/equip',{method:'POST',headers:{Cookie:`nameflare_session=${creatorSession.raw}`,Origin:'https://attacker.example','Content-Type':'application/json','X-CSRF-Token':creatorSession.csrf},body:JSON.stringify({cosmeticId:'demo-0'})});assert.equal(crossOrigin.status,403);\n    const preflight=await fetch(base+'/api/v1/resolve',{method:'OPTIONS',headers:{Origin:'https://overlay.example','Access-Control-Request-Method':'POST'}});assert.equal(preflight.status,204);\n    for(const path of ['/','/app.js','/style.css','/effects.css','/overlay','/overlay.js','/license'])assert.equal((await call(path)).status,200);\n    const source=await (await call('/source')).json();assert.ok(source.files['public/app.js']);assert.ok(source.files['LICENSE']);assert.ok(source.files['docs/ORACLE.md']);assert.ok(source.files['docs/oracle/nameflare.service']);assert.ok(source.files['docs/oracle/nameflare.nginx.conf']);assert.ok(source.files['docs/oracle/nameflare-http.nginx.conf']);assert.equal(source.files['.env'],undefined);assert.equal(Object.keys(source.files).some(p => /\\.(key|pem)(\\.pub)?$/.test(p)),false);assert.equal(Object.keys(source.files).some(p => p.includes('.sqlite')),false);\n    assert.equal((await call('/api/me')).headers.get('Cache-Control'),'no-store');\n  }finally{app.server.closeAllConnections();await new Promise(resolve => app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n\ntest('Node Flare publishing controls protect moderation and allow anyone to equip shared approved Flares',async()=>{\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-publishing-')),store=new Store();store.seedDemo();\n  const app=createApp({store,dataDir:dir,baseUrl:'http://localhost',env:{}});await new Promise(resolve=>app.server.listen(0,'127.0.0.1',resolve));\n  const base=`http://127.0.0.1:${app.server.address().port}`,creator=store.newSession('demo-creator'),viewer=store.newSession('demo-mod');\n  const call=(path,data,session)=>fetch(base+path,{method:data===undefined?'GET':'POST',headers:{...(data===undefined?{}:{'Content-Type':'application/json',Origin:'http://localhost'}),...(session?{Cookie:`nameflare_session=${session.raw}`,'X-CSRF-Token':session.csrf}:{})},body:data===undefined?undefined:JSON.stringify(data)});\n  try{\n    const submitted=await call('/api/cosmetics',{name:'Link signal',description:'Only discoverable by link',recipe:recipe(),listed:false},creator);assert.equal(submitted.status,201);const c=(await submitted.json()).cosmetic;\n    assert.equal((await call(`/api/v1/cosmetics/${c.id}`)).status,404);\n    assert.equal((await call('/api/cosmetics/publication',{id:c.id,listed:true},viewer)).status,403);\n    assert.equal((await call('/api/cosmetics/publication',{id:c.id,listed:true})).status,401);\n    for(const listed of ['false',0,null])assert.equal((await call('/api/cosmetics',{name:'Invalid',recipe:recipe(),listed},creator)).status,400);\n    assert.equal((await call('/api/cosmetics/publication',{id:c.id},creator)).status,400);\n    store.review(store.user('demo-owner'),c.id,'approved','Readable');\n    assert.equal((await (await call('/api/v1/cosmetics')).json()).cosmetics.some(row=>row.id===c.id),false);\n    const shared=(await (await call(`/api/v1/cosmetics/${c.id}`)).json()).cosmetic;assert.equal(shared.description,'Only discoverable by link');assert.equal(shared.listed,false);assert.equal(shared.owner_id,undefined);\n    assert.equal((await call('/api/equip',{cosmeticId:c.id},viewer)).status,200);\n    assert.equal((await call('/api/cosmetics/publication',{id:c.id,listed:true},creator)).status,200);\n    const catalog=await (await call('/api/v1/cosmetics?q=Link&effect=animated&sort=popular&limit=1')).json();assert.equal(catalog.cosmetics[0].id,c.id);assert.equal(catalog.cosmetics[0].equipped_count,1);assert.equal(catalog.total,9);\n    const staticFlare=store.submit('demo-creator','Static signal','',{...recipe(),animation:'none',glow:0});store.review(store.user('demo-owner'),staticFlare.id,'approved','');\n    assert.equal((await (await call('/api/v1/cosmetics?effect=static')).json()).cosmetics[0].id,staticFlare.id);\n    for(const query of ['effect=unknown','sort=unknown'])assert.equal((await call(`/api/v1/cosmetics?${query}`)).status,400);\n    const all=await (await call('/api/v1/cosmetics?sort=name&limit=1')).json();assert.equal(all.next_offset,1);\n    store.setPublication('demo-creator',c.id,false);assert.equal(store.user('demo-mod').equipped,c.id);assert.equal(store.resolve('twitch','1003').cosmetic.id,c.id);\n    store.review(store.user('demo-owner'),c.id,'rejected','Rights concern');store.setPublication('demo-creator',c.id,true);assert.equal(store.user('demo-mod').equipped,null);assert.equal((await call(`/api/v1/cosmetics/${c.id}`)).status,404);\n    assert.ok(store.auditLog().some(row=>row.action==='flare.publication'));\n  }finally{app.server.closeAllConnections();await new Promise(resolve=>app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n\ntest('provider avatars reject arbitrary hosts, insecure URLs, credentials and executable URLs',()=>{\n  assert.equal(safeAvatar('kick','https://files.kick.com/a.png'),'https://files.kick.com/a.png');\n  for(const url of ['javascript:alert(1)','http://static-cdn.jtvnw.net/a','https://evil.test/a','https://static-cdn.jtvnw.net.evil.test/a','https://user:pass@static-cdn.jtvnw.net/a','https://static-cdn.jtvnw.net:444/a'])assert.equal(safeAvatar('twitch',url),'');\n  assert.equal(safeAvatar('unknown','https://files.kick.com/a.png'),'');\n});\n\ntest('demo factory cannot be enabled with a public origin or production environment',() => {\n  const store=new Store();try{\n    fails(() => createApp({store,demo:true,baseUrl:'https://public.example.com',env:{}}),500);\n    fails(() => createApp({store,demo:true,baseUrl:'http://localhost',env:{NODE_ENV:'production'}}),500);\n  }finally{store.close();}\n});\n","test/accounts.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { DatabaseSync } from 'node:sqlite';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { Store } from '../lib/store.js';\nimport { createApp } from '../server.js';\nconst recipe={colors:['#ff1a35','#ffffff'],animation:'none',speed:4,angle:90,glow:0};\nconst profile=(id)=>({provider:'twitch',id,login:`user${id}`,name:`User ${id}`,avatar_url:'https://static-cdn.jtvnw.net/avatar.png'});\n\ntest('additive SQLite avatar migration preserves existing accounts and sessions across restart',()=>{\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-upgrade-')),path=join(dir,'database.sqlite');\n  const old=new DatabaseSync(path);\n  old.exec(`CREATE TABLE users (id TEXT PRIMARY KEY,name TEXT NOT NULL,role TEXT NOT NULL DEFAULT 'user',equipped TEXT,created_at TEXT NOT NULL);\n    CREATE TABLE identities (provider TEXT NOT NULL,provider_id TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id),login TEXT NOT NULL,display_name TEXT NOT NULL,PRIMARY KEY(provider,provider_id),UNIQUE(user_id,provider));\n    INSERT INTO users VALUES ('existing','Existing','user',NULL,'2026-01-01T00:00:00.000Z');\n    INSERT INTO identities VALUES ('twitch','42','existing','existing','Existing');\n    CREATE TABLE cosmetics (id TEXT PRIMARY KEY,owner_id TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,recipe TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',reason TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,reviewed_at TEXT,reviewer_id TEXT);\n    INSERT INTO cosmetics VALUES ('legacy','existing','Legacy Flare','', '{\"colors\":[\"#ff1a35\",\"#ffffff\"],\"animation\":\"none\",\"speed\":4,\"angle\":90,\"glow\":0}', 'approved','','2026-01-01',NULL,NULL);`);old.close();\n  let store=new Store(path);\n  try{\n    assert.equal(store.getCosmetic('legacy').listed,1);assert.equal(store.catalog({listedOnly:true})[0].id,'legacy');\n    assert.equal(store.account('existing').name,'Existing');assert.equal(store.account('existing').avatar_url,'');\n    const session=store.newSession('existing');assert.equal(store.linkIdentity(profile('42'),null,'1'),'existing');\n    store.close();store=new Store(path);assert.equal(store.session(session.raw).id,'existing');\n    assert.equal(store.account('existing').avatar_url,profile('42').avatar_url);\n    assert.equal(store.account('existing').profile_provider,'twitch');\n    store.linkIdentity({provider:'kick',id:'7',login:'kick',name:'Kick name',avatar_url:'https://files.kick.com/avatar.png'},'existing','1');\n    assert.equal(store.account('existing').name,'User 42','linking another platform does not replace preferred profile');\n    assert.equal(store.updateProfile('existing','kick').name,'Kick name');\n    assert.throws(()=>store.updateProfile('existing','youtube'),error=>error.status===400);\n    store.linkIdentity({...profile('42'),name:'Renamed Twitch'},'existing','1');assert.equal(store.account('existing').name,'Kick name');\n    assert.equal(store.updateProfile('existing','twitch').name,'Renamed Twitch');\n  }finally{store.close();rmSync(dir,{recursive:true,force:true});}\n});\n\ntest('Node account and moderation endpoints preserve CSRF, owner-only tables, pagination and immediate role removal',async()=>{\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-accounts-')),store=new Store();\n  const ownerId=store.linkIdentity(profile('1'),null,'1'),creatorId=store.linkIdentity(profile('2'),null,'1'),modId=store.linkIdentity(profile('3'),null,'1');\n  store.setModerator(store.user(ownerId),'3',true);\n  const owner=store.newSession(ownerId),creator=store.newSession(creatorId),mod=store.newSession(modId);\n  const app=createApp({store,dataDir:dir,baseUrl:'http://localhost',env:{}});await new Promise(resolve=>app.server.listen(0,'127.0.0.1',resolve));\n  const base=`http://127.0.0.1:${app.server.address().port}`;\n  const call=(path,data,session,headers={})=>fetch(base+path,{method:data===undefined?'GET':'POST',headers:{...(session?{Cookie:`nameflare_session=${session.raw}`} : {}),...(data===undefined?{}:{'Content-Type':'application/json',Origin:'http://localhost','X-CSRF-Token':session?.csrf || ''}),...headers},body:data===undefined?undefined:JSON.stringify(data)});\n  try{\n    assert.equal((await call('/api/admin/users')).status,401);assert.equal((await call('/api/admin/users',undefined,creator)).status,403);assert.equal((await call('/api/admin/users',undefined,mod)).status,403);\n    const usersResponse=await call('/api/admin/users?q=user2',undefined,owner);assert.equal(usersResponse.headers.get('Cache-Control'),'no-store');\n    const users=await usersResponse.json();assert.equal(users.users.length,1);assert.equal(users.users[0].twitch_id,'2');assert.equal(users.users[0].avatar_url,profile('2').avatar_url);assert.equal(users.users[0].csrf,undefined);\n    assert.equal((await call('/api/account/profile',{provider:'youtube'},creator)).status,400);\n    assert.equal((await call('/api/account/profile',{provider:'twitch'},creator,{'X-CSRF-Token':'bad'})).status,403);\n    assert.equal((await call('/api/account/profile',{provider:'twitch'},creator)).status,200);\n    const paint=store.submit(creatorId,'Review detail','Artwork description',recipe);\n    const pending=await (await call('/api/admin/queue',undefined,mod)).json();assert.equal(pending.cosmetics[0].creator_identities[0].provider_id,'2');assert.equal(pending.cosmetics[0].creator_avatar,profile('2').avatar_url);assert.ok(pending.cosmetics[0].created_at);assert.equal(pending.cosmetics[0].reviewer,null);\n    assert.equal((await call('/api/admin/review',{id:paint.id,status:'rejected',reason:'x'},mod)).status,400);\n    assert.equal((await call('/api/admin/review',{id:paint.id,status:'approved',reason:'Original and readable'},mod)).status,200);\n    const approved=await (await call('/api/admin/queue?status=approved&q=Review',undefined,mod)).json();assert.equal(approved.cosmetics[0].reviewer,'User 3');assert.ok(approved.cosmetics[0].reviewed_at);\n    const published=await (await call(`/api/v1/cosmetics/${paint.id}`)).json();assert.equal(published.cosmetic.creator_identities,undefined);assert.equal(published.cosmetic.creator_avatar,profile('2').avatar_url);assert.equal(published.cosmetic.owner_id,undefined);\n    store.equip(creatorId,paint.id);assert.equal((await call('/api/admin/review',{id:paint.id,status:'rejected',reason:'Unsafe animation'},owner)).status,200);\n    const rejected=await (await call('/api/admin/queue?status=rejected',undefined,mod)).json();assert.equal(rejected.cosmetics[0].reason,'Unsafe animation');assert.equal(rejected.stats.rejected,1);assert.equal(store.user(creatorId).equipped,null);\n    for(const path of ['/api/admin/queue?status=invalid','/api/admin/queue?offset=-1','/api/admin/users?offset=NaN'])assert.equal((await call(path,undefined,owner)).status,400);\n    for(let i=10;i<65;i++)store.linkIdentity(profile(String(i)),null,'1');\n    const page=await (await call('/api/admin/users',undefined,owner)).json();assert.equal(page.users.length,50);assert.equal(page.next_offset,50);\n    const second=await (await call('/api/admin/users?offset=50',undefined,owner)).json();assert.equal(second.users.length,8);assert.equal(second.next_offset,null);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'1',enabled:false},owner)).status,403);\n    assert.equal((await call('/api/admin/moderators',{twitchId:'3',enabled:false},owner)).status,200);assert.equal((await call('/api/admin/queue',undefined,mod)).status,403);\n  }finally{app.server.closeAllConnections();await new Promise(resolve=>app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n\ntest('first real OAuth sign-in creates a regular account, copies avatar, and returning login reuses it',async()=>{\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-signup-')),store=new Store(),originalFetch=globalThis.fetch;\n  const app=createApp({store,dataDir:dir,baseUrl:'http://localhost',env:{TWITCH_CLIENT_ID:'client',TWITCH_CLIENT_SECRET:'secret',OWNER_TWITCH_ID:'1'}});await new Promise(resolve=>app.server.listen(0,'127.0.0.1',resolve));\n  const base=`http://127.0.0.1:${app.server.address().port}`;\n  try{\n    globalThis.fetch=(url,options)=>String(url).startsWith(base)?originalFetch(url,options):Promise.resolve(String(url).includes('/token')?Response.json({access_token:'not-persisted'}):Response.json({data:[{id:'42',login:'newcreator',display_name:'New Creator',profile_image_url:profile('42').avatar_url}]}));\n    const login=async()=>{\n      const start=await fetch(base+'/auth/twitch',{redirect:'manual'}),state=new URL(start.headers.get('Location')).searchParams.get('state'),binding=start.headers.get('Set-Cookie').split(';')[0];\n      const callback=await fetch(`${base}/auth/twitch/callback?state=${state}&code=test`,{redirect:'manual',headers:{Cookie:binding}});assert.equal(callback.status,302);assert.equal(callback.headers.get('Location'),'/#account');\n      const session=callback.headers.getSetCookie().find(value=>value.startsWith('nameflare_session=')).split(';')[0];\n      return (await (await fetch(base+'/api/me',{headers:{Cookie:session}})).json()).user;\n    };\n    const first=await login();assert.equal(first.role,'user');assert.equal(first.name,'New Creator');assert.equal(first.avatar_url,profile('42').avatar_url);assert.equal(first.identities[0].provider_id,'42');\n    const returning=await login();assert.equal(returning.id,first.id);assert.equal(store.db.prepare('SELECT COUNT(*) AS n FROM users').get().n,1);\n    assert.equal(store.db.prepare('SELECT * FROM identities').get().access_token,undefined);\n  }finally{globalThis.fetch=originalFetch;app.server.closeAllConnections();await new Promise(resolve=>app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n","test/editor.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtempSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport { createApp } from '../server.js';\nimport { Store } from '../lib/store.js';\nimport { changeLayerType,moveLayer,defaultRecipe } from '../public/editor.js';\nimport { legacyLayer,normalizeLayers } from '../public/paints.js';\n\nconst recipe={colors:['#ffaabb','#9988ff','#aaffdd'],animation:'shimmer',speed:6,angle:90,glow:10,glowColor:'#aaffdd',shadowX:-2,shadowY:3,shadowBlur:5,shadowOpacity:70,shadowColor:'#111111',outline:.5,outlineColor:'#ffffff',depth:6,depthAngle:70,depthColor:'#332244'};\n\ntest('GIF layer retains its attachment, opacity and placement through reorder, duplication and type roundtrips',()=>{\n  const id=`${'a'.repeat(64)}.gif`,image={type:'image',textureId:id,opacity:.4,size:[175,120],at:[20,60],canvasRepeat:'repeat'};\n  const layers=[image,legacyLayer(defaultRecipe)];assert.equal(moveLayer(layers,image,1),1);assert.equal(layers[1].textureId,id);assert.equal(moveLayer(layers,image,-1),0);\n  const gradient=changeLayerType(image,'radial');assert.equal(gradient.textureId,id);const restored=changeLayerType(gradient,'image');assert.deepEqual(normalizeLayers([restored])[0],image);\n  const duplicate=structuredClone(restored);layers.splice(1,0,duplicate);moveLayer(layers,image,1);assert.equal(layers.filter(l=>l.textureId===id).length,2);\n  assert.equal(normalizeLayers([gradient])[0].textureId,undefined,'dormant texture never leaks into a gradient recipe');\n});\n\ntest('local demo serves every effect control and creator → owner review → equip flow',async() => {\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-demo-test-'));const store=new Store();\n  const app=createApp({store,dataDir:dir,demo:true,baseUrl:'http://localhost',env:{}});\n  await new Promise(resolve => app.server.listen(0,'127.0.0.1',resolve));const base=`http://127.0.0.1:${app.server.address().port}`;\n  const request=async(path,data,session) => fetch(base+path,{method:data===undefined ? 'GET':'POST',headers:{...(data===undefined ? {}:{'Content-Type':'application/json',Origin:'http://localhost'}),...(session ? {Cookie:session.cookie,'X-CSRF-Token':session.csrf}:{})},body:data===undefined ? undefined:JSON.stringify(data)});\n  const login=async role => {const response=await request('/api/demo/login',{role});assert.equal(response.status,200);const cookie=response.headers.get('set-cookie').split(';')[0];const me=await (await fetch(base+'/api/me',{headers:{Cookie:cookie}})).json();return {cookie,csrf:me.csrf,user:me.user};};\n  try{\n    const html=await (await request('/')).text();\n    for(const key of ['glow','glowColor','shadowX','shadowY','shadowBlur','shadowOpacity','shadowColor','outline','outlineColor','depth','depthAngle','depthColor'])assert.ok(html.includes(`name=\"${key}\"`),`Missing editor field ${key}`);\n    for(const id of ['view-guide','guide-troubleshooting','texture-attachments','preview-chat-size','preview-size-value','undo-design','redo-design','save-draft','export-design','import-design','preview-size','preview-background','preset-select','preset-gallery','submit-cosmetic','layer-list','shadow-list','texture-drop','pause-preview','tab-fill','tab-effects','tab-publish'])assert.ok(html.includes(`id=\"${id}\"`),`Missing editor action ${id}`);\n    for(const id of ['logo-twitch','logo-kick','logo-youtube','catalog-sort','catalog-name','catalog-pause','catalog-refresh','collection-status'])assert.ok(html.includes(`id=\"${id}\"`));\n    assert.ok(html.includes('name=\"listed\"'));assert.ok(html.includes('Flare name'));assert.equal(html.includes('Cosmetic name'),false);\n    const logo=await request('/logo.svg');assert.equal(logo.status,200);assert.match(logo.headers.get('content-type'),/^image\\/svg\\+xml/);assert.match(await logo.text(),/<title id=\"title\">Nameflare<\\/title>/);assert.ok(html.includes('href=\"/logo.svg\"'));assert.ok(html.includes('class=\"brand-symbol\" src=\"/logo.svg\"'));\n    const config=await (await request('/api/config')).json();assert.equal(config.name,'Nameflare');assert.equal(config.demo,true);\n    assert.equal((await request('/auth/twitch')).status,403);\n    const creator=await login('creator');assert.equal(creator.user.role,'user');\n    const submitted=await request('/api/cosmetics',{name:'Editor effects',description:'All layered effects',recipe},creator);assert.equal(submitted.status,201);const c=(await submitted.json()).cosmetic;\n    assert.deepEqual(c.recipe,recipe);assert.equal(c.status,'pending');\n    assert.equal((await request(`/api/v1/cosmetics/${c.id}`)).status,404);\n    const owner=await login('owner');assert.equal(owner.user.role,'owner');\n    const queue=await (await request('/api/admin/queue',undefined,owner)).json();assert.ok(queue.cosmetics.some(cosmetic => cosmetic.id===c.id));\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'approved',reason:'Readable and original'},owner)).status,200);\n    assert.equal((await request('/api/equip',{cosmeticId:c.id},creator)).status,200);\n    const resolved=await (await request('/api/v1/users/twitch/1002')).json();assert.deepEqual(resolved.user.cosmetic.recipe,recipe);\n    const yours=await (await request('/api/my/cosmetics',undefined,creator)).json();assert.ok(yours.cosmetics.some(cosmetic => cosmetic.id===c.id && cosmetic.status==='approved'));\n    const sessionCookie=creator.cookie.split('=')[1];assert.ok(store.session(sessionCookie));\n    assert.equal((await request('/api/logout',{},creator)).status,200);assert.equal(store.session(sessionCookie),null);\n  }finally{app.server.closeAllConnections();await new Promise(resolve => app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n","test/paints.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { mkdtempSync,rmSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { tmpdir } from 'node:os';\nimport { validateRecipe,validateTexture } from '../lib/cosmetics.js';\nimport { normalizeLayers,normalizeShadows,convert7TVPaint,layerBackground } from '../public/paints.js';\nimport { applyCosmetic, updateCosmeticText, createFrameUpdate } from '../public/renderer.js';\nimport { Store } from '../lib/store.js';\nimport { createApp } from '../server.js';\nconst base={colors:['#ff1a35','#ffd4dc'],angle:90,glow:4,speed:4,animation:'shimmer'};\nconst linear={type:'linear',opacity:.6,stops:[{at:1,color:'#ffffff'},{at:0,color:'#ff000080'},{at:.4,color:'#00000000'}],angle:120,repeat:true};\nconst textureId=`${'a'.repeat(64)}.gif`;\n\ntest('complex recipe bounds layers, RGBA stops, image IDs, shadow stacks, and text weight',()=>{\n  const r=validateRecipe({...base,layers:[linear,{type:'radial',opacity:1,stops:linear.stops,shape:'circle'}, {type:'image',textureId,opacity:.4}],shadows:[{x:3,y:-2,blur:8,color:'#ff1a3580'}],fontWeight:700});\n  assert.equal(r.layers.length,3);assert.equal(r.layers[0].stops[0].at,0);assert.equal(r.shadows[0].color,'#ff1a3580');assert.equal(r.fontWeight,700);\n  for(const invalid of [\n    {layers:[]},{layers:Array(7).fill(linear)},{layers:[{...linear,opacity:2}]},{layers:[{...linear,stops:[{at:0,color:'red'},{at:1,color:'#ffffff'}]}]},\n    {layers:[{type:'image',textureId:'https://evil.test/a.gif'}]},{layers:[{...linear,canvasRepeat:'url(evil)'}]},{layers:[{...linear,size:[Infinity,100]}]},\n    {layers:[{...linear,stops:[{at:0,color:'#ff0000'},{at:0,color:'#ffffff'}]}]},{shadows:Array(9).fill({x:0,y:0,blur:1,color:'#ffffff'})},{shadows:[{x:0,y:0,blur:80,color:'#ffffff'}]},{fontWeight:1000}\n  ])assert.throws(()=>validateRecipe({...base,...invalid}),error=>error.status===400);\n  assert.throws(()=>normalizeLayers([{type:'image',image_url:'https://evil'}]),/upload/);\n  assert.equal(normalizeLayers([{type:'image'}],{allowEmptyImages:true})[0].textureId,undefined);\n  assert.throws(()=>normalizeShadows(null));\n});\ntest('7TV legacy and gradients paint conversion decodes packed RGBA and warns about images/flairs',()=>{\n  const converted=convert7TVPaint({name:'Complex legacy',gradients:[{function:'CONIC_GRADIENT',stops:[{at:0,color:0xff000080},{at:1,color:-1}],repeat:true,angle:45,at:[20,70],size:[150,200],canvas_repeat:'repeat'},{function:'URL',image_url:'https://cdn.7tv.app/file.gif',stops:[],repeat:false}],shadows:[{x_offset:2,y_offset:-2,radius:8,color:0x00ff0080}],text:{weight:600,transform:'uppercase',stroke:{width:1,color:0xffffffff}},flairs:[{kind:'VECTOR',data:'<svg>'}]});\n  assert.equal(converted.recipe.layers[0].type,'conic');assert.equal(converted.recipe.layers[0].stops[0].color,'#ff000080');assert.equal(converted.recipe.layers[0].stops[1].color,'#ffffffff');assert.equal(converted.recipe.shadows[0].color,'#00ff0080');assert.equal(converted.recipe.fontWeight,600);assert.equal(converted.recipe.layers[1].textureId,undefined);assert.equal(converted.warnings.length,3);assert.equal(JSON.stringify(converted.recipe).includes('cdn.7tv'),false);\n  const legacy=convert7TVPaint({function:'LINEAR_GRADIENT',angle:270,repeat:false,stops:[{at:0,color:0xff1a35ff},{at:1,color:0xffffffff}]});assert.equal(legacy.recipe.layers[0].angle,270);\n});\ntest('7TV current database layers convert solid/linear/radial/image data safely',()=>{\n  const converted=convert7TVPaint({name:'Current paint',data:{layers:[\n    {opacity:.8,ty:{type:'single_color',data:-1}},\n    {opacity:.6,ty:{type:'linear_gradient',data:{angle:180,repeating:true,stops:[{at:0,color:0xff1a35ff},{at:1,color:-1}]}}},\n    {opacity:1,ty:{type:'radial_gradient',data:{shape:'circle',repeating:false,stops:[{at:0,color:0x00ff00ff},{at:1,color:-1}]}}},\n    {opacity:.5,ty:{type:'image',data:{outputs:[{url:'https://cdn.example/image.webp'}]}}}\n  ],shadows:[{offset_x:3,offset_y:4,blur:7,color:0x11111180}]}});\n  assert.deepEqual(converted.recipe.layers.map(l=>l.type),['solid','linear','radial','image']);assert.equal(converted.recipe.layers[0].opacity,.8);assert.equal(converted.recipe.shadows[0].y,4);\n  assert.throws(()=>convert7TVPaint({layers:[{ty:{type:'shader',data:{}}}]}),/Unsupported/);\n  assert.throws(()=>convert7TVPaint({gradients:[{function:'LINEAR_GRADIENT',stops:[{at:0,color:'url(javascript:x)'},{at:1,color:-1}]}]}));\n});\ntest('layer backgrounds are safe typed CSS with repeating gradients and alpha',()=>{\n  const [layer]=normalizeLayers([linear]);assert.match(layerBackground(layer),/^repeating-linear-gradient\\(120deg/);assert.match(layerBackground(layer),/0\\.5020/);\n  assert.match(layerBackground(normalizeLayers([{...linear,type:'radial',shape:'circle'}])[0]),/^repeating-radial-gradient/);\n  assert.match(layerBackground(normalizeLayers([{...linear,type:'conic'}])[0]),/^repeating-conic-gradient/);\n});\ntest('layered renderer preserves text, removes stale layers, clamps fonts, and honors reduced motion',()=>{\n  const previousDoc=globalThis.document,previousWindow=globalThis.window;\n  const children=[],values=new Map(),classes=new Set();\n  globalThis.document={createElement:()=>({dataset:{},style:{},setAttribute(){},remove(){const i=children.indexOf(this);if(i>=0)children.splice(i,1);}})};\n  globalThis.window={matchMedia:()=>({matches:false})};\n  const element={textContent:'B_X_N_E',dataset:{},append:child=>children.push(child),classList:{add:(...k)=>k.forEach(x=>classes.add(x)),remove:(...k)=>k.forEach(x=>classes.delete(x))},style:{setProperty:(k,v)=>values.set(k,v),removeProperty:k=>values.delete(k)}};\n  try{\n    const r=validateRecipe({...base,layers:[linear,{type:'image',textureId,opacity:.5}],shadows:[{x:2,y:3,blur:8,color:'#ffffff80'}],fontWeight:700});applyCosmetic(element,{recipe:r},'https://example.com');assert.equal(children.length,2);assert.equal(children[0].dataset.text,'B_X_N_E');assert.equal(element.textContent,'B_X_N_E');assert.equal(children[0].style.opacity,.6);assert.match(children[1].style.backgroundImage,/example.com\\/textures/);assert.match(element.style.filter,/drop-shadow\\(2px 3px/);\n    applyCosmetic(element,{recipe:r},'https://example.com',{previewTextures:{[textureId]:'blob:https://example.com/local'}});assert.equal(children.length,2);assert.match(children[1].style.backgroundImage,/blob:/);\n    const originalLayers=[...children],originalStyles=children.map(child=>({...child.style}));\n    element.firstChild={nodeType:3,data:'B_X_N_E'};\n    updateCosmeticText(element,'<script>not HTML</script>');\n    assert.equal(element.firstChild.data,'<script>not HTML</script>');assert.equal(element.dataset.text,'<script>not HTML</script>');\n    assert.equal(children.length,2);for(const [i,child] of children.entries()){assert.equal(child,originalLayers[i]);assert.equal(child.dataset.text,'<script>not HTML</script>');assert.deepEqual(child.style,originalStyles[i]);}\n    assert.equal(element.dataset.animation,'shimmer');assert.equal(element.dataset.paused,'false');assert.equal(element.style.filter.includes('drop-shadow'),true);\n    window.matchMedia=()=>({matches:true});applyCosmetic(element,{recipe:r},'https://example.com');assert.doesNotMatch(children[1].style.backgroundImage,/url/);\n    applyCosmetic(element,null,'https://example.com');assert.equal(children.length,0);assert.equal(classes.has('nameflare-name'),false);\n  }finally{if(previousDoc===undefined)delete globalThis.document;else globalThis.document=previousDoc;if(previousWindow===undefined)delete globalThis.window;else globalThis.window=previousWindow;}\n});\ntest('text-only renderer updates preserve plain text and avoid redundant DOM writes',()=>{\n  let writes=0;const text={nodeType:3,_data:'Initial',get data(){return this._data;},set data(value){writes++;this._data=value;}};\n  const element={firstChild:text,dataset:{text:'Initial',animation:'flow',paused:'true'}};\n  updateCosmeticText(element,'New name');assert.equal(text.data,'New name');assert.equal(element.dataset.text,'New name');assert.equal(writes,1);\n  updateCosmeticText(element,'New name');assert.equal(writes,1);assert.equal(element.dataset.animation,'flow');assert.equal(element.dataset.paused,'true');\n  updateCosmeticText(element,'');assert.equal(text.data,'');assert.equal(element.dataset.text,'');\n  const plain={textContent:'Plain',dataset:{}};updateCosmeticText(plain,'<img src=x>');assert.equal(plain.textContent,'<img src=x>');assert.equal(plain.dataset.text,undefined);\n});\ntest('rapid preview input batches into one frame and renders only the latest value',()=>{\n  const frames=[],rendered=[];let value='';\n  const schedule=createFrameUpdate(()=>rendered.push(value),callback=>frames.push(callback));\n  for(const name of ['B','B_','B_X','B_X_N_E']){value=name;schedule();}\n  assert.equal(frames.length,1);assert.deepEqual(rendered,[]);frames.shift()();assert.deepEqual(rendered,['B_X_N_E']);\n  value='Next';schedule();assert.equal(frames.length,1);frames.shift()();assert.deepEqual(rendered,['B_X_N_E','Next']);\n});\ntest('animated GIF image layers remain private until approved and become private after revocation',async()=>{\n  // Valid 1×1 GIF header plus two graphics-control/image frames; byte preservation keeps animation.\n  const single=Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7','base64');\n  const bytes=Buffer.concat([single.subarray(0,19),single.subarray(19,-1),single.subarray(19)]);\n  const data=`data:image/gif;base64,${bytes.toString('base64')}`;assert.equal(validateTexture(data).bytes.equals(bytes),true);\n  const dir=mkdtempSync(join(tmpdir(),'nameflare-layer-'));const store=new Store();store.seedDemo();const app=createApp({store,dataDir:dir,baseUrl:'http://localhost',env:{}});await new Promise(resolve=>app.server.listen(0,'127.0.0.1',resolve));const origin=`http://127.0.0.1:${app.server.address().port}`;\n  const creator=store.newSession('demo-creator'),owner=store.newSession('demo-owner');\n  const request=(path,data,session)=>fetch(origin+path,{method:data===undefined?'GET':'POST',headers:{...(data===undefined?{}:{'Content-Type':'application/json',Origin:'http://localhost'}),...(session?{Cookie:`nameflare_session=${session.raw}`,'X-CSRF-Token':session.csrf}:{})},body:data===undefined?undefined:JSON.stringify(data)});\n  try{\n    const upload=await request('/api/textures',{data},creator);assert.equal(upload.status,201);const id=(await upload.json()).id;\n    const r={...base,layers:[{type:'image',textureId:id,opacity:.8},linear],shadows:[{x:3,y:3,blur:5,color:'#ff1a3580'}]};\n    assert.equal((await request('/api/cosmetics',{name:'Stolen texture',description:'',recipe:r},owner)).status,400);\n    const submitted=await request('/api/cosmetics',{name:'Animated complex paint',description:'Two GIF frames',recipe:r},creator);assert.equal(submitted.status,201);const c=(await submitted.json()).cosmetic;\n    assert.equal((await request(`/textures/${id}`)).status,401);assert.equal((await request('/api/admin/review',{id:c.id,status:'approved',reason:''},owner)).status,200);\n    const asset=await request(`/textures/${id}`);assert.equal(asset.status,200);assert.equal(asset.headers.get('content-type'),'image/gif');assert.equal(Buffer.from(await asset.arrayBuffer()).equals(bytes),true);\n    const publicC=await (await request(`/api/v1/cosmetics/${c.id}`)).json();assert.equal(publicC.cosmetic.recipe.layers.length,2);\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'rejected',reason:'Unsafe flashing'},owner)).status,200);assert.equal((await request(`/textures/${id}`)).status,401);\n    const html=await (await request('/')).text();assert.match(html,/BY B_X_N_E/);assert.match(html,/role=\"tablist\" aria-label=\"Paint editor sections\"/);\n    for(const path of ['/paints.js','/editor.js'])assert.equal((await request(path)).status,200);\n  }finally{app.server.closeAllConnections();await new Promise(resolve=>app.server.close(resolve));store.close();rmSync(dir,{recursive:true,force:true});}\n});\n","README.md":"# Nameflare\n\n**Your name, in motion. Owned by B_X_N_E.** Independent, open-source animated name cosmetics for streaming communities. Crimson branding and a readable studio interface inspired by the owner’s [Banechat](https://chat.bxne.dev). No emotes or badges in this MVP.\n\nNameflare is intended to have **one official hosted API**, maintained by the project owner, with public source code. Publishing the code does not expose the live database, secrets, or moderator access. Forks may self-host their own service; applications should use the official operator's configured origin by default. No official domain has been registered or deployed by this workspace.\n\n## Included\n\n- **Flares** are the product name for name cosmetics. Name and describe a Flare in Finish, then choose public listing (default) or unlisted sharing. Approved public Flares appear in Discover; anyone can browse without signing in and equip after signing in. Unlisted Flares are omitted from the catalog, not private: their link, equipped identity, recipe, and assets remain public. Only their creator can change listing, with an audit record; approval is never bypassed.\n- Discover includes server-side search/effect filters, recently approved/most-equipped/name sorting, equip counts, custom-name and paused previews, direct equip buttons, and shareable `/#discover/:id` detail pages. Collection has review-status filters, share links, and publication controls. SVG Twitch, Kick, and YouTube brand paths are embedded locally from [Simple Icons](https://github.com/simple-icons/simple-icons) (CC0); trademarks remain with their owners.\n\n- Live Paint / Effects / Finish studio: up to six solid/linear/radial/conic/image layers, per-layer opacity/sizing/tiling, 2–12 positioned RGBA gradient stops, animated GIF/WebP/PNG fills, glow, outline, eight additional colored shadows, adjustable text weight, and 0–8 layers of 3D-style extrusion.\n- Six visual presets, randomized starting points, layer reorder/duplicate, undo/redo, pause motion, light/dark/checkerboard previews, actual chat-size preview with a magnified inspection view, browser-local drafts, and JSON import/export. Recognized 7TV paint JSON can be converted with warnings; it does not import entitlements or artwork rights.\n- Drag/drop GIFs without signing in to preview them locally. The authenticated upload happens on submission and validates ownership for every image layer. Files remain limited to 2 MB / 2048×2048 and animation bytes are preserved. Reattach image files after reload/import; JSON drafts do not embed them. Preview text does not rename a platform account.\n- Public passwordless sign-up/sign-in through Twitch, Kick (PKCE), and YouTube channel OAuth. Linked platform avatars, selectable profile source, account creation time, connected identities, and equipped paints; one account can link one identity per platform. No provider access/refresh tokens are persisted.\n- SQLite persistence for users, hashed sessions, cosmetics, moderation roles, and audit records.\n- Owner-only searchable, paginated accounts/moderators table with grant/removal controls using verified numeric Twitch IDs. The owner is bootstrapped only from `OWNER_TWITCH_ID`; first signup is never automatically an admin.\n- Searchable, paginated pending/approved/disapproved dashboard with creator avatars/verified identities, submission/review timestamps, reviewer, feedback, full recipe, and pause/light-backdrop previews. Independent review (no self-review), audited approval/rejection/revocation, and approved-only public recipes/assets.\n- CORS-enabled versioned public API, batch identity resolution, a transparent OBS browser-source overlay, direct public Twitch chat connection, and an authenticated single-room relay for authorized Kick/YouTube connectors.\n- On-site creator, OAuth, moderation, OBS, troubleshooting, and API handbook (`/guide` or the Guide navigation item).\n- Cloudflare-native Worker with D1 accounts/recipes, private R2 textures, OAuth, moderation and edge rate limiting; see [Cloudflare runbook](docs/CLOUDFLARE.md). Remote deployment is not performed. Direct Twitch works; the Node-only streaming relay is not ported.\n- Responsive dependency-free web interface, Node tests, Docker hosting configuration, and a local Wrangler development dependency.\n\n## Run locally\n\nRequires Node **22.13+** with built-in SQLite (Node 24 recommended). There are no third-party runtime dependencies or packages to install.\n\n```sh\nnpm run demo\n```\n\nVisit `http://localhost:3000`. Use **Sign in** to try Creator, Owner, or Moderator demo accounts. Demo is loopback-only, uses `data-demo`, blocks real OAuth, and cannot run in production. Sample cosmetics are explicitly seeded only in demo mode.\n\nFor real local OAuth, copy `.env.example` to `.env`, fill credentials, and run:\n\n```sh\nnpm start\nnpm run check\nnpm test\n```\n\n`npm run check` performs JavaScript syntax validation (this project is JavaScript, not TypeScript). Provider adapter tests mock OAuth servers; real sign-in still requires valid credentials and manual verification on each provider.\n\n## Account setup\n\nRegister these exact callback URLs using your actual `PUBLIC_BASE_URL`:\n\n- `/auth/twitch/callback` — Twitch developer console. Identity only; no posting permissions.\n- `/auth/kick/callback` — Kick developer application with `user:read` scope and PKCE.\n- `/auth/youtube/callback` — Google OAuth web app; enable YouTube Data API v3 and request `youtube.readonly`. Public use may require Google's sensitive-scope verification. The identity is the authenticated YouTube **channel ID**, not a Google email or account ID.\n\nSee [step-by-step Kick and YouTube setup](docs/ORACLE.md#public-accounts-and-provider-setup) for exact callbacks, consent/test-user configuration, private credentials and service restart. Anyone can register through an enabled provider; YouTube Testing access is restricted to test users until the Google app is published/verified as required.\n\nConfigure your numeric Twitch user ID as `OWNER_TWITCH_ID` before launch, then sign in with that Twitch account. Prospective moderators sign in with Twitch first and find their numeric ID under Connected accounts. The owner adds it from Moderation. Roles are read from the database on every request; removing a moderator takes effect without waiting for session expiry.\n\n## Integrations\n\nSee [API and renderer contract](docs/API.md), [deployment guide](docs/DEPLOYMENT.md), and [moderation/security policy](SECURITY.md).\n\n**Compatibility is not automatic:** Twitch/Kick/YouTube websites do not render Nameflare effects without a dedicated integration. Stock Chatterino does not consume this API. An adapter or fork is required; this repository provides the contract and a reusable web renderer, not a native Chatterino patch. Twitch direct chat is implemented but must be live-tested against the platform before launch. Kick/YouTube need an authorized connector feeding the relay; their live-chat connectors are not shipped.\n\nThe “3D” effect is layered text extrusion, not a WebGL mesh editor. Designs are a constrained, cross-client recipe, not arbitrary CSS, shaders, or scripts. Native clients may fall back to a solid palette color when they do not support an effect. Browser `prefers-reduced-motion` disables animations and replaces animated textures with a static gradient.\n\n## Hosting and source\n\nChoose the [Cloudflare-native deployment](docs/CLOUDFLARE.md), or deploy a single Node container behind HTTPS with durable storage. Configure OAuth secrets privately and publish corresponding source. Do not deploy demo mode. Docker Compose is supplied but has not been executed or deployed here. Do not run the Node/SQLite backend on ephemeral/serverless storage; the separate Cloudflare runtime uses D1/R2 instead.\n\nThis is an **MVP, not an audited production service**. Before a public launch add image decoding/re-encoding and malware/content scanning, backup/restore operations, per-client edge rate limits, an abuse-report workflow, privacy/retention and account-deletion workflows, multi-room overlay authorization if needed, and load tests. See the deployment guide for operational limits.\n\n## License and contribution\n\n[GNU Affero General Public License v3.0 only](LICENSE). A modified hosted version must offer users its corresponding source under the license. Artwork has separate creator rights; the software license does not grant rights to every uploaded cosmetic. Nameflare branding and designation of an “official” API are not granted by the code license.\n\nOpen issues and pull requests are welcome once the repository is published. Keep secrets and personal data out of examples; include tests for changes affecting moderation, identity linking, rendering, or API publication. Use `SOURCE_URL` to link the running service to its actual public repository. On Node, without it, `/source` downloads a JSON bundle of explicitly allowlisted source files (no secrets or databases); `/license` serves the license text. A deployer distributing or modifying this program must comply with the full license and provide the appropriate corresponding source.\n\nThe Cloudflare runtime requires `SOURCE_URL` for its source link; it does not generate a filesystem bundle. Its OAuth flows have local mocked coverage, but not live-provider verification.\n\nNameflare is not affiliated with 7TV, Twitch, Kick, YouTube, or Chatterino.\n","LICENSE":"GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007\n\nCopyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\nEveryone is permitted to copy and distribute verbatim copies\nof this license document, but changing it is not allowed.\n\nPreamble\n\nThe GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nDevelopers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\nA secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate. Many developers of free software are heartened and\nencouraged by the resulting cooperation. However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\nThe GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community. It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server. Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\nAn older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals. This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\nTERMS AND CONDITIONS\n\n0. Definitions.\n\n\"This License\" refers to version 3 of the GNU Affero General Public License.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\nThe Corresponding Source for a work in source code form is that\nsame work.\n\n2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\na) The work must carry prominent notices stating that you modified\nit, and giving a relevant date.\n\nb) The work must carry prominent notices stating that it is\nreleased under this License and any conditions added under section\n7. This requirement modifies the requirement in section 4 to\n\"keep intact all notices\".\n\nc) You must license the entire work, as a whole, under this\nLicense to anyone who comes into possession of a copy. This\nLicense will therefore apply, along with any applicable section 7\nadditional terms, to the whole of the work, and all its parts,\nregardless of how they are packaged. This License gives no\npermission to license the work in any other way, but it does not\ninvalidate such permission if you have separately received it.\n\nd) If the work has interactive user interfaces, each must display\nAppropriate Legal Notices; however, if the Program has interactive\ninterfaces that do not display Appropriate Legal Notices, your\nwork need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\na) Convey the object code in, or embodied in, a physical product\n(including a physical distribution medium), accompanied by the\nCorresponding Source fixed on a durable physical medium\ncustomarily used for software interchange.\n\nb) Convey the object code in, or embodied in, a physical product\n(including a physical distribution medium), accompanied by a\nwritten offer, valid for at least three years and valid for as\nlong as you offer spare parts or customer support for that product\nmodel, to give anyone who possesses the object code either (1) a\ncopy of the Corresponding Source for all the software in the\nproduct that is covered by this License, on a durable physical\nmedium customarily used for software interchange, for a price no\nmore than your reasonable cost of physically performing this\nconveying of source, or (2) access to copy the\nCorresponding Source from a network server at no charge.\n\nc) Convey individual copies of the object code with a copy of the\nwritten offer to provide the Corresponding Source. This\nalternative is allowed only occasionally and noncommercially, and\nonly if you received the object code with such an offer, in accord\nwith subsection 6b.\n\nd) Convey the object code by offering access from a designated\nplace (gratis or for a charge), and offer equivalent access to the\nCorresponding Source in the same way through the same place at no\nfurther charge. You need not require recipients to copy the\nCorresponding Source along with the object code. If the place to\ncopy the object code is a network server, the Corresponding Source\nmay be on a different server (operated by you or a third party)\nthat supports equivalent copying facilities, provided you maintain\nclear directions next to the object code saying where to find the\nCorresponding Source. Regardless of what server hosts the\nCorresponding Source, you remain obligated to ensure that it is\navailable for as long as needed to satisfy these requirements.\n\ne) Convey the object code using peer-to-peer transmission, provided\nyou inform other peers where the object code and Corresponding\nSource of the work are being offered to the general public at no\ncharge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\na) Disclaiming warranty or limiting liability differently from the\nterms of sections 15 and 16 of this License; or\n\nb) Requiring preservation of specified reasonable legal notices or\nauthor attributions in that material or in the Appropriate Legal\nNotices displayed by works containing it; or\n\nc) Prohibiting misrepresentation of the origin of that material, or\nrequiring that modified versions of such material be marked in\nreasonable ways as different from the original version; or\n\nd) Limiting the use for publicity purposes of names of licensors or\nauthors of the material; or\n\ne) Declining to grant rights under trademark law for use of some\ntrade names, trademarks, or service marks; or\n\nf) Requiring indemnification of licensors and authors of that\nmaterial by anyone who conveys the material (or modified versions of\nit) with contractual assumptions of liability to the recipient, for\nany liability that these contractual assumptions directly impose on\nthose licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n13. Remote Network Interaction; Use with the GNU General Public License.\n\nNotwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software. This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time. Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\nEND OF TERMS AND CONDITIONS\n\nHow to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n<one line to give the program's name and a brief idea of what it does.>\nCopyright (C) <year> <name of author>\n\nThis program is free software: you can redistribute it and/or modify\nit under the terms of the GNU Affero General Public License as published\nby the Free Software Foundation, either version 3 of the License, or\n(at your option) any later version.\n\nThis program is distributed in the hope that it will be useful,\nbut WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\nGNU Affero General Public License for more details.\n\nYou should have received a copy of the GNU Affero General Public License\nalong with this program. If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source. For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code. There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\nYou should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n","SECURITY.md":"# Security & moderation\n\n## Current controls\n\n- OAuth state is random, browser-cookie-bound, provider-specific, time-limited, and single-use upon successful validation. Kick and YouTube use PKCE. Linking refuses an identity already owned by another account and refuses replacing a different identity for the same provider.\n- Provider tokens are used only for identity lookup and are never persisted. YouTube requires read-only channel access; channel ID, not email, is the linked identity.\n- Session bearer tokens are stored hashed. Cookies are HttpOnly, SameSite=Lax, and Secure on HTTPS. Mutations require an exact trusted Origin and session-bound CSRF token. Logout and new authentication rotate/revoke the current session.\n- Owner identity is configured, never assigned to first signup. Role enforcement is server-side. Mods cannot appoint other mods, remove owners, or approve their own designs. Reviews and role changes have an audit trail.\n- Public endpoints and assets publish approved material only. Revocation clears equipped instances. Readers must expire their cache; previously downloaded files cannot be revoked retroactively.\n- Names, descriptions, review notes, and chat messages are rendered as plain text. Cosmetic properties are constrained and bounded; raw CSS/HTML/SVG/shaders and arbitrary external URLs are not accepted.\n- Requests, upload bytes/canvas dimensions, pending submissions, texture count, and overlay connection count have baseline limits. Browser reduced-motion uses a static gradient instead of animated image textures.\n\n## Known limitations\n\nThis MVP is not independently audited. Raster upload inspection validates signatures and canvas bounds, not full decoding, frame limits, malware, or photosensitive content. Add a hardened image pipeline before public uploads. Synchronous SQLite/file I/O, a socket-address limiter, and a global single-room SSE relay are not a large-scale or multi-tenant architecture. Uploaded files accumulate; retention, orphan cleanup, account deletion/export, copyright reports, moderation appeals, and live provider webhook connectors are not implemented. The public API intentionally discloses linked platform names/IDs and equipped designs; make that visibility clear to users.\n\nAccount sessions last seven days. If provider-level disconnection/reauthorization enforcement is required, implement shorter sessions or explicit revocation; the app does not keep long-lived provider tokens. Changing owner configuration alone does not revoke historical owner roles.\n\n## Review checklist\n\nApprove only original or licensed artwork. Check hateful/sexual/illegal material, impersonation, harmful symbols, unreadable designs, and rapid flashes. Keep outlines, shadows, and depth readable in chat at normal size. Explain rejections; remove approval for later policy violations. Do not approve your own submission through a second controlled moderator account. Creator artwork rights are separate from the software license.\n\n## Reporting issues\n\nBefore public launch, the operator must publish a private security contact in the live service and repository. Do not put credentials, exploit details for a live service, personal data, or private pending artwork in public issues. Rotate compromised OAuth secrets, overlay keys, and sessions through an audited incident procedure. Keep demo mode off internet-facing deployments.\n","docs/API.md":"# Nameflare API & renderer contract\n\nThe official origin is configured by the operator in `PUBLIC_BASE_URL`. Until deployment, all examples refer to your local origin, not a reserved official domain. API paths are identical on self-hosted forks. Public API reads require no key; account/moderation mutations require the app's same-origin HttpOnly session plus CSRF token and are not a third-party write API.\n\n## Approved public data\n\n- `GET /api/v1/cosmetics?limit=60&offset=0&q=aurora` — `{version:1, total: number, cosmetics: [...], next_offset: number | null}`. Limit 1–100; offset 0–1,000,000. Only approved, publicly listed Flares. `total` counts all approved public Flares, not the filtered result. Search by Flare or creator name. `effect`: `all` (default), `animated`, `static`, `glow`, `texture`, `depth`. `sort`: `newest` (recent approval, default), `popular` (current equipped-user count), `name`. Invalid filter/sort returns 400; filtering happens before pagination.\n- `GET /api/v1/cosmetics/:id` — `{cosmetic: {...}}`. Approved unlisted Flares are also readable by ID. Pending/rejected/unknown IDs return 404.\n- `GET /api/v1/users/:provider/:provider_id` — `{user: {provider, provider_id, login, display_name, cosmetic}}`. `cosmetic` is an approved recipe or null. Unknown identity returns 404. Providers: `twitch`, `kick`, `youtube`. Use immutable platform IDs, never a user-supplied username; YouTube uses the channel ID.\n- `POST /api/v1/resolve` — JSON `{\"provider\":\"twitch\",\"ids\":[\"123\",\"456\"]}` → `{users:[...]}`. At most 100 string IDs. Unknown identities are omitted; known identities without a cosmetic return null. No credentials needed; wildcard CORS supported. Results have `no-store`.\n- `GET /textures/:textureId` — approved raster asset. Pending textures are session-restricted to the uploader and reviewers. Access becomes private again upon revocation of all designs referencing a texture. Prior client caches may retain data for up to 15 seconds; previously downloaded files cannot be recalled.\n\nRead responses include `Access-Control-Allow-Origin: *`, `Cache-Control: public, max-age=15, must-revalidate`, and an ETag. Conditional 304 responses are not implemented. Never cache a null identity or cosmetic indefinitely. Default in-process rate limits: 300 GET requests and 40 non-GET requests per minute per socket address (including app requests). Implement proper edge limiting in production; behind a proxy the application may see a shared address.\n\n```json\n{\n  \"id\": \"cosmetic-uuid\",\n  \"name\": \"Golden dimension\",\n  \"creator\": \"artist\",\n  \"updated_at\": \"2026-10-06T00:00:00.000Z\",\n  \"recipe\": {\n    \"colors\": [\"#fff3bd\", \"#e0ac56\", \"#fff5d8\"],\n    \"animation\": \"shimmer\",\n    \"speed\": 7,\n    \"angle\": 120,\n    \"glow\": 3,\n    \"glowColor\": \"#f2c779\",\n    \"shadowX\": 2,\n    \"shadowY\": 3,\n    \"shadowBlur\": 4,\n    \"shadowOpacity\": 70,\n    \"shadowColor\": \"#000000\",\n    \"outline\": 0.5,\n    \"outlineColor\": \"#ffffff\",\n    \"depth\": 6,\n    \"depthAngle\": 70,\n    \"depthColor\": \"#705123\"\n  }\n}\n```\n\n## Creator publication and sharing\n\nThe UI calls cosmetics **Flares**. Existing `/cosmetics` routes, response keys, renderer names, and identity resolution contracts remain compatible.\n\n`POST /api/cosmetics` accepts an optional boolean `listed` (default `true`) alongside name, description, and recipe. `false` means unlisted—not private. All submissions still require independent approval. Public metadata includes `description`, `creator_avatar`, `creator_provider`, `listed`, and `equipped_count`; it never includes owner IDs, connected identities, moderation notes, or session data. Batch identity resolution retains its smaller legacy metadata shape.\n\n`POST /api/cosmetics/publication` accepts `{ \"id\": \"flare-id\", \"listed\": true }`. Requires a same-origin authenticated session, valid CSRF token, and ownership; moderator/owner roles do not override creator ownership. Every publication change is audited. Changing listing does not change approval, revoke existing equips, or hide public textures. Revocation still removes direct reads and clears all equipped assignments.\n\nShare `/#discover/:id` to open an approved Flare's preview. Anyone may read it; equipping any approved Flare requires sign-in, not creator ownership. Unlisted recipes/assets are accessible through ID links and equipped identity resolution and are not a confidentiality feature. Listing changes can take up to 15 seconds to leave public client caches.\n\nCloudflare deployments must apply `0003_flare_publication.sql` before running this release. Node applies its additive migration on startup. Existing records default to public listing.\n\n## Recipe version 1\n\nLegacy effect colors and the fallback `colors` palette are six-digit `#RRGGBB`. `colors`: 2–5 colors (the visual studio exposes three fallback colors). Complex layer/stop/shadow colors also allow `#RRGGBBAA` alpha. `animation`: `flow`, `shimmer`, `pulse`, `none`. `speed`: 1–20 seconds per cycle. `angle`: 0–360 degrees. `glow`: 0–20 px blur with `glowColor` (defaults to first palette color). `shadowX`/`shadowY`: −12–12 px, `shadowBlur`: 0–20 px, `shadowOpacity`: 0–100 percent, `shadowColor`: hex. `outline`: 0–2 px with hex `outlineColor`. `depth`: integer 0–8; `depthAngle`: 0–360 degrees; `depthColor`: hex. Defaults for old recipes: no shadow, outline, or depth; black shadow, white outline, `#303047` extrusion, 45° depth angle. Missing optional effects must not crash a client.\n\nOptional `textureId`: SHA-256 file key with `.gif`, `.webp`, or `.png` extension. Fetch only from the same trusted service origin at `/textures/:textureId`. Up to 2 MB; max logical canvas 2048×2048. Upload validation currently checks signature/canvas bounds, **not full decoding or total animated-frame cost**. Do not treat that as a complete image-safety pipeline.\n\nRender extrusion beneath the name's fill: for each layer i from 1 to depth, offset by `(cos(depthAngle) × i, sin(depthAngle) × i)` in pixels. The web implementation uses a behind-text pseudo-element so shadows do not cover the gradient. Apply glow around the composite glyph; outline around the fill. All names and chat messages must be inserted as text, never HTML. Display names remain the chat platform's display names; recipes never contain identity overrides.\n\nThe shared browser reference is `/renderer.js` plus `/effects.css` (also stored under `public`). Use `applyCosmetic(nameElement, cosmetic, serviceOrigin)` after setting `nameElement.textContent`. Reapply if the text changes. Honor reduced motion and provide a full disable toggle. Clamp values defensively even if data came from the official API.\n\n## Complex paint extension (backward-compatible optional fields)\n\nRecipes can include `layers`, `shadows`, and `fontWeight`. Clients that only support the original fields can use the fallback palette. Do not claim native 7TV API compatibility; this is Nameflare’s own format with a conversion helper.\n\n- `layers`: 1–6 objects, **top layer first**. Each has `type` (`solid`, `linear`, `radial`, `conic`, `image`), `opacity` (0–1), `size` ([width%, height%], each 1–500), `at` ([x%, y%], each 0–100), and `canvasRepeat` (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`).\n- Solid fill: `color` in hex RGBA.\n- Gradient fill: 2–12 `stops`, each `{at:0..1, color:\"#RRGGBB\" | \"#RRGGBBAA\"}`. Stops are normalized into ascending position order and must span distinct positions. Also `angle` (0–360), `repeat` (boolean), and `shape` (`circle` or `ellipse`, for radial gradients).\n- Image fill: `textureId` references a locally uploaded GIF/WebP/PNG. Every layer asset is ownership-checked on submission, privately accessible before review, and publicly accessible only while an approved paint references it. Opacity applies to the entire image layer, including GIF animation.\n- `shadows`: up to 8 extra `{x:-20..20, y:-20..20, blur:0..30, color:\"#RRGGBBAA\"}` shadows, rendered using ordered drop-shadow filters. Native implementations must take the same stacking order into account.\n- `fontWeight`: 400–900. The web renderer leaves the authoritative chat name intact and creates aria-hidden decorative fill layers; no text transformation or HTML injection.\n\nThe studio offers Paint / Effects / Finish tabs, editable positioned alpha stops, layer reorder/duplicate/remove, per-layer sizing/tiling, multi-shadow controls, presets, randomized starting points, undo/redo, and a pause-motion toggle. GIF file selection and drag/drop are **local preview only**; sign-in is needed to upload/submit. Assets stay under 2 MB / 2048×2048, and their original bytes preserve GIF animation. Draft/JSON recipes do not embed image files; reattach images after reloading/importing. Paused or reduced-motion previews replace image fills with a static gradient, rather than pretending to freeze a GIF frame.\n\n## 7TV paint JSON conversion\n\nThe studio accepts Nameflare JSON and recognized 7TV paint data: legacy `function`/`stops`, extension `gradients`, or current database `layers[].ty` definitions (`single_color`, `linear_gradient`, `radial_gradient`, `image`). It decodes signed/unsigned packed `0xRRGGBBAA` colors, opacity, stops, repeat, angle, size/position, shadow offsets/blur, and bounded text weight/stroke. Conic gradients are supported from the extension format. Image layers become empty local-upload slots: no remote image fetches, entitlements, or auto-approval.\n\nConversion rejects unknown layer types or out-of-bounds values and reports unsupported flairs, name transformation, alpha outline, and unsupported canvas-repeat modes. It is **not full 7TV feature parity**: vectors, badges, decorative text flairs, arbitrary shaders/CSS, provider entitlements, or future API fields are not implemented. Users must have artwork permission and all imports require Nameflare moderation.\n\nResearch references: [7TV extension paint types](https://github.com/SevenTV/Extension/blob/master/src/types/app.d.ts), [7TV current paint database types](https://github.com/SevenTV/SevenTV/blob/main/shared/src/database/types/paint.rs). Nameflare branding is owned by **B_X_N_E**, inspired by the owner’s [Banechat studio](https://chat.bxne.dev), not affiliated with 7TV.\n\n## OBS / browser overlay\n\n- `/overlay?channel=yourchannel` — transparent Twitch IRC read overlay (anonymous public-channel reader; respects CLEARMSG/CLEARCHAT and reconnects). Requires browser WebSocket internet access. Live operation is not provider-verified by unit tests.\n- `/overlay?demo=1` — explicitly labeled sample messages, not live chat.\n- `/overlay?room=PRIVATE_ROOM_KEY` — authenticated-by-capability SSE relay. Never publicly share this URL.\n- Options: `&effects=0` disables cosmetics, `&lifetime=45` sets message lifetime (5–300 seconds).\n\nCreate separate random `OVERLAY_INGEST_TOKEN` and `OVERLAY_ROOM_KEY` in the environment. Authorized server-side Kick/YouTube connectors may submit:\n\n```http\nPOST /api/overlay/messages\nAuthorization: Bearer YOUR_SERVER_ONLY_INGEST_TOKEN\nContent-Type: application/json\n\n{\"provider\":\"youtube\",\"provider_id\":\"UC-channel-id\",\"name\":\"viewer\",\"text\":\"hello\"}\n```\n\nThe room receives JSON SSE messages from `GET /api/overlay/events?room=...`. Relay messages must come from a trusted connector that verifies the platform user ID, performs chat moderation/deletion handling, and follows provider terms. The current relay has one global room and no deletion-event contract; it is not a multi-tenant overlay service. Its URL contains a read secret, so redact query strings from proxy logs. SSE is capped at 100 connections. Ingest secrets never belong in OBS, a browser extension, source control, or public documentation.\n\n## Chatterino / third-party clients\n\n**Stock Chatterino support is not shipped.** A native integration must be implemented and tested in a compatible fork; installing this web app does not modify Chatterino.\n\nAdapter contract:\n1. Resolve real Twitch user IDs from chat messages and batch `/resolve` queries.\n2. Cache for at most 15 seconds and invalidate on expiration; support removed designs and null results.\n3. Implement gradient animation, bounded outline/shadow/extrusion, and GIF/WebP image decoding in the client's rendering pipeline. A CSS recipe is not directly executable in Qt. Apply frame-count/decode/memory limits and reduced-motion controls.\n4. Maintain text selection, Unicode shaping, clipping, badges, readable fallback colors, and high-DPI scaling. Cosmetics must never replace authoritative identity or moderation status.\n5. Let users choose the official origin or an explicitly trusted self-hosted fork; never silently fetch arbitrary asset origins embedded by another user.\n\nFuture web extensions and native adapters can use this contract, but no Twitch/Kick/YouTube extension or Chatterino binary is included here.\n","docs/DEPLOYMENT.md":"# Hosting the official Nameflare service\n\nOpen-source code and centralized service ownership are compatible. Publish the repository with its license; run the official service at an origin you control. Domain availability/ownership is not verified here. The workspace is not deployed, and hosting bills, DNS changes, OAuth registration, or publication require the owner's explicit action.\n\n## Cloudflare-native alternative\n\nThe requested Workers/D1/private-R2 runtime is implemented separately from Node. Use [the Cloudflare runbook](CLOUDFLARE.md) for local migration, provider registration, resource bindings, secret storage, deployment prerequisites, and limits. It has been bundled and locally tested, not remotely deployed. The existing Node streaming relay is not ported.\n\n## Oracle Ubuntu setup\n\nFor the verified low-memory Oracle staging layout and administrator/HTTPS prerequisites, see [the Oracle runbook](ORACLE.md). Native Node avoids Docker overhead on the 1 GB host; public launch still requires production configuration and acceptance checks.\n\n## Node topology\n\nOne Node 24 container + persistent local volume + HTTPS reverse proxy on one host. The app serves its UI, versioned API, and textures from the same origin. SQLite runs in WAL mode with foreign keys and a busy timeout. Do not run multiple app replicas against a shared network SQLite file. Use PostgreSQL, object storage, a distributed limiter, and pub/sub before horizontal scaling; SSE rooms are in-process today.\n\n1. Publish corresponding source in a public repository, retaining the AGPL license.\n2. Create a domain and HTTPS host (for example, a VPS or a container host with a persistent disk). Choose your canonical app/API origin; this MVP uses one origin, such as `https://nameflare.your-domain.example`, with `/api/v1` underneath.\n3. Privately configure `.env` or host secrets using `.env.example`: HTTPS `PUBLIC_BASE_URL`, `OWNER_TWITCH_ID`, provider credentials, and `SOURCE_URL` pointing to the actual published repository. Never put client secrets or live data in Git.\n4. Register exact provider callbacks and complete required Google verification. Test each login/linking flow against live providers. Account linking was tested only with mocked provider responses in this workspace.\n5. Build and start the production image using the supplied Dockerfile or Compose. The Compose volume stores database and texture files. It exposes only `127.0.0.1:3000` to the host; put HTTPS ingress in front of that. If the proxy is in another container, use a private shared network rather than assuming its localhost is the host's localhost.\n6. Sign in as the configured owner Twitch identity. Grant reviewers by numeric Twitch ID after they sign in.\n7. Configure monitoring, per-client edge rate limiting, upload scanning, storage quotas, privacy procedures, and regular tested backups before opening public signup.\n\nProduction startup fails without an HTTPS origin and numeric owner ID. Demo mode is rejected in production and binds loopback in development. Production does not seed users or sample cosmetics. An unconfigured provider is visibly disabled, not faked.\n\n## Reverse proxy checklist\n\n- TLS termination, upstream `http://127.0.0.1:3000`, no public direct access to port 3000.\n- Do not rewrite the Origin header. App mutations require exact `PUBLIC_BASE_URL` origin plus session-bound CSRF.\n- Cap request bodies at 3 MB; rate-limit uploads, login starts, and public resolves by true client IP at the trusted edge.\n- The in-process limiter intentionally ignores arbitrary forwarded headers; behind one proxy, all traffic may share its socket address. It is suitable for an MVP but must be replaced or made configurable for public scale after establishing trusted-proxy boundaries.\n- Disable response buffering for `/api/overlay/events`; allow streaming heartbeats. Apply sensible connection limits.\n- Avoid logging authorization headers, OAuth codes, cookies, or overlay room query strings.\n- Public recipes and textures have a 15-second freshness window. Do not add longer CDN caching that delays revocation. Do not cache session endpoints, pending textures, or error responses.\n- Serve `/source` and `/license` from this app so users can access license and source information; set `SOURCE_URL` for a live repository link.\n\n## Backups & operations\n\nPersist the entire data directory. Use an SQLite online backup/snapshot procedure with known consistency guarantees and separately back up textures. Do not blindly copy a live WAL database without its transactional state. Test restoring to a private staging environment. Disk usage can grow over time: per-account upload count and pending submission caps are not a retention policy. Add orphan-asset cleanup, global storage limits, and alerting before broad launch.\n\nChanging `OWNER_TWITCH_ID` does not revoke a previously persisted owner role; ownership transfer needs an explicit audited operational procedure. OAuth identity unlink/merge, account deletion, reports/appeals, and privacy exports are not implemented. Do not promise these features until shipped.\n\n## Remaining launch work\n\n- Fully decode, sanitize/re-encode raster uploads with frame/time/memory limits, content/malware scanning, and copyright/abuse reporting. Header validation and manual review are only an MVP baseline.\n- Load-test API, disk reads, SSE, and live Twitch connection behavior. Node's SQLite API may still emit an experimental warning on supported versions.\n- Add account deletion/export, retention and privacy notices, moderation appeals, and legal policy for uploads.\n- If needed, implement per-channel/multi-room relay credentials and moderation deletion events, authorized Kick/YouTube live chat connectors, web extensions, and a native Chatterino adapter.\n- Automate backups, dependency/platform security review, disaster recovery, and monitoring.\n\nNo commands in this guide have provisioned a server or made the API internet-accessible.\n","docs/CLOUDFLARE.md":"# Cloudflare-native Nameflare\n\nThe second runtime is Workers + D1 + private R2 + Workers static assets. No Node server, filesystem, or `node:sqlite` is bundled into the Worker. The original Node backend remains usable locally. Neither this code nor a dry-run deploy has provisioned resources, changed DNS, or enabled a live OAuth app.\n\n## Local development\n\nWrangler is a local development dependency. Install using `npm ci`, then:\n\n```bash\nnpm run cf:migrate:local\nnpm run cf:dev\n```\n\nOpen `http://127.0.0.1:8787`. The `local` environment uses local D1/R2 resources and placeholder owner ID `1001`, not demo seed data. It has no real provider credentials. The existing sample-account demo remains `npm run demo`.\n\nIf testing OAuth locally, use `.dev.vars.local` (ignored by Git) for provider client IDs/secrets and register the exact callback origin accepted by the provider. Do not reuse production resources or secrets for local testing. The `LOCAL_DEV` bypass is allowed only on a loopback canonical URL.\n\n`npm run cf:check` bundles the Worker and assets without uploading. `npm test` checks application behavior against a SQLite-backed D1 test adapter; that is not a substitute for live D1 concurrency tests or provider verification.\n\n## Provisioning checklist (owner action required)\n\nConfirm the account, billing/quotas, hostname ownership, and published AGPL source repository first. The following commands **create remote resources** and are instructions, not commands already run here:\n\n```bash\nnpx wrangler login\nnpx wrangler d1 create nameflare\nnpx wrangler r2 bucket create nameflare-textures\n```\n\nThe chosen planned origin is `https://nameflare.bxne.dev`; ownership/DNS and account access have not been verified. No custom-domain route is enabled yet.\n\nThis version includes `0002_profile_avatars.sql` for account/profile avatars and `0003_flare_publication.sql` for public/unlisted Flares (existing designs remain publicly listed). Apply pending migrations before running the updated Worker; do not recreate or replace existing production tables. For detailed provider registration and public account behavior, see [the provider setup guide](ORACLE.md#public-accounts-and-provider-setup). Store Cloudflare credentials with Wrangler secrets rather than the Node `.env` instructions.\n\nReplace the zero database ID in `wrangler.jsonc` with the returned D1 ID. Set the actual bucket name. Set top-level vars:\n\n- `PUBLIC_BASE_URL`: one HTTPS origin without a trailing slash/path (e.g. your actual owned hostname).\n- `OWNER_TWITCH_ID`: B_X_N_E's immutable numeric Twitch ID. Never a login name.\n- `SOURCE_URL`: a public HTTPS repository containing the complete corresponding version, license, frontend and backend source.\n\nAdd an explicit custom-domain route to top-level configuration:\n\n```json\n\"routes\": [{ \"pattern\": \"YOUR_OWNED_HOSTNAME\", \"custom_domain\": true }]\n```\n\nKeep `workers_dev` and preview URLs disabled for a single canonical origin. The handler rejects alternate Host origins. Do not enable the `local` environment on a public route.\n\nApply the migration only after reviewing the target database:\n\n```bash\nnpx wrangler d1 migrations apply nameflare --env='' --remote\n```\n\nSet provider credentials with secret prompts, never literal secrets in shell history or `wrangler.jsonc`:\n\n```bash\nnpx wrangler secret put TWITCH_CLIENT_ID --env=''\nnpx wrangler secret put TWITCH_CLIENT_SECRET --env=''\nnpx wrangler secret put KICK_CLIENT_ID --env=''\nnpx wrangler secret put KICK_CLIENT_SECRET --env=''\nnpx wrangler secret put YOUTUBE_CLIENT_ID --env=''\nnpx wrangler secret put YOUTUBE_CLIENT_SECRET --env=''\n```\n\nRegister only the providers you intend to offer. Unconfigured providers remain visibly disabled. Deploy only with explicit approval of this account, domain and resources:\n\n```bash\nnpx wrangler deploy --env=''\n```\n\n## OAuth registration\n\nCallbacks must match `PUBLIC_BASE_URL` exactly:\n\n| Provider | Callback path | Provider configuration |\n| --- | --- | --- |\n| Twitch | `/auth/twitch/callback` | Confidential web application, client ID/secret, authorization-code grant; no posting scope |\n| Kick | `/auth/kick/callback` | OAuth application, `user:read`, authorization code with S256 PKCE |\n| Google/YouTube | `/auth/youtube/callback` | Web OAuth client, YouTube Data API v3 enabled, consent screen and test users, `youtube.readonly`, S256 PKCE |\n\nTwitch developer console: https://dev.twitch.tv/console/apps\nKick developer documentation: https://docs.kick.com/\nGoogle credentials: https://console.cloud.google.com/apis/credentials\n\nGoogle's read-only scope may require verification before broad public use. A Google account must have a YouTube channel. Provider tokens are used only to retrieve the immutable identity and are never persisted or exposed to the browser. Linking is cookie/session-bound; expired or replayed state is rejected. User cancellation must be tested along with identity collisions.\n\nThe configured owner must sign in with Twitch. First signup and matching display names confer no privilege. Changing the configured owner ID does not revoke persisted owner roles; transfer needs an explicit operational procedure.\n\n## Security and parity\n\n- D1 stores hashed sessions and OAuth states. State consumption is one atomic `DELETE ... RETURNING`; identity linking uses a transactional D1 batch plus uniqueness and ownership constraints.\n- D1 triggers enforce pending/texture quotas, no self-review, audit review decisions, and atomically clear equipped paints on revocation.\n- R2 must remain **private**: no `r2.dev` public URL, bucket custom domain, or long-lived object URL. All reads go through the Worker, which checks current approval/ownership.\n- Public responses and approved textures use a 15-second cache window. Do not add longer CDN cache rules. Errors and private endpoints use `no-store`.\n- Writes require a same-origin cookie session and CSRF token. Public batch resolve is the documented exception and accepts only bounded public reads.\n- Rate-limit bindings use Cloudflare's trusted `CF-Connecting-IP`. Workers rate limiting is edge-local, not a strict globally consistent quota; account quotas live in D1.\n- Scheduled hourly cleanup deletes expired sessions/state. Configure backups, D1 restore drills, R2 retention/quota monitoring, and privacy policies.\n- Direct anonymous Twitch overlay is supported. The Node SSE relay is **not** ported; relay endpoints return 501. Kick/YouTube account linking does not implement their live chat connectors. Multi-room relay requires Durable Objects or another deliberate streaming architecture.\n- Image validation is still header/dimension validation, not full raster decoding, frame/time/memory limits or scanning. Harden uploads and abuse handling before opening unrestricted public signup.\n- There is no automatic migration of existing SQLite data or image files to D1/R2. Use a separately reviewed import/backup plan; do not import demo accounts to production.\n\n## Staging acceptance\n\nVerify on the actual HTTPS hostname: each provider login/link, cancellation and replay rejection, account collision, new session rotation, owner bootstrap, moderator grant/revocation, private GIF upload, duplicate upload, review/no-self-review, equip/resolve, anonymous private-asset rejection, approval and revocation, CORS/error caching, R2 privacy, and OBS Twitch behavior. Live provider checks and deployment require credentials/access not supplied in this thread.\n\nReference documentation:\n- https://developers.cloudflare.com/workers/wrangler/configuration/\n- https://developers.cloudflare.com/workers/static-assets/\n- https://developers.cloudflare.com/d1/worker-api/d1-database/\n- https://developers.cloudflare.com/r2/api/workers/workers-api-reference/\n","docs/ORACLE.md":"# Nameflare on Oracle Ubuntu\n\n## Verified staging target\n\nThe inspected target is `150.136.143.145`, Ubuntu 22.04.5 LTS, x86-64, approximately 1 GB RAM, and 44 GB free disk. SSH is available as `ubuntu`. An enabled Internet Gateway and subnet default route restored SSH access. The earlier console helper's ARM output was not the instance architecture; `uname -m` on the instance reports `x86_64`.\n\nThe setup uses native Node rather than Docker to minimize memory and storage overhead. Node 24 is downloaded from nodejs.org and checked against that release's published SHA-256 digest. No third-party production dependencies are required.\n\nStaging paths:\n\n- `/home/ubuntu/nameflare-runtime`: Node 24 runtime.\n- `/home/ubuntu/nameflare`: allowlisted application source only.\n- `/home/ubuntu/nameflare-private/.env`: private configuration, mode 600; do not upload it with source.\n- `/home/ubuntu/nameflare-data`: persistent SQLite database and textures; mode 700.\n\nSSH keys, `.env`, OAuth credentials, `node_modules`, `.wrangler`, and demo data must not be copied with application source. The deployment files below are templates, not proof of an active public service.\n\n## Production gates\n\n1. Configure a DNS A record for `nameflare.bxne.dev` pointing at `150.136.143.145`. Verify ownership and check existing records before editing. Start DNS-only; if enabling the Cloudflare proxy later, use Full (strict), avoid caching private/API endpoints, and deliberately configure trusted real-IP handling. Never blindly trust client-provided forwarding headers.\n2. Permit inbound TCP 80 and 443 in the Oracle subnet security lists / VNIC security groups and the Ubuntu host firewall. Keep SSH limited to trusted source IPs; keep port 3000 private. Inspect existing firewall rules before making changes; do not flush them or remove Oracle rules.\n3. Set a real numeric `OWNER_TWITCH_ID`; no dummy/demo identity. Configure at least Twitch OAuth for sign-in. Register `https://nameflare.bxne.dev/auth/twitch/callback` exactly.\n4. Configure HTTPS and a persistent system service only after the application source has passed tests on this host.\n5. A public repository is recommended. The Node runtime also provides an allowlisted source-file bundle at `/source` and the license at `/license` when `SOURCE_URL` is unset. Verify both before launch; never point the source URL at an unrelated repository. Operators remain responsible for AGPL corresponding-source obligations.\n\n## Administrator setup (not automatically executed)\n\nRun administrator commands yourself in an SSH terminal, or approve an appropriate elevation workflow. These commands install packages and affect the host; review first. They do not enable the application yet.\n\n```bash\nsudo apt-get update\nsudo apt-get install -y nginx certbot\nsudo install -d -m 0755 /var/www/nameflare-acme\n```\n\nBefore certificate issuance, enable [the HTTP-only bootstrap site](oracle/nameflare-http.nginx.conf). It serves ACME challenges and returns an uncached maintenance response elsewhere; it never exposes the unconfigured app. The following commands deliberately refuse to overwrite an existing Nameflare site. Preserve the Ubuntu default and any other sites.\n\n```bash\ntest ! -e /etc/nginx/sites-available/nameflare && \\\n  test ! -L /etc/nginx/sites-available/nameflare && \\\n  test ! -e /etc/nginx/sites-enabled/nameflare && \\\n  test ! -L /etc/nginx/sites-enabled/nameflare && \\\n  sudo install -m 0644 /home/ubuntu/nameflare/docs/oracle/nameflare-http.nginx.conf \\\n    /etc/nginx/sites-available/nameflare && \\\n  sudo ln -s /etc/nginx/sites-available/nameflare /etc/nginx/sites-enabled/nameflare && \\\n  sudo nginx -t && sudo systemctl reload nginx\n```\n\nDo not enable the TLS block before the certificate exists: `nginx -t` would fail. Verify external port-80 access and DNS before requesting a certificate. Use the administrator's real email when issuing a certificate:\n\n```bash\nsudo certbot certonly --webroot -w /var/www/nameflare-acme \\\n  -d nameflare.bxne.dev --agree-tos --non-interactive \\\n  --email YOUR_REAL_EMAIL\n```\n\nOnce certificates exist, install [the complete Nginx template](oracle/nameflare.nginx.conf) as a separate site, validate with `sudo nginx -t`, then reload Nginx. Preserve any existing sites. The template caps bodies at 3 MB, limits requests by client IP, disables API proxy caching, and supports streaming relay responses. Cloudflare-proxied traffic requires additional reviewed trusted-IP configuration; the template assumes direct clients. The Node limiter behind a proxy still shares a socket-address bucket, which limits total traffic; review that limitation before scaling.\n\nPrivately edit the `.env` file with the numeric owner ID and OAuth credentials, then install the service:\n\n```bash\nsudo install -m 0644 /home/ubuntu/nameflare/docs/oracle/nameflare.service \\\n  /etc/systemd/system/nameflare.service\nsudo systemctl daemon-reload\nsudo systemctl enable --now nameflare.service\nsudo systemctl status nameflare.service --no-pager\n```\n\nThe service enforces production mode, binds `127.0.0.1:3000`, and uses a 256 MB memory ceiling. It runs as the non-root Ubuntu user, has a private temporary directory, and limits writable paths to persistent application data. Startup intentionally fails without HTTPS configuration and a numeric owner ID. Do not bypass this validation or start public demo mode. A deployment operator controlling this Unix user also controls the application; use a dedicated service account as a future hardening step.\n\n## Public accounts and provider setup\n\nAnyone can use **Sign in / Create account**. The first successful provider sign-in creates a regular Nameflare account; later sign-ins reuse that verified identity. There is no invite list or separate password. Only the configured owner Twitch ID receives owner access. Link other providers from **My account while signed in** to avoid separate accounts; automatic merging and unlinking are not implemented.\n\nThe provider profile supplies the display name and avatar. Existing accounts get these fields on their next provider sign-in. Users can choose a linked profile source; changing it does not rename their platform accounts. Unsupported image hosts or missing images use initials. Nameflare stores verified profile metadata, not provider tokens. Loading provider avatars contacts their image CDN with no referrer; include this in your privacy notice.\n\n### Kick\n\n1. Sign in at [Kick Developers](https://dev.kick.com/), open account settings → **Developer**, and create an application. Follow any developer-access requirements shown by Kick.\n2. Set the exact redirect URI to `https://nameflare.bxne.dev/auth/kick/callback`.\n3. If scope configuration is offered, allow `user:read`. Nameflare uses PKCE and does not need chat-write permissions for sign-in.\n4. Copy the application **Client ID** and **Client Secret** into the private configuration as `KICK_CLIENT_ID` and `KICK_CLIENT_SECRET`. Never copy credentials into public source, chat, screenshots, or shell command arguments.\n5. Test both a new account and linking Kick to an existing account, including cancellation. Kick sign-in alone does not implement a live-chat connector.\n\n### YouTube / Google\n\n1. In [Google Cloud Console](https://console.cloud.google.com/), create or select a project. Under APIs & Services → Library, enable **YouTube Data API v3**.\n2. Open **Google Auth Platform → Branding** (or OAuth consent screen). Enter Nameflare's app name, real support/developer email, and the requested site/privacy information. Add `bxne.dev` as an authorized domain when required; complete domain ownership verification if requested.\n3. Under **Audience**, select **External** for public users. While the app is in Testing, add the Google accounts of your testers under **Test users**. Testing is not unrestricted public access.\n4. Under **Data Access**, add `https://www.googleapis.com/auth/youtube.readonly`. This reads the authenticated YouTube channel identity; no upload or posting scope is requested.\n5. Under **Clients**, create an OAuth client of type **Web application**. Add `https://nameflare.bxne.dev/auth/youtube/callback` under **Authorized redirect URIs**. A JavaScript origin, API key, or service account is not a substitute for this redirect or OAuth client. Nameflare's server-side flow does not require an authorized JavaScript origin.\n6. Copy the **Client ID** and **Client Secret** securely at creation into `YOUTUBE_CLIENT_ID` and `YOUTUBE_CLIENT_SECRET`. Google may only display the secret at creation; store it privately.\n7. Test with an allowed Google account that owns a YouTube channel. The stored identity is the channel ID, not the email/Google account ID. If no channel is returned, create/select a channel and restart the flow.\n8. Before broad public YouTube sign-up, publish the consent application and complete any Google-required verification for the sensitive scope. Prepare actual privacy/terms pages and domain verification; do not use placeholders or promise verification is automatic. Review quota and user-data policy requirements.\n\n### Apply credentials privately\n\nIn the server SSH terminal:\n\n```bash\nTERM=xterm-256color nano /home/ubuntu/nameflare-private/.env\n```\n\nUpdate the existing entries (no duplicates):\n\n```dotenv\nKICK_CLIENT_ID=YOUR_KICK_CLIENT_ID\nKICK_CLIENT_SECRET=YOUR_KICK_CLIENT_SECRET\nYOUTUBE_CLIENT_ID=YOUR_GOOGLE_WEB_CLIENT_ID\nYOUTUBE_CLIENT_SECRET=YOUR_GOOGLE_WEB_CLIENT_SECRET\n```\n\nReplace placeholders with actual credentials. Keep the canonical `PUBLIC_BASE_URL=https://nameflare.bxne.dev`. Save, then deliberately restart the service:\n\n```bash\nsudo systemctl restart nameflare\nsystemctl is-active nameflare\ncurl --fail https://nameflare.bxne.dev/api/config\n```\n\n`providers.kick` and `providers.youtube` become `true` when credentials are present, but this is not proof they are valid. Complete actual sign-in/linking tests. If callbacks change, update provider registration to match the exact origin/path.\n\n### Admin and reviewer workflow\n\n- Sign in with the configured owner Twitch account, then open **Moderation**. Owner-only **Accounts & moderation team** lists registered users, linked IDs, join dates and current roles. Search by name/login or exact platform ID, then **Make moderator** or **Remove moderator**. Adding by numeric Twitch ID also remains available; the user must first sign in/link Twitch.\n- Moderators see the paint dashboard and audit history, not the private account-management table. Access is checked on every request; removal affects existing sessions immediately. Owner access cannot be removed from the table.\n- Use **Pending**, **Approved**, or **Disapproved** plus creator/paint search. Each paint shows animated/chat-size preview, pause/light backdrop, description, full recipe, creator identities, creation/review timestamps, reviewer and feedback. Lists are paginated in batches of 50.\n- Approval publishes the paint. Disapproval requires feedback. Revocation removes approved paints from the public API and clears equipped assignments; public clients revalidate within 15 seconds. Nobody reviews their own design; the owner needs another moderator for their own submissions.\n- Creators see status, reviewer/time, and feedback under **My collection** and may edit a copy to submit again. Approval is never inherited.\n\n### Updating this version\n\nThese workspace edits do not automatically update the server. Review the source changes and back up production data with SQLite online backup or a controlled service stop before upload/restart. Copy only changed application/source files, preserving private environment, database, textures, and other local edits. Node adds avatar/profile and Flare publication columns on startup without replacing data. Cloudflare requires applying **all pending** migrations, including `0002_profile_avatars.sql` and `0003_flare_publication.sql`, before deploying this version. Never apply migrations to remote resources without explicit approval.\n\nOfficial references: [Kick Developers](https://dev.kick.com/), [Kick API docs](https://docs.kick.com/), [Google consent configuration](https://developers.google.com/workspace/guides/configure-oauth-consent), [YouTube web-server OAuth](https://developers.google.com/youtube/v3/guides/auth/server-side-web-apps).\n\n## Acceptance and operations\n\n- Check localhost `/healthz`, public HTTPS `/healthz`, `/api/config`, `/source`, and `/license`.\n- Verify the service survives SSH disconnect and a planned reboot.\n- Complete live-provider login, cancellation, account linking, owner bootstrap, moderation, private-image access, approval/revocation, and equip/resolve checks.\n- Keep database/textures outside the code directory. Use SQLite online backup or a controlled service stop to capture a consistent database plus textures; do not blindly copy a running WAL file.\n- Copy encrypted backups off-server and test restoration before public signup. No automated/off-server backup is configured by these templates.\n- Monitor memory, disk, CPU, API abuse, and service errors. The host's apparent free capacity is not a load-test result.\n- Do not perform an unconditional full system upgrade or force reboot as part of deployment. Oracle free-tier billing/quotas and reclamation rules must be reviewed separately.\n","docs/oracle/nameflare.service":"[Unit]\nDescription=Nameflare Node API and creator studio\nAfter=network.target\n\n[Service]\nType=simple\nUser=ubuntu\nGroup=ubuntu\nWorkingDirectory=/home/ubuntu/nameflare\nEnvironmentFile=/home/ubuntu/nameflare-private/.env\nEnvironment=NODE_ENV=production HOST=127.0.0.1 PORT=3000 DATA_DIR=/home/ubuntu/nameflare-data\nExecStart=/home/ubuntu/nameflare-runtime/bin/node --max-old-space-size=128 /home/ubuntu/nameflare/server.js\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=15\nUMask=0077\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=read-only\nReadWritePaths=/home/ubuntu/nameflare-data\nMemoryMax=256M\nTasksMax=64\nLimitNOFILE=4096\n\n[Install]\nWantedBy=multi-user.target\n","docs/oracle/nameflare.nginx.conf":"# Include from nginx's http context (Ubuntu sites-available/sites-enabled).\n# Do not enable until the certificate files below exist.\nlimit_req_zone $binary_remote_addr zone=nameflare_read:10m rate=5r/s;\nlimit_conn_zone $binary_remote_addr zone=nameflare_conn:10m;\nmap $request_method $nameflare_write_key {\n    default $binary_remote_addr;\n    GET \"\";\n    HEAD \"\";\n    OPTIONS \"\";\n}\nlimit_req_zone $nameflare_write_key zone=nameflare_mutations:10m rate=40r/m;\n\nserver {\n    listen 80;\n    server_name nameflare.bxne.dev;\n    access_log off;\n    location /.well-known/acme-challenge/ {\n        root /var/www/nameflare-acme;\n    }\n    location / {\n        return 301 https://nameflare.bxne.dev$request_uri;\n    }\n}\n\nserver {\n    listen 443 ssl;\n    server_name nameflare.bxne.dev;\n    ssl_certificate /etc/letsencrypt/live/nameflare.bxne.dev/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/nameflare.bxne.dev/privkey.pem;\n    ssl_protocols TLSv1.2 TLSv1.3;\n    client_max_body_size 3m;\n    client_body_timeout 15s;\n    client_header_timeout 15s;\n    limit_req_status 429;\n    limit_conn_status 429;\n    limit_conn nameflare_conn 20;\n    # No URI access logs: OAuth callback codes and relay capabilities are sensitive.\n    access_log off;\n    error_log /var/log/nginx/nameflare-error.log crit;\n\n    location /api/overlay/events {\n        limit_req zone=nameflare_read burst=30 nodelay;\n        proxy_pass http://127.0.0.1:3000;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header Connection \"\";\n        proxy_buffering off;\n        proxy_cache off;\n        proxy_read_timeout 75s;\n    }\n    location / {\n        limit_req zone=nameflare_read burst=30 nodelay;\n        limit_req zone=nameflare_mutations burst=10 nodelay;\n        proxy_pass http://127.0.0.1:3000;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header Connection \"\";\n        proxy_cache off;\n        proxy_read_timeout 30s;\n    }\n}\n","docs/oracle/nameflare-http.nginx.conf":"# Initial HTTP-only site; replace with nameflare.nginx.conf after TLS issuance.\nserver {\n    listen 80;\n    server_name nameflare.bxne.dev;\n    access_log off;\n    error_log /var/log/nginx/nameflare-error.log crit;\n    client_max_body_size 3m;\n    add_header Cache-Control \"no-store\" always;\n    add_header X-Content-Type-Options \"nosniff\" always;\n\n    location /.well-known/acme-challenge/ {\n        root /var/www/nameflare-acme;\n        default_type text/plain;\n        try_files $uri =404;\n    }\n    location / {\n        default_type text/plain;\n        return 503 \"Nameflare is being configured. Please try again later.\\n\";\n    }\n}\n","cloudflare/worker.js":"import { timingSafeEqual } from 'node:crypto';\nimport { HttpError, assert, text, validateRecipe, validateTexture, publishingFlag } from '../lib/cosmetics.js';\nimport { providerConfig, authorizationURL, finishOAuth, avatarSources } from '../lib/oauth.js';\nimport { D1Store, token } from './store.js';\nconst providers=['twitch','kick','youtube'];\nconst equal=(a,b)=>typeof a==='string' && typeof b==='string' && Buffer.byteLength(a)===Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a),Buffer.from(b));\nconst cookie=(name,value,age,secure)=>`${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${secure?'; Secure':''}`;\nconst publicHeaders={'Access-Control-Allow-Origin':'*','Cache-Control':'public, max-age=15, must-revalidate'};\nasync function body(request) {\n  assert((request.headers.get('Content-Type') || '').split(';')[0]==='application/json',415,'Send application/json.');\n  const reader=request.body?.getReader();assert(reader,400,'Expected a JSON object.');\n  let size=0;const chunks=[];\n  while(true){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>2900000){await reader.cancel();throw new HttpError(413,'Request body is too large.');}chunks.push(value);}\n  const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}\n  try{const result=JSON.parse(new TextDecoder().decode(bytes));assert(result && typeof result==='object' && !Array.isArray(result),400,'Expected a JSON object.');return result;}\n  catch(error){if(error instanceof HttpError)throw error;throw new HttpError(400,'Invalid JSON.');}\n}\nfunction json(value,status=200,extra={}) { return new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json; charset=utf-8',...extra}}); }\nfunction redirect(to,cookies=[]) {\n  const headers=new Headers({Location:to});for(const value of cookies)headers.append('Set-Cookie',value);\n  return new Response(null,{status:302,headers});\n}\nexport async function handleRequest(request,env) {\n  const url=new URL(request.url),path=url.pathname,method=request.method;\n  const base=(env.PUBLIC_BASE_URL || '').replace(/\\/$/,'');let canonical;\n  try{canonical=new URL(base);}catch{throw new HttpError(503,'Operator must configure PUBLIC_BASE_URL.');}\n  const local=env.LOCAL_DEV==='true' && ['127.0.0.1','localhost'].includes(canonical.hostname);\n  assert(canonical.origin===base && (canonical.protocol==='https:' || local),503,'Configure one canonical HTTPS origin.');\n  assert(url.origin===canonical.origin,421,'Use the canonical service hostname.');\n  assert(/^\\d{1,30}$/.test(env.OWNER_TWITCH_ID || ''),503,'Operator must configure a numeric owner Twitch ID.');\n  const secure=!local,store=new D1Store(env.DB),cookies=Object.fromEntries((request.headers.get('Cookie') || '').split(';').map(c=>c.trim().split('=')).filter(c=>c.length===2));\n  const getSession=()=>store.session(cookies.nameflare_session);\n  async function user() { const session=await getSession();assert(session,401,'Sign in to continue.');return session; }\n  async function mod(owner=false) { const session=await user();assert(owner?session.role==='owner':['owner','moderator'].includes(session.role),403,owner?'Owner access required.':'Moderator access required.');return session; }\n  async function mutation() {\n    assert(request.headers.get('Origin')===base,403,'Requests must come from the official app origin.');\n    const session=await user();assert(equal(request.headers.get('X-CSRF-Token'),session.csrf),403,'Invalid security token. Refresh the page.');return session;\n  }\n  if(method==='OPTIONS' && path.startsWith('/api/v1/'))return new Response(null,{status:204,headers:{'Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'GET, POST, OPTIONS','Access-Control-Allow-Headers':'Content-Type','Access-Control-Max-Age':'86400'}});\n  if(path.startsWith('/api/') || path.startsWith('/auth/') || path.startsWith('/textures/')) {\n    const limiter=method==='GET'?env.READ_LIMITER:env.WRITE_LIMITER;\n    assert(limiter,503,'Operator must configure API rate-limit bindings.');\n    const result=await limiter.limit({key:request.headers.get('CF-Connecting-IP') || 'local'});\n    assert(result.success,429,'Too many requests. Try again in a minute.');\n  }\n  if(method==='GET' && path==='/healthz'){await store.first('SELECT 1');return json({status:'ok',runtime:'cloudflare'});}\n  if(method==='GET' && path==='/license')return redirect('https://www.gnu.org/licenses/agpl-3.0.txt');\n  if(method==='GET' && path==='/source') {\n    let source;try{source=new URL(env.SOURCE_URL);}catch{}\n    assert(source?.protocol==='https:',503,'The operator must publish corresponding source before launch.');return redirect(source.toString());\n  }\n  if(method==='GET' && path==='/api/config')return json({name:'Nameflare',owner:'B_X_N_E',demo:false,runtime:'cloudflare',officialApi:`${base}/api/v1`,providers:Object.fromEntries(providers.map(p=>{const c=providerConfig(p,env);return [p,Boolean(c.clientId && c.clientSecret)];})),stats:await store.stats(),features:{relay:false}});\n  if(method==='GET' && path==='/api/me'){const session=await getSession();return json({user:session?await store.account(session.id):null,csrf:session?.csrf || null});}\n  const auth=/^\\/auth\\/(twitch|kick|youtube)(\\/callback)?$/.exec(path);\n  if(method==='GET' && auth) {\n    const provider=auth[1],session=await getSession(),config=providerConfig(provider,env);\n    assert(config.clientId && config.clientSecret,503,`${provider} linking needs OAuth credentials from the service operator.`);\n    if(!auth[2]) {\n      const binding=token(),verifier=token(),state=await store.state(provider,session?.id,binding,verifier);\n      return redirect(authorizationURL(provider,base,env,state,verifier),[cookie(`nameflare_oauth_${provider}`,binding,600,secure)]);\n    }\n    const state=await store.consumeState(url.searchParams.get('state'),provider,cookies[`nameflare_oauth_${provider}`]);\n    assert(state.user_id?session?.id===state.user_id:!session,403,'Account changed during login. Please start again.');\n    assert(!url.searchParams.has('error'),400,'Authorization was cancelled.');\n    const profile=await finishOAuth(provider,url.searchParams.get('code'),state.verifier,base,env);\n    const id=await store.linkIdentity(profile,state.user_id,env.OWNER_TWITCH_ID);\n    await store.revokeSession(cookies.nameflare_session);const created=await store.newSession(id);\n    return redirect('/#account',[cookie('nameflare_session',created.raw,604800,secure),cookie(`nameflare_oauth_${provider}`,'',0,secure)]);\n  }\n  if(method==='POST' && path==='/api/logout'){await mutation();await store.revokeSession(cookies.nameflare_session);const response=json({ok:true});response.headers.append('Set-Cookie',cookie('nameflare_session','',0,secure));return response;}\n  if(method==='POST' && path==='/api/account/profile') {\n    const session=await mutation(),data=await body(request);\n    assert(providers.includes(data.provider),400,'Choose a connected platform for your profile.');\n    return json({user:await store.updateProfile(session.id,data.provider)});\n  }\n  if(method==='GET' && path==='/api/admin/users') {\n    await mod(true);\n    const offset=Number(url.searchParams.get('offset') || 0);\n    assert(Number.isInteger(offset) && offset>=0 && offset<=1000000,400,'Invalid pagination.');\n    const users=await store.adminUsers({query:(url.searchParams.get('q') || '').slice(0,80),offset,limit:51});\n    return json({users:users.slice(0,50),next_offset:users.length>50?offset+50:null});\n  }\n  if(method==='GET' && path==='/api/my/cosmetics')return json({cosmetics:await store.mine((await user()).id)});\n  if(method==='POST' && path==='/api/cosmetics') {\n    const session=await mutation(),data=await body(request),recipe=validateRecipe(data.recipe);\n    const textures=new Set([recipe.textureId,...(recipe.layers || []).filter(l=>l.type==='image').map(l=>l.textureId)].filter(Boolean));\n    for(const id of textures)assert(await store.ownsTexture(id,session.id),400,'Upload every image layer from your own account first.');\n    return json({cosmetic:await store.submit(session.id,text(data.name,2,40,'Name'),text(data.description || '',0,400,'Description'),recipe,publishingFlag(data.listed))},201);\n  }\n  if(method==='POST' && path==='/api/cosmetics/publication') {\n    const session=await mutation(),data=await body(request);\n    assert(typeof data.listed==='boolean',400,'Public listing must be true or false.');\n    return json({cosmetic:await store.setPublication(session.id,text(data.id,1,100,'Flare ID'),data.listed)});\n  }\n  if(method==='POST' && path==='/api/textures') {\n    const session=await mutation(),uploaded=validateTexture((await body(request)).data);\n    const existing=await store.first('SELECT owner_id FROM textures WHERE id=?',uploaded.id);\n    assert(!existing || existing.owner_id===session.id,409,'This texture is already owned by another creator.');\n    const count=await store.first('SELECT COUNT(*) AS n FROM textures WHERE owner_id=?',session.id);\n    assert(existing || count.n<50,429,'Texture limit reached (50 per account).');\n    // Only API-authorized requests can read R2; the bucket must have no public URL.\n    await env.TEXTURES.put(uploaded.id,uploaded.bytes,{httpMetadata:{contentType:`image/${uploaded.id.split('.').pop()}`}});\n    await store.run('INSERT OR IGNORE INTO textures VALUES (?,?)',uploaded.id,session.id);\n    assert(await store.ownsTexture(uploaded.id,session.id),409,'This texture was claimed by another creator.');\n    return json({id:uploaded.id},201);\n  }\n  if(method==='POST' && path==='/api/equip') {\n    const session=await mutation(),data=await body(request);assert(data.cosmeticId===null || typeof data.cosmeticId==='string',400,'Invalid cosmetic ID.');\n    await store.equip(session.id,data.cosmeticId);return json({ok:true});\n  }\n  if(method==='GET' && path==='/api/admin/queue') {\n    await mod();\n    const status=url.searchParams.get('status') || 'pending',offset=Number(url.searchParams.get('offset') || 0);\n    assert(['pending','approved','rejected'].includes(status),400,'Invalid review status.');\n    assert(Number.isInteger(offset) && offset>=0 && offset<=1000000,400,'Invalid pagination.');\n    const cosmetics=await store.moderationCatalog({status,offset,limit:51,query:(url.searchParams.get('q') || '').slice(0,80)});\n    return json({cosmetics:cosmetics.slice(0,50),stats:await store.stats(),next_offset:cosmetics.length>50?offset+50:null});\n  }\n  if(method==='GET' && path==='/api/admin/audit'){await mod();return json({entries:await store.auditLog()});}\n  if(method==='GET' && path==='/api/admin/moderators'){await mod(true);return json({moderators:await store.moderators()});}\n  if(method==='POST' && path==='/api/admin/review') {\n    const session=await mutation();await mod();const data=await body(request);assert(['approved','rejected'].includes(data.status),400,'Invalid review decision.');\n    return json({cosmetic:await store.review(session,text(data.id,1,100,'Cosmetic ID'),data.status,text(data.reason || '',data.status==='rejected'?3:0,400,'Review note'))});\n  }\n  if(method==='POST' && path==='/api/admin/moderators') {\n    const session=await mutation();await mod(true);const data=await body(request);assert(typeof data.enabled==='boolean' && typeof data.twitchId==='string' && /^\\d{1,30}$/.test(data.twitchId),400,'Provide a numeric Twitch ID and enabled flag.');\n    await store.setModerator(session,data.twitchId,data.enabled);return json({moderators:await store.moderators()});\n  }\n  if(method==='GET' && path==='/api/v1/cosmetics') {\n    const offset=Number(url.searchParams.get('offset') || 0),limit=Number(url.searchParams.get('limit') || 60);\n    assert(Number.isInteger(offset) && offset>=0 && offset<=1000000 && Number.isInteger(limit) && limit>=1 && limit<=100,400,'Invalid pagination.');\n    const results=await store.catalog({offset,limit:limit+1,query:(url.searchParams.get('q') || '').slice(0,80),listedOnly:true,effect:url.searchParams.get('effect') || 'all',sort:url.searchParams.get('sort') || 'newest'});\n    return json({version:1,total:(await store.stats()).public,cosmetics:results.slice(0,limit).map(c=>store.publicCosmetic(c)),next_offset:results.length>limit?offset+limit:null},200,publicHeaders);\n  }\n  const cosmetic=/^\\/api\\/v1\\/cosmetics\\/([a-zA-Z0-9-]+)$/.exec(path);\n  if(method==='GET' && cosmetic){const c=store.publicCosmetic(await store.getCosmetic(cosmetic[1]));assert(c,404,'Approved cosmetic not found.');return json({cosmetic:c},200,publicHeaders);}\n  const identity=/^\\/api\\/v1\\/users\\/(twitch|kick|youtube)\\/([^/]+)$/.exec(path);\n  if(method==='GET' && identity){const result=await store.resolve(identity[1],decodeURIComponent(identity[2]));assert(result,404,'No linked identity found.');return json({user:result},200,publicHeaders);}\n  if(method==='POST' && path==='/api/v1/resolve') {\n    const data=await body(request);assert(providers.includes(data.provider) && Array.isArray(data.ids) && data.ids.length<=100 && data.ids.every(id=>typeof id==='string' && id.length>0 && id.length<=100),400,'Provide a platform and up to 100 string user IDs.');\n    return json({users:await store.resolveMany(data.provider,data.ids)},200,{'Access-Control-Allow-Origin':'*'});\n  }\n  const texture=/^\\/textures\\/([a-f0-9]{64}\\.(gif|png|webp))$/.exec(path);\n  if(method==='GET' && texture) {\n    const id=texture[1],published=await store.publicTexture(id);\n    if(!published){const session=await user();assert(['owner','moderator'].includes(session.role) || await store.ownsTexture(id,session.id),403,'This texture is awaiting approval.');}\n    const object=await env.TEXTURES.get(id);assert(object,404,'Texture not found.');\n    return new Response(object.body,{headers:{'Content-Type':`image/${id.split('.').pop()}`,...(published?publicHeaders:{})}});\n  }\n  if(path.startsWith('/api/overlay/'))throw new HttpError(501,'Relay streaming is not implemented on the Cloudflare runtime. Use direct Twitch or the Node relay.');\n  if(path.startsWith('/api/') || path.startsWith('/auth/') || path.startsWith('/textures/'))throw new HttpError(404,'Not found.');\n  if(method==='GET') {\n    if(path==='/guide')return redirect('/#guide');\n    const assetPath=path==='/overlay'?'/overlay.html':path;\n    const assetURL=new URL(request.url);assetURL.pathname=assetPath;\n    return env.ASSETS.fetch(new Request(assetURL,request));\n  }\n  throw new HttpError(404,'Not found.');\n}\nexport default {\n  async fetch(request,env) {\n    let response;\n    try{response=await handleRequest(request,env);}\n    catch(error){\n      const message=String(error.message || '');\n      if(message.includes('Pending submission limit') || message.includes('Texture limit'))response=json({error:'Account submission or texture limit reached.'},429);\n      else if(message.includes('Only approved cosmetics'))response=json({error:'Only approved cosmetics can be equipped.'},400);\n      else response=json({error:error instanceof HttpError?error.message:'Request failed. Please try again.'},error.status || 500);\n    }\n    const headers=new Headers(response.headers);\n    if(!headers.has('Cache-Control'))headers.set('Cache-Control','no-store');\n    if(new URL(request.url).pathname.startsWith('/api/v1/'))headers.set('Access-Control-Allow-Origin','*');\n    headers.set('X-Content-Type-Options','nosniff');headers.set('Referrer-Policy','no-referrer');\n    headers.set('Permissions-Policy','camera=(), microphone=(), geolocation=()');\n    headers.set('Content-Security-Policy',`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: ${avatarSources}; connect-src 'self' wss://irc-ws.chat.twitch.tv; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`);\n    if(new URL(request.url).protocol==='https:')headers.set('Strict-Transport-Security','max-age=31536000');\n    return new Response(response.body,{status:response.status,headers});\n  },\n  async scheduled(event,env,ctx){ctx.waitUntil(new D1Store(env.DB).cleanup());}\n};\n","cloudflare/store.js":"import { randomBytes, createHash, randomUUID } from 'node:crypto';\nimport { assert, catalogSelection, publishingFlag } from '../lib/cosmetics.js';\nimport { safeAvatar } from '../lib/oauth.js';\nconst paintSelect = `SELECT c.*,u.name AS creator,u.avatar_url AS creator_avatar,u.profile_provider AS creator_provider,r.name AS reviewer,(SELECT COUNT(*) FROM users e WHERE e.equipped=c.id) AS equipped_count FROM cosmetics c JOIN users u ON u.id=c.owner_id LEFT JOIN users r ON r.id=c.reviewer_id`;\nexport const digest = value => createHash('sha256').update(value).digest('hex');\nexport const token = () => randomBytes(32).toString('base64url');\nconst now = () => new Date().toISOString();\nconst parse = row => row ? {...row,recipe:JSON.parse(row.recipe)} : null;\nexport class D1Store {\n  constructor(db) { this.db=db; }\n  stmt(sql,...args) { return this.db.prepare(sql).bind(...args); }\n  first(sql,...args) { return this.stmt(sql,...args).first(); }\n  async all(sql,...args) { return (await this.stmt(sql,...args).all()).results; }\n  run(sql,...args) { return this.stmt(sql,...args).run(); }\n  user(id) { return this.first('SELECT * FROM users WHERE id=?',id); }\n  async account(id) {\n    const user=await this.user(id);\n    return user ? {...user,identities:await this.all('SELECT provider,provider_id,login,display_name,avatar_url FROM identities WHERE user_id=?',id)} : null;\n  }\n  async identities(id) { return this.all('SELECT provider,provider_id,login,display_name,avatar_url FROM identities WHERE user_id=?',id); }\n  async updateProfile(id,provider) {\n    const identity=(await this.identities(id)).find(item=>item.provider===provider);\n    assert(identity,400,'Choose a connected platform for your profile.');\n    await this.run('UPDATE users SET profile_provider=?,name=?,avatar_url=? WHERE id=?',provider,identity.display_name,safeAvatar(provider,identity.avatar_url),id);\n    return this.account(id);\n  }\n  async adminUsers({query='',offset=0,limit=50}={}) {\n    const rows=await this.all(`SELECT u.*,i.provider_id AS twitch_id FROM users u LEFT JOIN identities i ON i.user_id=u.id AND i.provider='twitch' WHERE u.name LIKE ? OR EXISTS (SELECT 1 FROM identities x WHERE x.user_id=u.id AND (x.login LIKE ? OR x.provider_id=?)) ORDER BY u.created_at DESC,u.id LIMIT ? OFFSET ?`,`%${query}%`,`%${query}%`,query,limit,offset);\n    return Promise.all(rows.map(async row=>({...row,identities:await this.identities(row.id)})));\n  }\n  async moderationCatalog(options) { return Promise.all((await this.catalog(options)).map(async paint=>({...paint,creator_identities:await this.identities(paint.owner_id)}))); }\n  async session(raw) {\n    return raw ? this.first('SELECT sessions.csrf,users.* FROM sessions JOIN users ON users.id=sessions.user_id WHERE hash=? AND expires>?',digest(raw),Date.now()) : null;\n  }\n  async newSession(id) {\n    const raw=token(),csrf=token();\n    await this.run('INSERT INTO sessions VALUES (?,?,?,?)',digest(raw),id,csrf,Date.now()+7*86400000);\n    return {raw,csrf};\n  }\n  async revokeSession(raw) { if(raw)await this.run('DELETE FROM sessions WHERE hash=?',digest(raw)); }\n  async state(provider,userId,binding,verifier) {\n    const raw=token();await this.run('INSERT INTO oauth_states VALUES (?,?,?,?,?,?)',digest(raw),provider,userId || null,digest(binding),verifier,Date.now()+600000);return raw;\n  }\n  async consumeState(raw,provider,binding) {\n    const state=await this.first('DELETE FROM oauth_states WHERE hash=? AND provider=? AND binding=? AND expires>? RETURNING *',digest(raw || ''),provider,digest(binding || ''),Date.now());\n    assert(state,400,'Login expired or invalid. Please start again.');return state;\n  }\n  async linkIdentity(profile,currentId,ownerTwitchId) {\n    const existing=await this.first('SELECT * FROM identities WHERE provider=? AND provider_id=?',profile.provider,profile.id);\n    if(currentId && existing)assert(existing.user_id===currentId,409,'This identity belongs to another account. Sign out to access that account.');\n    const id=currentId || existing?.user_id || randomUUID();\n    const slot=await this.first('SELECT * FROM identities WHERE user_id=? AND provider=?',id,profile.provider);\n    assert(!slot || slot.provider_id===profile.id,409,'This account already has a different identity for this platform.');\n    const statements=[\n      this.stmt('INSERT OR IGNORE INTO users (id,name,created_at) VALUES (?,?,?)',id,profile.name,now()),\n      this.stmt('INSERT INTO identities (provider,provider_id,user_id,login,display_name,avatar_url) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,provider_id) DO UPDATE SET login=excluded.login,display_name=excluded.display_name,avatar_url=excluded.avatar_url',profile.provider,profile.id,id,profile.login,profile.name,safeAvatar(profile.provider,profile.avatar_url)),\n      this.stmt(\"UPDATE users SET profile_provider=?,name=?,avatar_url=? WHERE id=? AND (profile_provider='' OR profile_provider=?)\",profile.provider,profile.name,safeAvatar(profile.provider,profile.avatar_url),id,profile.provider)\n    ];\n    if(profile.provider==='twitch' && profile.id===ownerTwitchId)statements.push(this.stmt(\"UPDATE users SET role='owner' WHERE id=?\",id));\n    try { await this.db.batch(statements); }\n    catch { assert(false,409,'This identity could not be linked. It may already belong to another account.'); }\n    return id;\n  }\n  async getCosmetic(id) { return parse(await this.first(`${paintSelect} WHERE c.id=?`,id)); }\n  async catalog({status='approved',offset=0,limit=60,query='',listedOnly=false,effect='all',sort='newest'}={}) {\n    const selection=catalogSelection({effect,sort});\n    return (await this.all(`${paintSelect} WHERE c.status=? ${listedOnly?'AND c.listed=1':''} AND (${selection.filter}) AND (c.name LIKE ? OR u.name LIKE ?) ORDER BY ${selection.order} LIMIT ? OFFSET ?`,status,`%${query}%`,`%${query}%`,limit,offset)).map(parse);\n  }\n  async mine(id) { return (await this.all(`${paintSelect} WHERE c.owner_id=? ORDER BY c.created_at DESC LIMIT 100`,id)).map(parse); }\n  async submit(id,name,description,recipe,listed=true) {\n    publishingFlag(listed);\n    const cosmeticId=randomUUID();\n    await this.run('INSERT INTO cosmetics (id,owner_id,name,description,recipe,created_at,listed) VALUES (?,?,?,?,?,?,?)',cosmeticId,id,name,description,JSON.stringify(recipe),now(),Number(listed));\n    return this.getCosmetic(cosmeticId);\n  }\n  async setPublication(userId,id,listed) {\n    publishingFlag(listed);\n    const c=await this.getCosmetic(id);assert(c,404,'Flare not found.');assert(c.owner_id===userId,403,'Only the creator can change publication.');\n    await this.db.batch([\n      this.stmt('UPDATE cosmetics SET listed=? WHERE id=? AND owner_id=?',Number(listed),id,userId),\n      this.stmt('INSERT INTO audit (actor_id,action,target,detail,created_at) VALUES (?,?,?,?,?)',userId,'flare.publication',id,listed?'public':'unlisted',now())\n    ]);\n    return this.getCosmetic(id);\n  }\n  publicCosmetic(c) { return c?.status==='approved' ? {id:c.id,name:c.name,description:c.description,creator:c.creator,creator_avatar:c.creator_avatar,creator_provider:c.creator_provider,listed:Boolean(c.listed),equipped_count:c.equipped_count || 0,recipe:c.recipe,updated_at:c.reviewed_at || c.created_at} : null; }\n  async resolve(provider,id) {\n    const identity=await this.first('SELECT i.provider,i.provider_id,i.login,i.display_name,u.equipped FROM identities i JOIN users u ON u.id=i.user_id WHERE i.provider=? AND i.provider_id=?',provider,id);\n    if(!identity)return null;\n    const {equipped,...result}=identity;return {...result,cosmetic:this.publicCosmetic(await this.getCosmetic(equipped || ''))};\n  }\n  async resolveMany(provider,ids) {\n    const rows=await this.all(\"SELECT i.provider,i.provider_id,i.login,i.display_name,c.id AS cosmetic_id,c.name AS cosmetic_name,u2.name AS creator,c.recipe,c.reviewed_at,c.created_at FROM json_each(?) requested JOIN identities i ON i.provider_id=requested.value AND i.provider=? JOIN users u ON u.id=i.user_id LEFT JOIN cosmetics c ON c.id=u.equipped AND c.status='approved' LEFT JOIN users u2 ON u2.id=c.owner_id ORDER BY CAST(requested.key AS INTEGER)\",JSON.stringify(ids),provider);\n    return rows.map(row=>({provider:row.provider,provider_id:row.provider_id,login:row.login,display_name:row.display_name,cosmetic:row.cosmetic_id?{id:row.cosmetic_id,name:row.cosmetic_name,creator:row.creator,recipe:JSON.parse(row.recipe),updated_at:row.reviewed_at || row.created_at}:null}));\n  }\n  async review(actor,id,status,reason) {\n    const c=await this.getCosmetic(id);assert(c,404,'Cosmetic not found.');assert(c.owner_id!==actor.id,403,'You cannot review your own submission.');\n    const row=await this.first(\"UPDATE cosmetics SET status=?,reason=?,reviewed_at=?,reviewer_id=? WHERE id=? AND (status='pending' OR (status='approved' AND ?='rejected')) RETURNING *\",status,reason,now(),actor.id,id,status);\n    assert(row,409,'This submission has already been reviewed.');return this.getCosmetic(id);\n  }\n  async equip(id,cosmeticId) {\n    if(cosmeticId!==null)assert((await this.getCosmetic(cosmeticId))?.status==='approved',400,'Only approved cosmetics can be equipped.');\n    await this.run('UPDATE users SET equipped=? WHERE id=?',cosmeticId,id);\n  }\n  moderators() { return this.all(\"SELECT u.id,u.name,u.role,u.avatar_url,u.profile_provider,u.created_at,i.provider_id AS twitch_id,i.login FROM users u JOIN identities i ON u.id=i.user_id AND i.provider='twitch' WHERE u.role IN ('moderator','owner')\"); }\n  async setModerator(actor,twitchId,enabled) {\n    const row=await this.first(\"SELECT u.* FROM users u JOIN identities i ON u.id=i.user_id WHERE i.provider='twitch' AND i.provider_id=?\",twitchId);\n    assert(row,404,'That Twitch account must sign in first. Use its numeric Twitch ID.');assert(row.role!=='owner',403,'The owner role cannot be changed here.');\n    await this.db.batch([\n      this.stmt(\"UPDATE users SET role=? WHERE id=? AND role!='owner'\",enabled?'moderator':'user',row.id),\n      this.stmt('INSERT INTO audit (actor_id,action,target,detail,created_at) VALUES (?,?,?,?,?)',actor.id,enabled?'moderator.added':'moderator.removed',row.id,twitchId,now())\n    ]);\n  }\n  auditLog() { return this.all('SELECT a.*,u.name AS actor FROM audit a JOIN users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 100'); }\n  async stats() {\n    const row=await this.first(\"SELECT SUM(status='approved' AND listed=1) AS public,SUM(status='approved') AS approved,SUM(status='pending') AS pending,SUM(status='rejected') AS rejected,COUNT(DISTINCT CASE WHEN status='approved' THEN owner_id END) AS creators FROM cosmetics\");\n    return {public:row.public || 0,approved:row.approved || 0,pending:row.pending || 0,rejected:row.rejected || 0,creators:row.creators || 0};\n  }\n  ownsTexture(id,owner) { return this.first('SELECT id FROM textures WHERE id=? AND owner_id=?',id,owner); }\n  async publicTexture(id) {\n    return this.first(\"SELECT c.id FROM cosmetics c WHERE c.status='approved' AND (json_extract(c.recipe,'$.textureId')=? OR EXISTS (SELECT 1 FROM json_each(c.recipe,'$.layers') layer WHERE json_extract(layer.value,'$.textureId')=?)) LIMIT 1\",id,id);\n  }\n  async cleanup() { await this.db.batch([this.stmt('DELETE FROM sessions WHERE expires<?',Date.now()),this.stmt('DELETE FROM oauth_states WHERE expires<?',Date.now())]); }\n}\n","cloudflare/migrations/0001_initial.sql":"PRAGMA foreign_keys=ON;\nCREATE TABLE users (id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user','moderator','owner')), equipped TEXT, created_at TEXT NOT NULL);\nCREATE TABLE identities (provider TEXT NOT NULL CHECK(provider IN ('twitch','kick','youtube')), provider_id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id), login TEXT NOT NULL, display_name TEXT NOT NULL, PRIMARY KEY(provider,provider_id), UNIQUE(user_id,provider));\nCREATE TABLE sessions (hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), csrf TEXT NOT NULL, expires INTEGER NOT NULL);\nCREATE TABLE oauth_states (hash TEXT PRIMARY KEY, provider TEXT NOT NULL, user_id TEXT, binding TEXT NOT NULL, verifier TEXT NOT NULL, expires INTEGER NOT NULL);\nCREATE TABLE cosmetics (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), name TEXT NOT NULL, description TEXT NOT NULL, recipe TEXT NOT NULL CHECK(json_valid(recipe)), status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected')), reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, reviewed_at TEXT, reviewer_id TEXT);\nCREATE TABLE textures (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id));\nCREATE TABLE audit (id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL);\nCREATE TRIGGER identity_ownership BEFORE INSERT ON identities\nWHEN EXISTS(SELECT 1 FROM identities WHERE provider=NEW.provider AND provider_id=NEW.provider_id AND user_id!=NEW.user_id)\nBEGIN SELECT RAISE(ABORT,'Identity already linked'); END;\nCREATE TRIGGER review_audit AFTER UPDATE OF status ON cosmetics\nWHEN NEW.status!=OLD.status\nBEGIN INSERT INTO audit (actor_id,action,target,detail,created_at) VALUES (NEW.reviewer_id,'cosmetic.'||NEW.status,NEW.id,NEW.reason,NEW.reviewed_at); END;\nCREATE INDEX cosmetic_status ON cosmetics(status,created_at);\nCREATE INDEX identity_user ON identities(user_id);\nCREATE TRIGGER pending_quota BEFORE INSERT ON cosmetics\nWHEN (SELECT COUNT(*) FROM cosmetics WHERE owner_id=NEW.owner_id AND status='pending')>=10\nBEGIN SELECT RAISE(ABORT,'Pending submission limit reached'); END;\nCREATE TRIGGER texture_quota BEFORE INSERT ON textures\nWHEN (SELECT COUNT(*) FROM textures WHERE owner_id=NEW.owner_id)>=50 AND NOT EXISTS(SELECT 1 FROM textures WHERE id=NEW.id)\nBEGIN SELECT RAISE(ABORT,'Texture limit reached'); END;\nCREATE TRIGGER valid_equip BEFORE UPDATE OF equipped ON users\nWHEN NEW.equipped IS NOT NULL AND NOT EXISTS(SELECT 1 FROM cosmetics WHERE id=NEW.equipped AND status='approved')\nBEGIN SELECT RAISE(ABORT,'Only approved cosmetics can be equipped'); END;\nCREATE TRIGGER no_self_review BEFORE UPDATE OF status ON cosmetics\nWHEN NEW.reviewer_id=NEW.owner_id\nBEGIN SELECT RAISE(ABORT,'Cannot review own submission'); END;\nCREATE TRIGGER clear_revoked AFTER UPDATE OF status ON cosmetics WHEN NEW.status='rejected'\nBEGIN UPDATE users SET equipped=NULL WHERE equipped=NEW.id; END;\n","cloudflare/migrations/0002_profile_avatars.sql":"ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';\nALTER TABLE users ADD COLUMN profile_provider TEXT NOT NULL DEFAULT '';\nALTER TABLE identities ADD COLUMN avatar_url TEXT NOT NULL DEFAULT '';\n","wrangler.jsonc":"{\n  \"$schema\": \"./node_modules/wrangler/config-schema.json\",\n  \"name\": \"nameflare\",\n  \"account_id\": \"8fa674defa5c4d23493395210a633f82\",\n  \"main\": \"cloudflare/worker.js\",\n  \"compatibility_date\": \"2026-10-07\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"workers_dev\": false,\n  \"preview_urls\": false,\n  \"send_metrics\": false,\n  \"assets\": { \"directory\": \"./public\", \"binding\": \"ASSETS\", \"run_worker_first\": true },\n  \"d1_databases\": [{ \"binding\": \"DB\", \"database_name\": \"nameflare\", \"database_id\": \"00000000-0000-0000-0000-000000000000\", \"migrations_dir\": \"cloudflare/migrations\" }],\n  \"r2_buckets\": [{ \"binding\": \"TEXTURES\", \"bucket_name\": \"nameflare-textures\" }],\n  \"ratelimits\": [\n    { \"name\": \"READ_LIMITER\", \"namespace_id\": \"1001\", \"simple\": { \"limit\": 300, \"period\": 60 } },\n    { \"name\": \"WRITE_LIMITER\", \"namespace_id\": \"1002\", \"simple\": { \"limit\": 40, \"period\": 60 } }\n  ],\n  \"triggers\": { \"crons\": [\"17 * * * *\"] },\n  \"vars\": { \"PUBLIC_BASE_URL\": \"https://nameflare.bxne.dev\", \"OWNER_TWITCH_ID\": \"\", \"SOURCE_URL\": \"\" },\n  \"env\": {\n    \"local\": {\n      \"name\": \"nameflare-local\",\n      \"workers_dev\": false,\n      \"preview_urls\": false,\n      \"vars\": { \"LOCAL_DEV\": \"true\", \"PUBLIC_BASE_URL\": \"http://127.0.0.1:8787\", \"OWNER_TWITCH_ID\": \"1001\", \"SOURCE_URL\": \"\" },\n      \"d1_databases\": [{ \"binding\": \"DB\", \"database_name\": \"nameflare-local\", \"database_id\": \"00000000-0000-0000-0000-000000000000\", \"migrations_dir\": \"cloudflare/migrations\" }],\n      \"r2_buckets\": [{ \"binding\": \"TEXTURES\", \"bucket_name\": \"nameflare-local-textures\" }],\n      \"ratelimits\": [\n        { \"name\": \"READ_LIMITER\", \"namespace_id\": \"1003\", \"simple\": { \"limit\": 300, \"period\": 60 } },\n        { \"name\": \"WRITE_LIMITER\", \"namespace_id\": \"1004\", \"simple\": { \"limit\": 40, \"period\": 60 } }\n      ]\n    }\n  }\n}\n","test/cloudflare.test.js":"import { test } from 'node:test';\nimport assert from 'node:assert/strict';\nimport { DatabaseSync } from 'node:sqlite';\nimport { readFileSync } from 'node:fs';\nimport worker from '../cloudflare/worker.js';\nimport { D1Store } from '../cloudflare/store.js';\nclass LocalD1 {\n  constructor(){this.db=new DatabaseSync(':memory:');for(const migration of ['0001_initial.sql','0002_profile_avatars.sql','0003_flare_publication.sql'])this.db.exec(readFileSync(new URL(`../cloudflare/migrations/${migration}`,import.meta.url),'utf8'));}\n  prepare(sql){const db=this.db;let args=[];return {bind(...values){args=values;return this;},async first(){return db.prepare(sql).get(...args) || null;},async all(){return {results:db.prepare(sql).all(...args)};},async run(){return {meta:db.prepare(sql).run(...args)};}};}\n  async batch(statements){this.db.exec('BEGIN');try{const results=[];for(const stmt of statements)results.push(await stmt.run());this.db.exec('COMMIT');return results;}catch(error){this.db.exec('ROLLBACK');throw error;}}\n}\nconst recipe={colors:['#ff1a35','#ffffff'],animation:'none',speed:4,angle:120,glow:2};\nconst gif='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';\nfunction fixture(){\n  const DB=new LocalD1(),objects=new Map();\n  const env={DB,TEXTURES:{async put(id,bytes){objects.set(id,new Uint8Array(bytes));},async get(id){const bytes=objects.get(id);return bytes?{body:bytes}:null;}},ASSETS:{async fetch(request){return new Response(new URL(request.url).pathname);}},READ_LIMITER:{async limit(){return {success:true};}},WRITE_LIMITER:{async limit(){return {success:true};}},PUBLIC_BASE_URL:'https://nameflare.example',OWNER_TWITCH_ID:'1001',SOURCE_URL:'https://example.com/source',TWITCH_CLIENT_ID:'client',TWITCH_CLIENT_SECRET:'secret'};\n  const store=new D1Store(DB);\n  const request=(path,data,session,headers={})=>worker.fetch(new Request(env.PUBLIC_BASE_URL+path,{method:data===undefined?'GET':'POST',headers:{...(data===undefined?{}:{'Content-Type':'application/json',Origin:env.PUBLIC_BASE_URL}),...(session?{Cookie:`nameflare_session=${session.raw}`,'X-CSRF-Token':session.csrf}:{}),...headers},body:data===undefined?undefined:JSON.stringify(data)}),env);\n  return {env,store,request,close:()=>DB.db.close()};\n}\ntest('Workers creator → private R2 → approval → equip → revocation with CSRF and owner/mod guards',async()=>{\n  const f=fixture();const {store,request}=f;\n  try{\n    const creatorId=await store.linkIdentity({provider:'twitch',id:'1002',name:'Creator',login:'creator'},null,'1001');\n    const ownerId=await store.linkIdentity({provider:'twitch',id:'1001',name:'Owner',login:'owner'},null,'1001');\n    const creator=await store.newSession(creatorId),owner=await store.newSession(ownerId);\n    assert.equal((await request('/api/config')).status,200);assert.equal((await (await request('/api/me',undefined,owner)).json()).user.role,'owner');\n    assert.equal((await request('/api/textures',{data:gif},creator,{'X-CSRF-Token':'bad'})).status,403);\n    const upload=await request('/api/textures',{data:gif},creator);assert.equal(upload.status,201);const {id}=await upload.json();\n    assert.equal((await request(`/textures/${id}`)).status,401);\n    assert.equal((await request('/api/cosmetics',{name:'Stolen',recipe:{...recipe,textureId:id}},owner)).status,400);\n    const submitted=await request('/api/cosmetics',{name:'GIF stack',recipe:{...recipe,layers:[{type:'image',textureId:id,opacity:.7},{type:'solid',color:'#ff1a35',opacity:.2}]}},creator);assert.equal(submitted.status,201);const c=(await submitted.json()).cosmetic;\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'approved'},creator)).status,403);\n    await store.run(\"UPDATE users SET role='moderator' WHERE id=?\",creatorId);\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'approved'},creator)).status,403);\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'approved'},owner)).status,200);\n    assert.equal((await request('/api/equip',{cosmeticId:c.id},creator)).status,200);\n    const resolved=await (await request('/api/v1/resolve',{provider:'twitch',ids:['1002','unknown']})).json();assert.equal(resolved.users.length,1);assert.equal(resolved.users[0].cosmetic.id,c.id);\n    const texture=await request(`/textures/${id}`);assert.equal(texture.headers.get('Cache-Control'),'public, max-age=15, must-revalidate');assert.equal(Buffer.from(await texture.arrayBuffer()).toString('base64'),gif.split(',')[1]);\n    assert.equal((await request('/api/admin/review',{id:c.id,status:'rejected',reason:'Flashing'},owner)).status,200);\n    assert.equal((await store.user(creatorId)).equipped,null);assert.equal((await request(`/textures/${id}`)).status,401);\n    const revoked=await request(`/api/v1/cosmetics/${c.id}`);assert.equal(revoked.status,404);assert.equal(revoked.headers.get('Cache-Control'),'no-store');assert.equal(revoked.headers.get('Access-Control-Allow-Origin'),'*');\n    assert.equal((await store.auditLog()).length,2);\n    assert.equal((await request('/api/admin/moderators',{twitchId:'1002',enabled:false},owner)).status,200);assert.equal((await store.user(creatorId)).role,'user');\n    assert.equal((await request('/api/overlay/events')).status,501);\n  }finally{f.close();}\n});\ntest('D1 constraints prevent races, identity takeover, quota bypass and replayed state',async()=>{\n  const f=fixture();const {store}=f;\n  try{\n    const p=id=>({provider:'twitch',id,login:id,name:id});const one=await store.linkIdentity(p('1'),null,'1001'),two=await store.linkIdentity(p('2'),null,'1001');\n    await assert.rejects(()=>store.linkIdentity(p('1'),two,'1001'),e=>e.status===409);\n    await assert.rejects(()=>store.run('INSERT INTO identities (provider,provider_id,user_id,login,display_name) VALUES (?,?,?,?,?)','twitch','1',two,'stolen','stolen'),/Identity already linked/);\n    const state=await store.state('kick',one,'browser','verifier');await assert.rejects(()=>store.consumeState(state,'kick','attacker'),e=>e.status===400);assert.equal((await store.consumeState(state,'kick','browser')).user_id,one);await assert.rejects(()=>store.consumeState(state,'kick','browser'),e=>e.status===400);\n    for(let i=0;i<10;i++)await store.submit(one,`Paint ${i}`,'',recipe);await assert.rejects(()=>store.submit(one,'Eleventh','',recipe),/Pending submission limit/);\n    await assert.rejects(()=>store.run('UPDATE users SET equipped=? WHERE id=?','unknown',one),/Only approved/);\n  }finally{f.close();}\n});\ntest('Workers OAuth callback binds provider/browser, rotates session and bootstraps only numeric owner',async()=>{\n  const f=fixture(),originalFetch=globalThis.fetch;\n  try{\n    const started=await f.request('/auth/twitch');assert.equal(started.status,302);const auth=new URL(started.headers.get('Location'));assert.equal(auth.searchParams.get('redirect_uri'),'https://nameflare.example/auth/twitch/callback');\n    const binding=started.headers.get('Set-Cookie').split(';')[0];assert.match(started.headers.get('Set-Cookie'),/HttpOnly; SameSite=Lax.*Secure/);\n    globalThis.fetch=async url=>String(url).includes('/token')?Response.json({access_token:'temporary'}):Response.json({data:[{id:'1001',login:'bxne',display_name:'B_X_N_E',profile_image_url:'https://static-cdn.jtvnw.net/user.png'}]});\n    const path=`/auth/twitch/callback?state=${auth.searchParams.get('state')}&code=test`;\n    assert.equal((await f.request(path,undefined,undefined,{Cookie:'nameflare_oauth_twitch=wrong'})).status,400);\n    const result=await f.request(path,undefined,undefined,{Cookie:binding});assert.equal(result.status,302);assert.equal(result.headers.get('Location'),'/#account');\n    const sessionCookie=result.headers.get('Set-Cookie').match(/nameflare_session=([^;]+)/)[1];const me=await (await f.request('/api/me',undefined,undefined,{Cookie:`nameflare_session=${sessionCookie}`})).json();assert.equal(me.user.role,'owner');assert.equal(me.user.avatar_url,'https://static-cdn.jtvnw.net/user.png');assert.equal(me.user.profile_provider,'twitch');\n    assert.equal((await f.request(path,undefined,undefined,{Cookie:binding})).status,400);\n  }finally{globalThis.fetch=originalFetch;f.close();}\n});\ntest('Workers profile settings, private account table, and review history match Node permissions',async()=>{\n  const f=fixture();try{\n    const ownerId=await f.store.linkIdentity({provider:'twitch',id:'1001',login:'owner',name:'Owner'},null,'1001');\n    const id=await f.store.linkIdentity({provider:'twitch',id:'42',login:'creator',name:'Creator',avatar_url:'https://static-cdn.jtvnw.net/a.png'},null,'1001');\n    const owner=await f.store.newSession(ownerId),creator=await f.store.newSession(id);\n    await f.store.linkIdentity({provider:'kick',id:'7',login:'kick',name:'Kick Creator',avatar_url:'https://files.kick.com/a.png'},id,'1001');\n    assert.equal((await f.store.account(id)).name,'Creator');\n    assert.equal((await f.request('/api/admin/users',undefined,creator)).status,403);\n    assert.equal((await f.request('/api/account/profile',{provider:'kick'},creator,{'X-CSRF-Token':'bad'})).status,403);\n    const updated=await (await f.request('/api/account/profile',{provider:'kick'},creator)).json();assert.equal(updated.user.name,'Kick Creator');assert.equal(updated.user.avatar_url,'https://files.kick.com/a.png');\n    assert.equal((await f.request('/api/account/profile',{provider:'youtube'},creator)).status,400);\n    const users=await (await f.request('/api/admin/users?q=creator',undefined,owner)).json();assert.equal(users.users.length,1);assert.equal(users.users[0].identities.length,2);assert.equal(users.users[0].twitch_id,'42');\n    const paint=await f.store.submit(id,'History paint','Original',recipe);\n    const pending=await (await f.request('/api/admin/queue',undefined,owner)).json();assert.equal(pending.cosmetics[0].creator_identities.length,2);assert.equal(pending.cosmetics[0].creator_avatar,'https://files.kick.com/a.png');\n    await f.request('/api/admin/review',{id:paint.id,status:'approved',reason:'Readable'},owner);\n    const approved=await (await f.request('/api/admin/queue?status=approved',undefined,owner)).json();assert.equal(approved.cosmetics[0].reviewer,'Owner');assert.ok(approved.cosmetics[0].reviewed_at);\n    await f.request('/api/admin/review',{id:paint.id,status:'rejected',reason:'Rights concern'},owner);\n    const rejected=await (await f.request('/api/admin/queue?status=rejected',undefined,owner)).json();assert.equal(rejected.stats.rejected,1);assert.equal(rejected.cosmetics[0].reason,'Rights concern');\n    for(const path of ['/api/admin/queue?status=all','/api/admin/queue?offset=-1','/api/admin/users?offset=NaN'])assert.equal((await f.request(path,undefined,owner)).status,400);\n    const role=await f.request('/api/admin/moderators',{twitchId:'42',enabled:true},owner);assert.equal(role.status,200);\n    assert.equal((await f.request('/api/admin/queue',undefined,creator)).status,200);assert.equal((await f.request('/api/admin/users',undefined,creator)).status,403);\n    await f.request('/api/admin/moderators',{twitchId:'42',enabled:false},owner);assert.equal((await f.request('/api/admin/queue',undefined,creator)).status,403);\n  }finally{f.close();}\n});\n\ntest('Workers public/unlisted Flares preserve approval, creator controls, discovery and universal equips',async()=>{\n  const f=fixture();try{\n    const creatorId=await f.store.linkIdentity({provider:'twitch',id:'2',name:'Artist',login:'artist'},null,'1');\n    const viewerId=await f.store.linkIdentity({provider:'twitch',id:'3',name:'Viewer',login:'viewer'},null,'1');\n    const ownerId=await f.store.linkIdentity({provider:'twitch',id:'1',name:'Owner',login:'owner'},null,'1');\n    const creator=await f.store.newSession(creatorId),viewer=await f.store.newSession(viewerId);\n    const response=await f.request('/api/cosmetics',{name:'Hidden signal',description:'A shared Flare',recipe,listed:false},creator);\n    assert.equal(response.status,201);const c=(await response.json()).cosmetic;assert.equal(c.listed,0);\n    assert.equal((await f.request(`/api/v1/cosmetics/${c.id}`)).status,404);\n    assert.equal((await f.request('/api/cosmetics/publication',{id:c.id,listed:true},viewer)).status,403);\n    assert.equal((await f.request('/api/cosmetics/publication',{id:c.id,listed:true},creator,{'X-CSRF-Token':'bad'})).status,403);\n    assert.equal((await f.request('/api/cosmetics',{name:'Invalid',recipe,listed:'false'},creator)).status,400);\n    await f.store.review(await f.store.user(ownerId),c.id,'approved','Readable');\n    let catalog=await (await f.request('/api/v1/cosmetics')).json();assert.equal(catalog.cosmetics.length,0);\n    const publicFlare=(await (await f.request(`/api/v1/cosmetics/${c.id}`)).json()).cosmetic;assert.equal(publicFlare.description,'A shared Flare');assert.equal(publicFlare.listed,false);assert.equal(publicFlare.owner_id,undefined);\n    assert.equal((await f.request('/api/equip',{cosmeticId:c.id},viewer)).status,200);\n    const resolved=await (await f.request('/api/v1/resolve',{provider:'twitch',ids:['3']})).json();assert.equal(resolved.users[0].cosmetic.id,c.id);\n    assert.equal((await f.request('/api/cosmetics/publication',{id:c.id,listed:true},creator)).status,200);\n    catalog=await (await f.request('/api/v1/cosmetics?effect=static&sort=popular&limit=1')).json();assert.equal(catalog.total,1);assert.equal(catalog.cosmetics[0].equipped_count,1);assert.equal(catalog.next_offset,null);\n    assert.equal((await (await f.request('/api/v1/cosmetics?effect=depth')).json()).cosmetics.length,0);\n    for(const query of ['effect=invalid','sort=invalid'])assert.equal((await f.request(`/api/v1/cosmetics?${query}`)).status,400);\n    await f.store.setPublication(creatorId,c.id,false);assert.equal((await f.store.user(viewerId)).equipped,c.id);\n    await f.store.review(await f.store.user(ownerId),c.id,'rejected','Rights concern');assert.equal((await f.store.user(viewerId)).equipped,null);\n    await f.store.setPublication(creatorId,c.id,true);assert.equal((await f.request(`/api/v1/cosmetics/${c.id}`)).status,404);\n  }finally{f.close();}\n});\n\ntest('Workers fail closed on canonical origin, missing limiter and unavailable OAuth; static routing works',async()=>{\n  const f=fixture();try{\n    assert.equal((await worker.fetch(new Request('https://wrong.example/api/me'),f.env)).status,421);\n    assert.equal((await f.request('/guide')).headers.get('Location'),'/#guide');assert.equal(await (await f.request('/overlay')).text(),'/overlay.html');\n    assert.equal((await f.request('/auth/kick')).status,503);\n    delete f.env.READ_LIMITER;assert.equal((await f.request('/api/config')).status,503);\n  }finally{f.close();}\n});\n","cloudflare/migrations/0003_flare_publication.sql":"ALTER TABLE cosmetics ADD COLUMN listed INTEGER NOT NULL DEFAULT 1 CHECK(listed IN (0,1));\nCREATE INDEX cosmetic_publication ON cosmetics(status,listed,reviewed_at);\n","package-lock.json":"{\n  \"name\": \"nameflare\",\n  \"version\": \"0.1.0\",\n  \"lockfileVersion\": 3,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n      \"name\": \"nameflare\",\n      \"version\": \"0.1.0\",\n      \"license\": \"AGPL-3.0-only\",\n      \"devDependencies\": {\n        \"wrangler\": \"^4.148.0\"\n      },\n      \"engines\": {\n        \"node\": \">=22.13.0\"\n      }\n    },\n    \"node_modules/@cloudflare/kv-asset-handler\": {\n      \"version\": \"0.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz\",\n      \"integrity\": \"sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==\",\n      \"dev\": true,\n      \"license\": \"MIT OR Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=22.0.0\"\n      }\n    },\n    \"node_modules/@cloudflare/unenv-preset\": {\n      \"version\": \"2.16.2\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz\",\n      \"integrity\": \"sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==\",\n      \"dev\": true,\n      \"license\": \"MIT OR Apache-2.0\",\n      \"peerDependencies\": {\n        \"unenv\": \"2.0.0-rc.24\",\n        \"workerd\": \">1.20260305.0 <2.0.0-0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"workerd\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/@cloudflare/workerd-darwin-64\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/@cloudflare/workerd-darwin-arm64\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/@cloudflare/workerd-linux-64\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/@cloudflare/workerd-linux-arm64\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/@cloudflare/workerd-windows-64\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/@cspotcode/source-map-support\": {\n      \"version\": \"0.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz\",\n      \"integrity\": \"sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/trace-mapping\": \"0.3.9\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/@emnapi/runtime\": {\n      \"version\": \"1.11.3\",\n      \"resolved\": \"https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz\",\n      \"integrity\": \"sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.4.0\"\n      }\n    },\n    \"node_modules/@esbuild/aix-ppc64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz\",\n      \"integrity\": \"sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"aix\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz\",\n      \"integrity\": \"sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz\",\n      \"integrity\": \"sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ia32\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz\",\n      \"integrity\": \"sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-loong64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz\",\n      \"integrity\": \"sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==\",\n      \"cpu\": [\n        \"loong64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-mips64el\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz\",\n      \"integrity\": \"sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==\",\n      \"cpu\": [\n        \"mips64el\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ppc64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz\",\n      \"integrity\": \"sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-riscv64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz\",\n      \"integrity\": \"sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-s390x\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz\",\n      \"integrity\": \"sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openharmony-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openharmony\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/sunos-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"sunos\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-arm64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz\",\n      \"integrity\": \"sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-ia32\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz\",\n      \"integrity\": \"sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-x64\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz\",\n      \"integrity\": \"sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@img/colour\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz\",\n      \"integrity\": \"sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@img/sharp-darwin-arm64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz\",\n      \"integrity\": \"sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-darwin-arm64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-darwin-x64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz\",\n      \"integrity\": \"sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-darwin-x64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-freebsd-wasm32\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz\",\n      \"integrity\": \"sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"dependencies\": {\n        \"@img/sharp-wasm32\": \"0.35.5\"\n      },\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-darwin-arm64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz\",\n      \"integrity\": \"sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-darwin-x64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz\",\n      \"integrity\": \"sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-arm\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz\",\n      \"integrity\": \"sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-arm64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz\",\n      \"integrity\": \"sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-ppc64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz\",\n      \"integrity\": \"sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-riscv64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz\",\n      \"integrity\": \"sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-s390x\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz\",\n      \"integrity\": \"sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linux-x64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz\",\n      \"integrity\": \"sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linuxmusl-arm64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz\",\n      \"integrity\": \"sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"musl\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-libvips-linuxmusl-x64\": {\n      \"version\": \"1.3.4\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz\",\n      \"integrity\": \"sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"musl\"\n      ],\n      \"license\": \"LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-arm\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz\",\n      \"integrity\": \"sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-arm\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-arm64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz\",\n      \"integrity\": \"sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-arm64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-ppc64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz\",\n      \"integrity\": \"sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-ppc64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-riscv64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz\",\n      \"integrity\": \"sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-riscv64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-s390x\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz\",\n      \"integrity\": \"sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-s390x\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linux-x64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz\",\n      \"integrity\": \"sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"glibc\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linux-x64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linuxmusl-arm64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz\",\n      \"integrity\": \"sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"musl\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linuxmusl-arm64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-linuxmusl-x64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz\",\n      \"integrity\": \"sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"libc\": [\n        \"musl\"\n      ],\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-libvips-linuxmusl-x64\": \"1.3.4\"\n      }\n    },\n    \"node_modules/@img/sharp-wasm32\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz\",\n      \"integrity\": \"sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0 AND LGPL-3.0-or-later AND MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@emnapi/runtime\": \"^1.11.3\"\n      },\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-webcontainers-wasm32\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz\",\n      \"integrity\": \"sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==\",\n      \"cpu\": [\n        \"wasm32\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@img/sharp-wasm32\": \"0.35.5\"\n      },\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-win32-arm64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz\",\n      \"integrity\": \"sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0 AND LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-win32-ia32\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz\",\n      \"integrity\": \"sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0 AND LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \"^20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@img/sharp-win32-x64\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz\",\n      \"integrity\": \"sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"Apache-2.0 AND LGPL-3.0-or-later\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      }\n    },\n    \"node_modules/@jridgewell/resolve-uri\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz\",\n      \"integrity\": \"sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6.0.0\"\n      }\n    },\n    \"node_modules/@jridgewell/sourcemap-codec\": {\n      \"version\": \"1.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz\",\n      \"integrity\": \"sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@jridgewell/trace-mapping\": {\n      \"version\": \"0.3.9\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz\",\n      \"integrity\": \"sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/resolve-uri\": \"^3.0.3\",\n        \"@jridgewell/sourcemap-codec\": \"^1.4.10\"\n      }\n    },\n    \"node_modules/@poppinss/colors\": {\n      \"version\": \"4.1.6\",\n      \"resolved\": \"https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz\",\n      \"integrity\": \"sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"kleur\": \"^4.1.5\"\n      }\n    },\n    \"node_modules/@poppinss/dumper\": {\n      \"version\": \"0.6.5\",\n      \"resolved\": \"https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz\",\n      \"integrity\": \"sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@poppinss/colors\": \"^4.1.5\",\n        \"@sindresorhus/is\": \"^7.0.2\",\n        \"supports-color\": \"^10.0.0\"\n      }\n    },\n    \"node_modules/@poppinss/exception\": {\n      \"version\": \"1.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz\",\n      \"integrity\": \"sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@sindresorhus/is\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz\",\n      \"integrity\": \"sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sindresorhus/is?sponsor=1\"\n      }\n    },\n    \"node_modules/@speed-highlight/core\": {\n      \"version\": \"1.2.24\",\n      \"resolved\": \"https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz\",\n      \"integrity\": \"sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==\",\n      \"dev\": true,\n      \"license\": \"CC0-1.0\"\n    },\n    \"node_modules/blake3-wasm\": {\n      \"version\": \"2.1.5\",\n      \"resolved\": \"https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz\",\n      \"integrity\": \"sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/cookie\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz\",\n      \"integrity\": \"sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/express\"\n      }\n    },\n    \"node_modules/detect-libc\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz\",\n      \"integrity\": \"sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/error-stack-parser-es\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz\",\n      \"integrity\": \"sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/antfu\"\n      }\n    },\n    \"node_modules/esbuild\": {\n      \"version\": \"0.28.1\",\n      \"resolved\": \"https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz\",\n      \"integrity\": \"sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"esbuild\": \"bin/esbuild\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"optionalDependencies\": {\n        \"@esbuild/aix-ppc64\": \"0.28.1\",\n        \"@esbuild/android-arm\": \"0.28.1\",\n        \"@esbuild/android-arm64\": \"0.28.1\",\n        \"@esbuild/android-x64\": \"0.28.1\",\n        \"@esbuild/darwin-arm64\": \"0.28.1\",\n        \"@esbuild/darwin-x64\": \"0.28.1\",\n        \"@esbuild/freebsd-arm64\": \"0.28.1\",\n        \"@esbuild/freebsd-x64\": \"0.28.1\",\n        \"@esbuild/linux-arm\": \"0.28.1\",\n        \"@esbuild/linux-arm64\": \"0.28.1\",\n        \"@esbuild/linux-ia32\": \"0.28.1\",\n        \"@esbuild/linux-loong64\": \"0.28.1\",\n        \"@esbuild/linux-mips64el\": \"0.28.1\",\n        \"@esbuild/linux-ppc64\": \"0.28.1\",\n        \"@esbuild/linux-riscv64\": \"0.28.1\",\n        \"@esbuild/linux-s390x\": \"0.28.1\",\n        \"@esbuild/linux-x64\": \"0.28.1\",\n        \"@esbuild/netbsd-arm64\": \"0.28.1\",\n        \"@esbuild/netbsd-x64\": \"0.28.1\",\n        \"@esbuild/openbsd-arm64\": \"0.28.1\",\n        \"@esbuild/openbsd-x64\": \"0.28.1\",\n        \"@esbuild/openharmony-arm64\": \"0.28.1\",\n        \"@esbuild/sunos-x64\": \"0.28.1\",\n        \"@esbuild/win32-arm64\": \"0.28.1\",\n        \"@esbuild/win32-ia32\": \"0.28.1\",\n        \"@esbuild/win32-x64\": \"0.28.1\"\n      }\n    },\n    \"node_modules/fsevents\": {\n      \"version\": \"2.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz\",\n      \"integrity\": \"sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \"^8.16.0 || ^10.6.0 || >=11.0.0\"\n      }\n    },\n    \"node_modules/kleur\": {\n      \"version\": \"4.1.5\",\n      \"resolved\": \"https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz\",\n      \"integrity\": \"sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/miniflare\": {\n      \"version\": \"5.20261006.0-alpha\",\n      \"resolved\": \"https://registry.npmjs.org/miniflare/-/miniflare-5.20261006.0-alpha.tgz\",\n      \"integrity\": \"sha512-UP3qIeQKHba59GHxBqD8H2GateacluT4vrjIpqEgH78kCSUxdu8jA0f7MH7ftRSAnnB1oOvEsDtav3xevQDbpQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@cspotcode/source-map-support\": \"0.8.1\",\n        \"sharp\": \"0.35.4\",\n        \"undici\": \"7.29.1\",\n        \"workerd\": \"1.20261006.1\",\n        \"ws\": \"8.21.0\",\n        \"youch\": \"4.1.0-beta.10\"\n      },\n      \"engines\": {\n        \"node\": \">=22.0.0\"\n      }\n    },\n    \"node_modules/path-to-regexp\": {\n      \"version\": \"6.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz\",\n      \"integrity\": \"sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/pathe\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz\",\n      \"integrity\": \"sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/semver\": {\n      \"version\": \"7.8.5\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.8.5.tgz\",\n      \"integrity\": \"sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/sharp\": {\n      \"version\": \"0.35.5\",\n      \"resolved\": \"https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz\",\n      \"integrity\": \"sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@img/colour\": \"^1.1.0\",\n        \"detect-libc\": \"^2.1.2\",\n        \"semver\": \"^7.8.5\"\n      },\n      \"engines\": {\n        \"node\": \">=20.9.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/libvips\"\n      },\n      \"optionalDependencies\": {\n        \"@img/sharp-darwin-arm64\": \"0.35.5\",\n        \"@img/sharp-darwin-x64\": \"0.35.5\",\n        \"@img/sharp-freebsd-wasm32\": \"0.35.5\",\n        \"@img/sharp-libvips-darwin-arm64\": \"1.3.4\",\n        \"@img/sharp-libvips-darwin-x64\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-arm\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-arm64\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-ppc64\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-riscv64\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-s390x\": \"1.3.4\",\n        \"@img/sharp-libvips-linux-x64\": \"1.3.4\",\n        \"@img/sharp-libvips-linuxmusl-arm64\": \"1.3.4\",\n        \"@img/sharp-libvips-linuxmusl-x64\": \"1.3.4\",\n        \"@img/sharp-linux-arm\": \"0.35.5\",\n        \"@img/sharp-linux-arm64\": \"0.35.5\",\n        \"@img/sharp-linux-ppc64\": \"0.35.5\",\n        \"@img/sharp-linux-riscv64\": \"0.35.5\",\n        \"@img/sharp-linux-s390x\": \"0.35.5\",\n        \"@img/sharp-linux-x64\": \"0.35.5\",\n        \"@img/sharp-linuxmusl-arm64\": \"0.35.5\",\n        \"@img/sharp-linuxmusl-x64\": \"0.35.5\",\n        \"@img/sharp-webcontainers-wasm32\": \"0.35.5\",\n        \"@img/sharp-win32-arm64\": \"0.35.5\",\n        \"@img/sharp-win32-ia32\": \"0.35.5\",\n        \"@img/sharp-win32-x64\": \"0.35.5\"\n      },\n      \"peerDependenciesMeta\": {\n        \"@types/node\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/supports-color\": {\n      \"version\": \"10.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz\",\n      \"integrity\": \"sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/chalk/supports-color?sponsor=1\"\n      }\n    },\n    \"node_modules/tslib\": {\n      \"version\": \"2.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\",\n      \"integrity\": \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\",\n      \"dev\": true,\n      \"license\": \"0BSD\",\n      \"optional\": true\n    },\n    \"node_modules/undici\": {\n      \"version\": \"7.29.1\",\n      \"resolved\": \"https://registry.npmjs.org/undici/-/undici-7.29.1.tgz\",\n      \"integrity\": \"sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=20.18.1\"\n      }\n    },\n    \"node_modules/unenv\": {\n      \"version\": \"2.0.0-rc.24\",\n      \"resolved\": \"https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz\",\n      \"integrity\": \"sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"pathe\": \"^2.0.3\"\n      }\n    },\n    \"node_modules/workerd\": {\n      \"version\": \"1.20261006.1\",\n      \"resolved\": \"https://registry.npmjs.org/workerd/-/workerd-1.20261006.1.tgz\",\n      \"integrity\": \"sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"Apache-2.0\",\n      \"bin\": {\n        \"workerd\": \"bin/workerd\"\n      },\n      \"engines\": {\n        \"node\": \">=16\"\n      },\n      \"optionalDependencies\": {\n        \"@cloudflare/workerd-darwin-64\": \"1.20261006.1\",\n        \"@cloudflare/workerd-darwin-arm64\": \"1.20261006.1\",\n        \"@cloudflare/workerd-linux-64\": \"1.20261006.1\",\n        \"@cloudflare/workerd-linux-arm64\": \"1.20261006.1\",\n        \"@cloudflare/workerd-windows-64\": \"1.20261006.1\"\n      }\n    },\n    \"node_modules/wrangler\": {\n      \"version\": \"4.148.0\",\n      \"resolved\": \"https://registry.npmjs.org/wrangler/-/wrangler-4.148.0.tgz\",\n      \"integrity\": \"sha512-wgbll8cA/7qOMJSoYQuJrw9M9lmedGzYtg6wvUK2p/7G1KaE88jFmQ/CvtQzVUF9C8PQtC4Y5p9vbpAsJGAlpA==\",\n      \"dev\": true,\n      \"license\": \"MIT OR Apache-2.0\",\n      \"dependencies\": {\n        \"@cloudflare/kv-asset-handler\": \"0.5.0\",\n        \"@cloudflare/unenv-preset\": \"2.16.2\",\n        \"blake3-wasm\": \"2.1.5\",\n        \"esbuild\": \"0.28.1\",\n        \"miniflare\": \"5.20261006.0-alpha\",\n        \"path-to-regexp\": \"6.3.0\",\n        \"unenv\": \"2.0.0-rc.24\",\n        \"workerd\": \"1.20261006.1\"\n      },\n      \"bin\": {\n        \"cf-wrangler\": \"bin/cf-wrangler.js\",\n        \"wrangler\": \"bin/wrangler.js\",\n        \"wrangler2\": \"bin/wrangler.js\"\n      },\n      \"engines\": {\n        \"node\": \">=22.0.0\"\n      },\n      \"optionalDependencies\": {\n        \"fsevents\": \"2.3.3\"\n      },\n      \"peerDependencies\": {\n        \"@cloudflare/workers-types\": \"^5.20261006.1\"\n      },\n      \"peerDependenciesMeta\": {\n        \"@cloudflare/workers-types\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/ws\": {\n      \"version\": \"8.21.0\",\n      \"resolved\": \"https://registry.npmjs.org/ws/-/ws-8.21.0.tgz\",\n      \"integrity\": \"sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.0.0\"\n      },\n      \"peerDependencies\": {\n        \"bufferutil\": \"^4.0.1\",\n        \"utf-8-validate\": \">=5.0.2\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bufferutil\": {\n          \"optional\": true\n        },\n        \"utf-8-validate\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/youch\": {\n      \"version\": \"4.1.0-beta.10\",\n      \"resolved\": \"https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz\",\n      \"integrity\": \"sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@poppinss/colors\": \"^4.1.5\",\n        \"@poppinss/dumper\": \"^0.6.4\",\n        \"@speed-highlight/core\": \"^1.2.7\",\n        \"cookie\": \"^1.0.2\",\n        \"youch-core\": \"^0.3.3\"\n      }\n    },\n    \"node_modules/youch-core\": {\n      \"version\": \"0.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz\",\n      \"integrity\": \"sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@poppinss/exception\": \"^1.2.2\",\n        \"error-stack-parser-es\": \"^1.0.5\"\n      }\n    }\n  }\n}\n","Dockerfile":"FROM node:24-bookworm-slim\nWORKDIR /app\nCOPY --chown=node:node package.json package-lock.json server.js wrangler.jsonc ./\nCOPY --chown=node:node lib ./lib\nCOPY --chown=node:node cloudflare ./cloudflare\nCOPY --chown=node:node public ./public\nCOPY --chown=node:node LICENSE README.md SECURITY.md compose.yaml .env.example .gitignore .dockerignore ./\nCOPY --chown=node:node Dockerfile ./Dockerfile\nCOPY --chown=node:node docs ./docs\nCOPY --chown=node:node test ./test\nRUN mkdir -p /app/data && chown node:node /app/data\nUSER node\nENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATA_DIR=/app/data\nEXPOSE 3000\nHEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD node -e \"fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"\nCMD [\"node\", \"server.js\"]\n","compose.yaml":"services:\n  nameflare:\n    build: .\n    restart: unless-stopped\n    init: true\n    env_file: .env\n    environment:\n      NODE_ENV: production\n      HOST: 0.0.0.0\n      PORT: 3000\n      DATA_DIR: /app/data\n    ports:\n      - \"127.0.0.1:3000:3000\"\n    volumes:\n      - nameflare-data:/app/data\n    stop_grace_period: 10s\nvolumes:\n  nameflare-data:\n",".env.example":"# Development: npm start. For an isolated sample-data demo: npm run demo.\nPORT=3000\nHOST=127.0.0.1\nPUBLIC_BASE_URL=http://localhost:3000\nDATA_DIR=./data\nNODE_ENV=development\n\n# Numeric Twitch ID of the service owner. Never a username.\nOWNER_TWITCH_ID=\n\n# Register exact callbacks: PUBLIC_BASE_URL/auth/{provider}/callback\nTWITCH_CLIENT_ID=\nTWITCH_CLIENT_SECRET=\nKICK_CLIENT_ID=\nKICK_CLIENT_SECRET=\nYOUTUBE_CLIENT_ID=\nYOUTUBE_CLIENT_SECRET=\n\n# Optional single-room server relay (Kick/YouTube); generate separate random secrets.\n# Ingest token is server-only. Room key is a private OBS read capability.\nOVERLAY_INGEST_TOKEN=\nOVERLAY_ROOM_KEY=\n\n# Optional public repository URL; lets hosted users obtain corresponding source.\nSOURCE_URL=\n",".gitignore":".env\n.env.*\n!.env.example\ndata/\ndata-demo/\nnode_modules/\n.wrangler/\n.dev.vars\n.dev.vars.*\ncoverage/\n*.sqlite\n*.sqlite-shm\n*.sqlite-wal\n*.log\n.DS_Store\n.freebuff/\n# Server access credentials must never be committed.\nssh-key-*.key\nssh-key-*.key.pub\n*.pem\n",".dockerignore":".env\n.env.*\n!.env.example\ndata\ndata-demo\n.git\n.freebuff\nnode_modules\n.wrangler\n.dev.vars\n.dev.vars.*\ncoverage\n*.log\n# Never send server access credentials to the build context.\nssh-key-*.key\nssh-key-*.key.pub\n*.pem\n"}}