diff --git a/doc/lucky1.png b/doc/lucky1.png deleted file mode 100644 index a5b41b6e..00000000 Binary files a/doc/lucky1.png and /dev/null differ diff --git a/doc/lucky2.png b/doc/lucky2.png deleted file mode 100644 index ba4c026f..00000000 Binary files a/doc/lucky2.png and /dev/null differ diff --git a/doc/lucky3.png b/doc/lucky3.png deleted file mode 100644 index 6fa7982f..00000000 Binary files a/doc/lucky3.png and /dev/null differ diff --git a/doc/taskplan1.png b/doc/taskplan1.png new file mode 100644 index 00000000..1f8db7be Binary files /dev/null and b/doc/taskplan1.png differ diff --git a/doc/taskplan2.png b/doc/taskplan2.png new file mode 100644 index 00000000..edb45d6d Binary files /dev/null and b/doc/taskplan2.png differ diff --git a/doc/taskplan3.png b/doc/taskplan3.png new file mode 100644 index 00000000..d4794702 Binary files /dev/null and b/doc/taskplan3.png differ diff --git a/doc/view.png b/doc/view.png new file mode 100644 index 00000000..169e112f Binary files /dev/null and b/doc/view.png differ diff --git a/doc/view2.png b/doc/view2.png new file mode 100644 index 00000000..9058c632 Binary files /dev/null and b/doc/view2.png differ diff --git a/luci-app-aurora-config/.dev/.npmrc b/luci-app-aurora-config/.dev/.npmrc new file mode 100644 index 00000000..7f59b89e --- /dev/null +++ b/luci-app-aurora-config/.dev/.npmrc @@ -0,0 +1,3 @@ +# Fail `pnpm install` early with a clear message on unsupported Node versions +# instead of letting the install die somewhere deep inside a dependency. +engine-strict=true diff --git a/luci-app-aurora-config/.dev/package.json b/luci-app-aurora-config/.dev/package.json new file mode 100644 index 00000000..28878df7 --- /dev/null +++ b/luci-app-aurora-config/.dev/package.json @@ -0,0 +1,19 @@ +{ + "name": "luci-app-aurora-config-dev", + "private": true, + "type": "module", + "description": "Build and test harness. htdocs/luci-static/resources is generated from src/resource by scripts/build-js.mjs -- never edit it by hand. Also pins the @eamonxg/luci-theme-tokens version that scripts/sync-tokens.mjs vendors (and lets Renovate/Dependabot propose bumps).", + "engines": { "node": ">=20" }, + "scripts": { + "build": "node scripts/build-js.mjs", + "check": "node scripts/build-js.mjs --check", + "test": "node scripts/build-js.mjs --check && node --test tests/*.test.mjs", + "sync-tokens": "node scripts/sync-tokens.mjs", + "gen-presets": "node scripts/gen-presets.mjs", + "gen-font-presets": "node scripts/gen-font-presets.mjs" + }, + "devDependencies": { + "@eamonxg/luci-theme-tokens": "2.0.0", + "terser": "^5.43.1" + } +} diff --git a/luci-app-aurora-config/.dev/pnpm-lock.yaml b/luci-app-aurora-config/.dev/pnpm-lock.yaml new file mode 100644 index 00000000..9877e3a1 --- /dev/null +++ b/luci-app-aurora-config/.dev/pnpm-lock.yaml @@ -0,0 +1,110 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eamonxg/luci-theme-tokens': + specifier: 2.0.0 + version: 2.0.0 + terser: + specifier: ^5.43.1 + version: 5.49.1 + +packages: + + '@eamonxg/luci-theme-tokens@2.0.0': + resolution: {integrity: sha512-LSPR6fW5iDN0r4eEfLLVirIgjJxUN8L1uJ3hZDHZRPbHtRE8v21pJSMhZoQKerCXUJx3spZc3W3r9f+baw3Jqw==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + colorjs.io@0.6.1: + resolution: {integrity: sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + terser@5.49.1: + resolution: {integrity: sha512-7A2xlQ5EnGT8KPA92dUh6RbRYTVw8hEaEN9L1K68l4UOXFuV511NnAqObGoRqGOQofQcMypisu1s3xawCEHrvA==} + engines: {node: '>=10'} + hasBin: true + +snapshots: + + '@eamonxg/luci-theme-tokens@2.0.0': + dependencies: + colorjs.io: 0.6.1 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + acorn@8.18.0: {} + + buffer-from@1.1.2: {} + + colorjs.io@0.6.1: {} + + commander@2.20.3: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + terser@5.49.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 diff --git a/luci-app-aurora-config/.dev/scripts/aurora-presets.json b/luci-app-aurora-config/.dev/scripts/aurora-presets.json new file mode 100644 index 00000000..91d111d6 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/aurora-presets.json @@ -0,0 +1,346 @@ +{ + "name": "@eamonxg/luci-theme-tokens", + "version": "2.0.0", + "presets": { + "default": { + "light": { + "bg": "#f7fafc", + "surface": "#fff", + "text": "#121a22", + "brand": "#0086bf", + "on_brand": "#fff", + "link": "#0086bf", + "info": "#0e5794", + "warning": "#653e00", + "success": "#00523c", + "danger": "#8d1925", + "text_muted": "#5f666d", + "text_subtle": "#7f858b", + "surface_sunken": "#f4f7fa", + "surface_overlay": "#fcffff", + "hairline": "#121a2221", + "hover_faint": "#ebeff2", + "brand_hover": "#0074ac", + "brand_subtle": "#dfecf5", + "brand_subtle_hover": "#d1dfe8", + "focus_ring": "#0186bf99", + "progress_start": "#70aed5", + "progress_end": "#0086bf", + "info_surface": "#d8f0ff", + "warning_surface": "#ffecd2", + "success_surface": "#d5f8e9", + "danger_surface": "#ffe6e4", + "danger_surface_hover": "#f9d9d7", + "control_bg": "#fcffff", + "scrim": "#0009", + "mega_menu_bg": "#f1f7fb", + "mega_menu_scrim": "#0d1c2a61" + }, + "dark": { + "bg": "#05070e", + "surface": "#141822", + "text": "#f9fafb", + "brand": "#00978f", + "on_brand": "#fff", + "link": "#00978f", + "info": "#8dc1ff", + "warning": "#f0ba59", + "success": "#51bd85", + "danger": "#f17070", + "text_muted": "#909297", + "text_subtle": "#5d6066", + "surface_sunken": "#0a0e17", + "surface_overlay": "#191d27", + "hairline": "#f9fafb1a", + "hover_faint": "#f9fafb0d", + "brand_hover": "#008880", + "brand_subtle": "#0a1a20", + "brand_subtle_hover": "#142329", + "focus_ring": "#00978f99", + "progress_start": "#166262", + "progress_end": "#00978f", + "info_surface": "#20344c", + "warning_surface": "#45310b", + "success_surface": "#153524", + "danger_surface": "#541f20", + "danger_surface_hover": "#602a2a", + "control_bg": "#0a0e17", + "scrim": "#0009", + "mega_menu_bg": "#0a0e17", + "mega_menu_scrim": "#00000080" + } + }, + "sage-green": { + "light": { + "bg": "#f8f7f4", + "surface": "#fff", + "text": "#1a1f2e", + "brand": "#7c9082", + "on_brand": "#fff", + "link": "#7c9082", + "info": "#0e5794", + "warning": "#653e00", + "success": "#00523c", + "danger": "#8d1925", + "text_muted": "#656973", + "text_subtle": "#84878f", + "surface_sunken": "#f8f7f3", + "surface_overlay": "#fdfcf9", + "hairline": "#1a1f2e21", + "hover_faint": "#f0eeea", + "brand_hover": "#6a7e70", + "brand_subtle": "#e8eae6", + "brand_subtle_hover": "#dbddd9", + "focus_ring": "#7c908299", + "progress_start": "#a6b3a8", + "progress_end": "#7c9082", + "info_surface": "#d8f0ff", + "warning_surface": "#ffecd2", + "success_surface": "#d5f8e9", + "danger_surface": "#ffe6e4", + "danger_surface_hover": "#f9d9d7", + "control_bg": "#fdfcf9", + "scrim": "#0009", + "mega_menu_bg": "#f8f6f0", + "mega_menu_scrim": "#0d1c2a61" + }, + "dark": { + "bg": "#0a0a0a", + "surface": "#121212", + "text": "#f5f5f5", + "brand": "#7c9082", + "on_brand": "#000", + "link": "#7c9082", + "info": "#8dc1ff", + "warning": "#f0ba59", + "success": "#51bd85", + "danger": "#f17070", + "text_muted": "#919191", + "text_subtle": "#616161", + "surface_sunken": "#090909", + "surface_overlay": "#161616", + "hairline": "#f5f5f51a", + "hover_faint": "#f5f5f50d", + "brand_hover": "#6d8173", + "brand_subtle": "#1a1c1a", + "brand_subtle_hover": "#232524", + "focus_ring": "#7c908299", + "progress_start": "#4f5b53", + "progress_end": "#7c9082", + "info_surface": "#20344c", + "warning_surface": "#45310b", + "success_surface": "#153524", + "danger_surface": "#541f20", + "danger_surface_hover": "#602a2a", + "control_bg": "#090909", + "scrim": "#0009", + "mega_menu_bg": "#090909", + "mega_menu_scrim": "#00000080" + } + }, + "amber-sand": { + "light": { + "bg": "#faf9f5", + "surface": "#faf9f5", + "text": "#3d3929", + "brand": "#c96442", + "on_brand": "#fff", + "link": "#c96442", + "info": "#0e5794", + "warning": "#653e00", + "success": "#00523c", + "danger": "#8d1925", + "text_muted": "#7f7c70", + "text_subtle": "#9a978c", + "surface_sunken": "#f8f7f3", + "surface_overlay": "#fffefa", + "hairline": "#3d392921", + "hover_faint": "#f0eeea", + "brand_hover": "#b55230", + "brand_subtle": "#f6e7df", + "brand_subtle_hover": "#e9dad2", + "focus_ring": "#c9644299", + "progress_start": "#dd9880", + "progress_end": "#c96442", + "info_surface": "#d8f0ff", + "warning_surface": "#ffecd2", + "success_surface": "#d5f8e9", + "danger_surface": "#ffe6e4", + "danger_surface_hover": "#f9d9d7", + "control_bg": "#fffefa", + "scrim": "#0009", + "mega_menu_bg": "#f7f6f0", + "mega_menu_scrim": "#0d1c2a61" + }, + "dark": { + "bg": "#262624", + "surface": "#30302e", + "text": "#c3c0b6", + "brand": "#d97757", + "on_brand": "#000", + "link": "#d97757", + "info": "#8dc1ff", + "warning": "#f0ba59", + "success": "#51bd85", + "danger": "#f17070", + "text_muted": "#83817a", + "text_subtle": "#63625d", + "surface_sunken": "#252523", + "surface_overlay": "#353533", + "hairline": "#c3c0b61a", + "hover_faint": "#c3c0b60d", + "brand_hover": "#c86848", + "brand_subtle": "#40332c", + "brand_subtle_hover": "#4b3d37", + "focus_ring": "#d9775799", + "progress_start": "#965a45", + "progress_end": "#d97757", + "info_surface": "#20344c", + "warning_surface": "#45310b", + "success_surface": "#153524", + "danger_surface": "#541f20", + "danger_surface_hover": "#602a2a", + "control_bg": "#252523", + "scrim": "#0009", + "mega_menu_bg": "#252523", + "mega_menu_scrim": "#00000080" + } + }, + "monochrome": { + "light": { + "bg": "#fcfcfc", + "surface": "#fff", + "text": "#000", + "brand": "#000", + "on_brand": "#fff", + "link": "#000", + "info": "#0e5794", + "warning": "#653e00", + "success": "#00523c", + "danger": "#8d1925", + "text_muted": "#414141", + "text_subtle": "#676767", + "surface_sunken": "#f7f7f7", + "surface_overlay": "#fff", + "hairline": "#00000021", + "hover_faint": "#eee", + "brand_hover": "#000", + "brand_subtle": "#d5d5d5", + "brand_subtle_hover": "#c8c8c8", + "focus_ring": "#0009", + "progress_start": "#383838", + "progress_end": "#000", + "info_surface": "#d8f0ff", + "warning_surface": "#ffecd2", + "success_surface": "#d5f8e9", + "danger_surface": "#ffe6e4", + "danger_surface_hover": "#f9d9d7", + "control_bg": "#fff", + "scrim": "#0009", + "mega_menu_bg": "#f6f6f6", + "mega_menu_scrim": "#0d1c2a61" + }, + "dark": { + "bg": "#000", + "surface": "#090909", + "text": "#fff", + "brand": "#fff", + "on_brand": "#000", + "link": "#fff", + "info": "#8dc1ff", + "warning": "#f0ba59", + "success": "#51bd85", + "danger": "#f17070", + "text_muted": "#868686", + "text_subtle": "#4d4d4d", + "surface_sunken": "#030303", + "surface_overlay": "#0d0d0d", + "hairline": "#ffffff1a", + "hover_faint": "#ffffff0d", + "brand_hover": "#eee", + "brand_subtle": "#0d0d0d", + "brand_subtle_hover": "#161616", + "focus_ring": "#fff9", + "progress_start": "#999", + "progress_end": "#fff", + "info_surface": "#20344c", + "warning_surface": "#45310b", + "success_surface": "#153524", + "danger_surface": "#541f20", + "danger_surface_hover": "#602a2a", + "control_bg": "#030303", + "scrim": "#0009", + "mega_menu_bg": "#030303", + "mega_menu_scrim": "#00000080" + } + }, + "sky-blue": { + "light": { + "bg": "#fff", + "surface": "#f7f8f8", + "text": "#0f1419", + "brand": "#1e9df1", + "on_brand": "#fff", + "link": "#1e9df1", + "info": "#0e5794", + "warning": "#653e00", + "success": "#00523c", + "danger": "#8d1925", + "text_muted": "#5f6367", + "text_subtle": "#808487", + "surface_sunken": "#f7f7f7", + "surface_overlay": "#fff", + "hairline": "#0f141921", + "hover_faint": "#eee", + "brand_hover": "#008add", + "brand_subtle": "#e8f4ff", + "brand_subtle_hover": "#dbe7f1", + "focus_ring": "#1e9df199", + "progress_start": "#7cbef5", + "progress_end": "#1e9df1", + "info_surface": "#d8f0ff", + "warning_surface": "#ffecd2", + "success_surface": "#d5f8e9", + "danger_surface": "#ffe6e4", + "danger_surface_hover": "#f9d9d7", + "control_bg": "#fff", + "scrim": "#0009", + "mega_menu_bg": "#f6f6f6", + "mega_menu_scrim": "#0d1c2a61" + }, + "dark": { + "bg": "#000", + "surface": "#17181c", + "text": "#e7e9ea", + "brand": "#1c9cf0", + "on_brand": "#fff", + "link": "#1c9cf0", + "info": "#8dc1ff", + "warning": "#f0ba59", + "success": "#51bd85", + "danger": "#f17070", + "text_muted": "#797a7a", + "text_subtle": "#454646", + "surface_sunken": "#0d0e12", + "surface_overlay": "#1c1d21", + "hairline": "#e7e9ea1a", + "hover_faint": "#e7e9ea0d", + "brand_hover": "#008cdf", + "brand_subtle": "#00040c", + "brand_subtle_hover": "#030c15", + "focus_ring": "#1c9cf099", + "progress_start": "#21669a", + "progress_end": "#1c9cf0", + "info_surface": "#20344c", + "warning_surface": "#45310b", + "success_surface": "#153524", + "danger_surface": "#541f20", + "danger_surface_hover": "#602a2a", + "control_bg": "#0d0e12", + "scrim": "#0009", + "mega_menu_bg": "#0d0e12", + "mega_menu_scrim": "#00000080" + } + } + } +} diff --git a/luci-app-aurora-config/.dev/scripts/build-js.mjs b/luci-app-aurora-config/.dev/scripts/build-js.mjs new file mode 100644 index 00000000..3fa6ef2d --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/build-js.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +// The ONLY writer of htdocs/luci-static/resources. Sources live in +// .dev/src/resource and are never shipped; this turns them into the bytes the +// router serves. uhttpd has no gzip -- there is no Content-Encoding anywhere in +// its source -- so these bytes are the router's flash-read and socket-write cost +// one for one. Rationale and measurements: +// docs/specs/2026-08-05-asset-pipeline-and-color-shim.md +import { readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { minify } from "terser"; + +const here = dirname(fileURLToPath(import.meta.url)); +const flag = (name, fallback) => { + const i = process.argv.indexOf(name); + return i !== -1 && process.argv[i + 1] ? resolve(process.argv[i + 1]) : fallback; +}; +const SRC = flag("--src", resolve(here, "../src/resource")); +const OUT = flag("--out", resolve(here, "../../htdocs/luci-static/resources")); +const check = process.argv.includes("--check"); + +// From luci-theme-aurora .dev/vite.config.ts. Every flag here is load-bearing: +// +// bare_returns a LuCI view's last statement is a top-level `return` +// directives:false its dependency declarations are string directives, which +// a directive-dropping compressor deletes +// +// And two options must stay at their DEFAULTS, which is why they are absent: +// mangle.toplevel and compress.toplevel. color.global.js and tokens.global.js +// publish themselves through a top-level `var Color` / `var AuroraTokens`; +// turning either on deletes the whole declaration -- measured, the output +// becomes an empty string -- and both pages white-screen with nothing logged. +// +// comments:false strips everything, so third-party licences are re-attached +// from .license sidecars below rather than preserved in place. +const TERSER = { + parse: { bare_returns: true }, + compress: { directives: false, passes: 2 }, + mangle: true, + format: { comments: false, beautify: false }, +}; + +async function walk(dir, base = dir) { + const found = []; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return found; + } + for (const entry of entries) { + // Hidden files are never sources and must never be shipped: .DS_Store rode + // along the first time this ran and would have been installed onto every + // router that upgraded. + if (entry.name.startsWith(".")) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...(await walk(full, base))); + else found.push(relative(base, full).split("\\").join("/")); + } + return found.sort(); +} + +// MIT and friends require the copyright and permission notice to travel with +// every copy. Vendored libraries carry theirs as plain // comments, which +// comments:false strips, so each such file gets a .license sidecar whose +// contents are re-attached verbatim as a banner. The sidecar is not itself an +// artifact -- it only ever appears inside the file it belongs to. +async function banner(rel) { + try { + const text = await readFile(join(SRC, `${rel}.license`), "utf8"); + return `/*!\n${text.trimEnd()}\n*/\n`; + } catch { + return ""; + } +} + +// `__ASSET_HASH()__` becomes the first 8 hex of that source file's +// sha256, so a resource we fetch by a URL we build ourselves can carry a +// version that changes if and only if its contents do. +// +// It has to happen here rather than being stamped into the source, because +// only this script reads every source file. The artifact stays a pure function +// of the sources, so --check is unaffected. +// +// Why it matters: uhttpd dates our installed files Last-Modified: epoch and +// sends no Cache-Control, so the heuristic freshness a browser computes runs to +// years -- it will not even revalidate. Resources LuCI `require`s get LuCI's +// own ?v=; the ones we fetch get this. +const ASSET_HASH = /__ASSET_HASH\(([^)]+)\)__/g; + +async function resolveAssetHashes(code, rel) { + const matches = [...code.matchAll(ASSET_HASH)]; + let out = code; + for (const [token, target] of matches) { + let contents; + try { + contents = await readFile(join(SRC, target), "utf8"); + } catch { + throw new Error(`__ASSET_HASH names ${target}, which is not a source`); + } + const hash = createHash("sha256").update(contents).digest("hex").slice(0, 8); + out = out.split(token).join(hash); + } + return out; +} + +async function build(rel) { + const source = await resolveAssetHashes( + await readFile(join(SRC, rel), "utf8"), + rel, + ); + if (rel.endsWith(".js")) { + // No sourceMap option at all: terser would otherwise be free to append a + // sourceMappingURL comment, and the map is not installed on the device. + const result = await minify(source, TERSER); + if (typeof result.code !== "string" || result.code.length === 0) + throw new Error(`${rel}: terser produced no output`); + return (await banner(rel)) + result.code; + } + if (rel.endsWith(".json")) return JSON.stringify(JSON.parse(source)); + return source; +} + +const sources = (await walk(SRC)).filter((rel) => !rel.endsWith(".license")); +if (sources.length === 0) { + console.error(`build-js: no sources under ${SRC}`); + process.exit(1); +} + +const built = new Map(); +for (const rel of sources) { + try { + built.set(rel, await build(rel)); + } catch (error) { + console.error(`build-js: ${rel}: ${error.message}`); + process.exit(1); + } +} + +if (check) { + const stale = []; + for (const [rel, content] of built) { + let current = null; + try { + current = await readFile(join(OUT, rel), "utf8"); + } catch {} + if (current !== content) stale.push(rel); + } + for (const rel of await walk(OUT)) + if (!built.has(rel)) stale.push(`${rel} (orphan: no source)`); + if (stale.length) { + console.error("build-js: artifacts are stale -- run `pnpm build` and commit:"); + for (const rel of stale) console.error(` ${rel}`); + process.exit(1); + } + console.log(`build-js: ${built.size} artifacts up to date`); +} else { + // Wipe first so a deleted source cannot leave a shipped artifact behind. + await rm(OUT, { recursive: true, force: true }); + let before = 0; + let after = 0; + for (const [rel, content] of built) { + const target = join(OUT, rel); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); + before += (await readFile(join(SRC, rel), "utf8")).length; + after += content.length; + } + console.log( + `build-js: ${built.size} files, ${before} -> ${after} chars ` + + `(${Math.round((after / before) * 100)}%)`, + ); +} diff --git a/luci-app-aurora-config/.dev/scripts/gen-font-presets.mjs b/luci-app-aurora-config/.dev/scripts/gen-font-presets.mjs new file mode 100644 index 00000000..709b6bb5 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/gen-font-presets.mjs @@ -0,0 +1,59 @@ +// Regenerates root/usr/share/aurora/font-presets.conf from the curated +// manifest below. Downloads every woff2 from jsDelivr to compute its sha256, +// so the router can verify integrity offline. Run: npm run gen-font-presets +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const OUT = fileURLToPath( + new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url), +); + +const SANS_TAIL = '"Lato", ui-sans-serif, system-ui, sans-serif'; +const MONO_TAIL = 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace'; + +// slot, name, label, source, family, stack, [pkg id, version, weights] +const MANIFEST = [ + ["sans", "default", "Lato", "Built-in", "Lato", `"Lato", ui-sans-serif, system-ui, sans-serif`], + ["sans", "system", "System UI", "System", "", `system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`], + ["sans", "geist-sans", "Geist Sans", "Fontsource", "Geist Sans", `"Geist Sans", ${SANS_TAIL}`, "geist-sans", "5.2.5", [400, 500, 600, 700]], + ["sans", "nunito", "Nunito", "Fontsource", "Nunito", `"Nunito", ${SANS_TAIL}`, "nunito", "5.2.7", [400, 500, 600, 700]], + ["sans", "space-grotesk", "Space Grotesk", "Fontsource", "Space Grotesk", `"Space Grotesk", ${SANS_TAIL}`, "space-grotesk", "5.2.10", [400, 500, 600, 700]], + ["mono", "default", "System Mono", "Built-in", "", MONO_TAIL], + ["mono", "jetbrains-mono", "JetBrains Mono", "Fontsource", "JetBrains Mono", `"JetBrains Mono", ${MONO_TAIL}`, "jetbrains-mono", "5.2.8", [400, 700]], + ["mono", "maple-mono", "Maple Mono", "Fontsource", "Maple Mono", `"Maple Mono", ${MONO_TAIL}`, "maple-mono", "5.2.6", [400, 700]], + ["mono", "fira-code", "Fira Code", "Fontsource", "Fira Code", `"Fira Code", ${MONO_TAIL}`, "fira-code", "5.2.7", [400, 700]], + ["mono", "cascadia-code", "Cascadia Code", "Fontsource", "Cascadia Code", `"Cascadia Code", ${MONO_TAIL}`, "cascadia-code", "5.2.3", [400, 700]], +]; + +const jsdelivr = (id, ver, w) => + `https://cdn.jsdelivr.net/npm/@fontsource/${id}@${ver}/files/${id}-latin-${w}-normal.woff2`; +const npmmirror = (id, ver, w) => + `https://registry.npmmirror.com/@fontsource/${id}/${ver}/files/files/${id}-latin-${w}-normal.woff2`; + +const lines = ["v2|generated-by-gen-font-presets|do-not-edit"]; +let total = 0; + +for (const [slot, name, label, source, family, stack, id, ver, weights] of MANIFEST) { + lines.push(`font|${slot}|${name}|${label}|${source}|${family}|${stack}`); + if (!id) continue; + if (weights.length > 4) + throw new Error(`${slot}/${name} exceeds 4-file budget: ${weights.length} weights`); + let presetBytes = 0; + for (const w of weights) { + const url = jsdelivr(id, ver, w); + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + presetBytes += buf.length; + const sha256 = createHash("sha256").update(buf).digest("hex"); + lines.push(`file|${slot}|${name}|${w}|${sha256}|${url}|${npmmirror(id, ver, w)}`); + total += 1; + } + if (presetBytes > 200 * 1024) + throw new Error(`${slot}/${name} exceeds 200KB budget: ${presetBytes} bytes`); + console.log(`${slot}/${name}: ${weights.length} files, ${(presetBytes / 1024).toFixed(1)}KB`); +} + +writeFileSync(OUT, lines.join("\n") + "\n"); +console.log(`Wrote ${OUT} (${total} file entries)`); diff --git a/luci-app-aurora-config/.dev/scripts/gen-pot.mjs b/luci-app-aurora-config/.dev/scripts/gen-pot.mjs new file mode 100644 index 00000000..4d7d4445 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/gen-pot.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Regenerate po/templates/aurora-config.pot from the translatable strings in +// this package. +// +// LuCI's own i18n-scan.pl lives in the OpenWrt build tree, which is not a +// dependency of this repo -- so the template used to be refreshed by hand and +// drifted from the source. This reproduces the same output: entries sorted by +// msgid, each preceded by "#:" references relative to the LuCI feed root. +// +// Two things the naive version of this script got wrong, both caught by +// diffing against the previous template: +// * _() calls routinely wrap across lines, so the scan runs over whole file +// text rather than line by line; +// * menu.d and acl.d titles/descriptions are translatable too, and LuCI's +// scanner picks them up from the JSON. + +import { readFile, writeFile, readdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Paths are held as repo-relative strings because they end up verbatim in the +// "#:" reference lines, but they are read through REPO so the script works from +// any working directory (it lives in .dev/scripts, two levels down). +const REPO = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const at = (relPath) => join(REPO, relPath); + +const PREFIX = "applications/luci-app-aurora-config"; +const OUT = "po/templates/aurora-config.pot"; +const JS_SCAN = ".dev/src/resource"; +// The "#:" references keep naming the installed path: that is where a reader +// of the shipped package finds the string, and holding it steady keeps this +// move out of the .pot and all 15 .po files. +const JS_REF = "htdocs/luci-static/resources"; +const JSON_ROOTS = ["root/usr/share/luci/menu.d", "root/usr/share/rpcd/acl.d"]; + +async function walk(dir, ext) { + const found = []; + for (const entry of await readdir(at(dir), { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...(await walk(path, ext))); + else if (entry.name.endsWith(ext)) found.push(path); + } + return found.sort(); +} + +// _("...") / _('...'), tolerating newlines and indentation after the paren. +// Concatenated expressions are deliberately not stitched together: LuCI's +// scanner does not either, and a string it cannot see whole is one a +// translator could not use whole. +const CALL = /\b_\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/g; + +const entries = new Map(); +const record = (raw, file, line) => { + // LuCI trims surrounding whitespace when it looks a string up, which is why + // no msgid in the existing catalogue carries a trailing space even though + // the source writes _("Theme: "). Emitting the untrimmed form would produce + // keys that never resolve, silently dropping those translations. + const msgid = raw.trim(); + if (!msgid) return; + if (!entries.has(msgid)) entries.set(msgid, new Set()); + entries.get(msgid).add(`${PREFIX}/${file}:${line}`); +}; + +for (const file of await walk(JS_SCAN, ".js")) { + const text = await readFile(at(file), "utf8"); + const ref = JS_REF + file.slice(JS_SCAN.length); + for (const match of text.matchAll(CALL)) { + const line = text.slice(0, match.index).split("\n").length; + record(match[2], ref, line); + } +} + +for (const root of JSON_ROOTS) { + for (const file of await walk(root, ".json")) { + const text = await readFile(at(file), "utf8"); + text.split("\n").forEach((line, index) => { + const match = /"(?:title|description)"\s*:\s*"((?:\\.|[^"\\])*)"/.exec(line); + if (match) record(match[1], file, index + 1); + }); + } +} + +const sorted = [...entries.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + +let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n'; +for (const msgid of sorted) { + out += "\n"; + for (const ref of [...entries.get(msgid)].sort()) out += `#: ${ref}\n`; + out += `msgid "${msgid}"\nmsgstr ""\n`; +} + +await writeFile(at(OUT), out); +console.log(`${OUT}: ${sorted.length} strings`); diff --git a/luci-app-aurora-config/.dev/scripts/gen-presets.mjs b/luci-app-aurora-config/.dev/scripts/gen-presets.mjs new file mode 100644 index 00000000..65ca9409 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/gen-presets.mjs @@ -0,0 +1,230 @@ +// Regenerate the preset templates under root/usr/share/aurora/ from +// scripts/aurora-presets.json -- resolved preset values precomputed at +// @eamonxg/luci-theme-tokens build time (inputs rounded to hex first, then +// derived, so stored == what the config UI recomputes; see the package's +// build.mjs). This script only formats and injects UCI option lines, and +// mirrors the preset data into the JS sources so the Marketplace can render +// the built-in preset cards offline (.dev/src/resource/aurora/presets.json). +// +// Only the colours come from the vendored json. A preset's layout and +// typography -- navigation shape, spacing, corner radius, content width, both +// font stacks, the toolbar switch -- live in the templates themselves and are +// read back out here: sync-tokens.mjs overwrites aurora-presets.json from the +// npm package on every sync, so anything this repo authors has to live +// somewhere that sync never touches. +// +// Zero dependencies / no build step. Run: node scripts/gen-presets.mjs + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +// here = .dev/scripts, so the repo root is two levels up. +const root = resolve(here, "../.."); +const PRESET_DIR = resolve(root, "root/usr/share/aurora"); +const SRC_PRESETS = resolve( + root, + ".dev/src/resource/aurora/presets.json", +); + +// The two groups a hub payload sorts these keys into (see build_share_payload +// in root/usr/libexec/rpcd/luci.aurora). The browser copy is emitted in that +// same shape, so the Marketplace reads a built-in preset with exactly the +// accessors it already uses for a shared configuration. +const LAYOUT_KEYS = [ + "nav_type", + "struct_spacing", + "struct_radius_base", + "struct_content_width_centered", + "toolbar_enabled", +]; +const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"]; + +// A hub payload's typography carries the roster id alongside the stack +// (build_share_payload emits both). The id is what says whether the family has +// to be downloaded before the preset looks the way it is drawn -- "default" +// and "system" resolve to faces already on the router, every other id is a +// Fontsource webfont. Recovered from the roster by stack, the same reverse +// lookup resolve_font_preset_id does on the device. +const FONT_PRESETS = resolve(PRESET_DIR, "font-presets.conf"); + +const fontIdByStack = () => { + const table = { sans: new Map(), mono: new Map() }; + for (const line of readFileSync(FONT_PRESETS, "utf8").split("\n")) { + const parts = line.split("|"); + if (parts[0] !== "font") continue; + const [, slot, id, , , , stack] = parts; + if (table[slot] && !table[slot].has(stack)) table[slot].set(stack, id); + } + return table; +}; + +const { presets } = JSON.parse( + readFileSync(resolve(here, "aurora-presets.json"), "utf8"), +); + +const colorLines = (preset) => { + const lines = []; + for (const mode of ["light", "dark"]) + for (const [key, hex] of Object.entries(presets[preset][mode])) + lines.push(`\toption ${mode}_${key} '${hex}'`); + return lines; +}; + +const isColorOptionLine = (line) => /^\toption (light|dark)_/.test(line); + +// `option ''` -> value. Only the first occurrence counts, the +// same way load_preset_struct_snapshot's awk pass takes it. +const readOption = (source, key) => { + const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source); + return match ? match[1] : null; +}; + +const fontIds = fontIdByStack(); + +const structureOf = (preset, source) => { + const pick = (keys) => + keys.reduce((acc, key) => { + const value = readOption(source, key); + if (value === null) + throw new Error( + `${preset}: template is missing option ${key} -- every built-in ` + + `preset is a whole look, not just a palette`, + ); + acc[key] = value; + return acc; + }, {}); + + const typography = pick(TYPOGRAPHY_KEYS); + for (const slot of ["sans", "mono"]) { + const stack = typography[`struct_font_${slot}`]; + const id = fontIds[slot].get(stack); + // A stack the roster does not know would leave the device unable to + // reverse-map it either, so the preset's typeface would silently degrade + // to the next family in the stack. Copy the roster line, do not retype it. + if (!id) + throw new Error( + `${preset}: struct_font_${slot} matches no ${slot} entry in ` + + `font-presets.conf -- copy the stack from its "font|${slot}|..." row`, + ); + typography[`font_${slot}`] = id; + } + + return { layout: pick(LAYOUT_KEYS), typography }; +}; + +const toolbarOf = (preset, source) => { + const items = []; + let current = null; + for (const raw of source.split("\n")) { + const line = raw.trim(); + if (/^config\s+toolbar_item\s*$/.test(line)) { + if (current) items.push(current); + current = { title: "", url: "", icon: "", enabled: "" }; + continue; + } + if (/^config\s/.test(line)) { + if (current) items.push(current); + current = null; + continue; + } + if (!current) continue; + const m = line.match(/^option\s+(title|url|icon|enabled)\s+(.*)$/); + if (!m) continue; + current[m[1]] = m[2].replace(/^'(.*)'$/, "$1").replace(/^"(.*)"$/, "$1"); + } + if (current) items.push(current); + + for (const item of items) { + if (!item.title || !item.url || !/^[01]$/.test(item.enabled)) + throw new Error( + `${preset}: a toolbar_item is missing title/url/enabled -- the device ` + + `rejects the whole preset on this (see load_preset_toolbar)`, + ); + } + return items.map(({ title, url, icon, enabled }) => + icon ? { title, url, icon, enabled } : { title, url, enabled }, + ); +}; + +// The four colours the Marketplace draws a card from -- marketplace.js SWATCH_KEYS. +// Kept in sync by builtin-presets.test.mjs, which parses that declaration and +// fails if this list drifts from it. +const SWATCH_KEYS = ["bg", "surface", "text", "brand"]; + +// Flat light_*/dark_* keys, the shape a hub payload's `colors` object uses -- +// but only the swatch keys. The templates keep all 62; this copy is downloaded +// by every visitor to the store and the store reads no others. Applying a +// built-in preset does not go through here at all: the browser sends a name and +// apply_theme_preset reads the full template on the router. +const flatColors = (preset) => { + const out = {}; + for (const mode of ["light", "dark"]) + for (const key of SWATCH_KEYS) { + const hex = presets[preset][mode][key]; + if (hex === undefined) + throw new Error(`${preset}: ${mode} is missing the swatch key ${key}`); + out[`${mode}_${key}`] = hex; + } + return out; +}; + +const browserPresets = {}; + +for (const preset of Object.keys(presets)) { + const templateFile = preset === "default" ? "default.template" : `${preset}.template`; + const path = resolve(PRESET_DIR, templateFile); + const lines = readFileSync(path, "utf8").split("\n"); + const out = []; + let injected = false; + for (const line of lines) { + if (isColorOptionLine(line)) { + if (!injected) { + out.push(...colorLines(preset)); + injected = true; + } + continue; + } + out.push(line); + } + if (!injected) throw new Error(`${preset}: no colour block found to replace`); + const source = out.join("\n"); + writeFileSync(path, source, "utf8"); + console.log(`gen-presets: wrote ${templateFile}`); + + const { layout, typography } = structureOf(preset, source); + browserPresets[preset] = { + colors: flatColors(preset), + layout, + typography, + toolbar: toolbarOf(preset, source), + }; +} + +// Browser copy for the Marketplace's built-in preset cards. Same data as the +// templates, projected into the hub's payload shape; regenerated together so +// the two can never drift. +mkdirSync(dirname(SRC_PRESETS), { recursive: true }); +const presetsJson = JSON.stringify({ presets: browserPresets }, null, 2) + "\n"; +writeFileSync(SRC_PRESETS, presetsJson, "utf8"); +console.log("gen-presets: wrote .dev/src/resource/aurora/presets.json"); + +// Stamp a content hash into marketplace.js, which appends it as ?v= when fetching +// the file above. uhttpd dates every static resource at the epoch and sends no +// Cache-Control, so a browser's heuristic freshness for it runs to years and it +// never revalidates -- without this stamp, a change here reaches nobody who has +// already opened the store. Same mechanism sync-tokens.mjs uses for the token +// engine; tests/builtin-presets.test.mjs checks the two agree. +const hash = createHash("sha256").update(presetsJson).digest("hex").slice(0, 8); +const stampLine = `const PRESETS_VERSION = "${hash}";`; +const galleryPath = resolve(root, ".dev/src/resource/view/aurora/marketplace.js"); +const gallery = readFileSync(galleryPath, "utf8").replace( + /const PRESETS_VERSION = "[^"]*";/, + stampLine, +); +if (!gallery.includes(stampLine)) + throw new Error("PRESETS_VERSION marker not found in marketplace.js"); +writeFileSync(galleryPath, gallery, "utf8"); +console.log(`gen-presets: stamped PRESETS_VERSION=${hash} into marketplace.js`); diff --git a/luci-app-aurora-config/.dev/scripts/merge-po.mjs b/luci-app-aurora-config/.dev/scripts/merge-po.mjs new file mode 100644 index 00000000..a95ffb11 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/merge-po.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Sync po//aurora-config.po against the regenerated template: drop +// entries the template no longer lists, append the ones it gained, and leave +// every existing translation untouched. +// +// Translations for the new strings come from translations.json next to this +// script. A language missing an entry leaves msgstr empty rather than shipping +// an English string dressed as a translation. + +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// This script lives in .dev/scripts; the catalogues it edits are at the repo +// root, so nothing here may depend on the working directory. +const here = dirname(fileURLToPath(import.meta.url)); +const at = (relPath) => join(here, "../..", relPath); + +const POT = at("po/templates/aurora-config.pot"); +const TABLE = join(here, "translations.json"); + +const parsePo = (text) => { + const entries = []; + let refs = []; + let msgid = null; + for (const line of text.split("\n")) { + if (line.startsWith("#:")) refs.push(line); + else if (line.startsWith("msgid ")) msgid = line.slice(7, -1); + else if (line.startsWith("msgstr ") && msgid !== null) { + entries.push({ refs, msgid, msgstr: line.slice(8, -1) }); + refs = []; + msgid = null; + } + } + return entries; +}; + +const potText = await readFile(POT, "utf8"); +const potEntries = parsePo(potText).filter((e) => e.msgid !== ""); +const table = JSON.parse(await readFile(TABLE, "utf8")); + +const langs = [ + "de", "es", "fr", "id", "it", "ja", "ko", + "nl", "pl", "ru", "tr", "uk", "zh_Hans", "zh_Hant", +]; + +for (const lang of langs) { + const path = at(`po/${lang}/aurora-config.po`); + const existing = new Map( + parsePo(await readFile(path, "utf8")) + .filter((e) => e.msgid !== "") + .map((e) => [e.msgid, e.msgstr]), + ); + + let out = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8"\n'; + let added = 0; + let untranslated = 0; + + for (const entry of potEntries) { + // An entry that exists but was never filled in counts as missing: a blank + // msgstr ships the English string, which is exactly what the table is for. + let msgstr = existing.get(entry.msgid); + if (!msgstr) { + if (msgstr === undefined) added += 1; + msgstr = table[entry.msgid]?.[lang] ?? ""; + if (!msgstr) untranslated += 1; + } + out += "\n"; + for (const ref of entry.refs) out += `${ref}\n`; + out += `msgid "${entry.msgid}"\nmsgstr "${msgstr}"\n`; + } + + await writeFile(path, out); + const dropped = [...existing.keys()].filter( + (id) => !potEntries.some((e) => e.msgid === id), + ).length; + console.log( + `${lang}: ${potEntries.length} entries (+${added} new, -${dropped} obsolete` + + (untranslated ? `, ${untranslated} UNTRANSLATED` : "") + + ")", + ); +} diff --git a/luci-app-aurora-config/.dev/scripts/sync-tokens.mjs b/luci-app-aurora-config/.dev/scripts/sync-tokens.mjs new file mode 100644 index 00000000..fd49bca3 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/sync-tokens.mjs @@ -0,0 +1,138 @@ +// Vendor the color-token artifacts from the @eamonxg/luci-theme-tokens npm +// package (renamed from @eamonxg/aurora-tokens; published from the +// luci-theme-tokens repo). Everything here is a plain copy -- all derivation +// happens upstream at package build time: +// - .dev/src/resource/utils/tokens.global.js +// - root/usr/share/aurora/color-tokens.conf +// - .dev/scripts/aurora-presets.json (resolved preset hex values) +// then stamps TOKENS_ENGINE_VERSION into view/aurora/studio.js and reruns +// scripts/gen-presets.mjs. All vendored artifacts are committed; nothing +// downstream (CI, SDK build, device) needs npm or the package repo. +// +// Source resolution: +// 1. --local a local luci-theme-tokens checkout (run `npm install` +// + `node build.mjs` there first) +// 2. npm registry the version pinned in package.json devDependencies +// 3. sibling checkout ../luci-theme-tokens, when the registry is unreachable +// +// Usage: node scripts/sync-tokens.mjs [--local ] +// node scripts/sync-tokens.mjs --check exit 1 if outputs are stale +// +// Zero dependencies, same as gen-presets.mjs. + +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, dirname, relative, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const here = dirname(fileURLToPath(import.meta.url)); +// here = .dev/scripts, so the repo root is two levels up. +const root = resolve(here, "../.."); +const PACKAGE = "@eamonxg/luci-theme-tokens"; +const check = process.argv.includes("--check"); + +const argValue = (flag) => { + const index = process.argv.indexOf(flag); + return index !== -1 ? process.argv[index + 1] : null; +}; + +const pinnedVersion = () => + JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8")) + .devDependencies[PACKAGE]; + +const buildCheckout = (checkout) => { + const built = spawnSync(process.execPath, [resolve(checkout, "build.mjs")], { + stdio: "inherit", + }); + if (built.status !== 0) + throw new Error(`building ${checkout} failed -- run \`npm install\` there first`); + return checkout; +}; + +const fetchFromRegistry = async (version) => { + const tarballUrl = `https://registry.npmjs.org/${PACKAGE}/-/${PACKAGE.split("/")[1]}-${version}.tgz`; + const response = await fetch(tarballUrl); + if (!response.ok) throw new Error(`fetch ${tarballUrl}: HTTP ${response.status}`); + const dir = mkdtempSync(join(tmpdir(), "luci-theme-tokens-")); + writeFileSync(join(dir, "package.tgz"), Buffer.from(await response.arrayBuffer())); + const extracted = spawnSync("tar", ["-xzf", join(dir, "package.tgz"), "-C", dir], { + stdio: "inherit", + }); + if (extracted.status !== 0) throw new Error("tar extraction failed"); + return join(dir, "package"); +}; + +async function loadPackage() { + const explicit = argValue("--local"); + if (explicit) { + console.log(`sync-tokens: source = local checkout ${explicit}`); + return buildCheckout(explicit); + } + const version = pinnedVersion(); + try { + const dir = await fetchFromRegistry(version); + console.log(`sync-tokens: source = npm ${PACKAGE}@${version}`); + return dir; + } catch (error) { + const sibling = resolve(root, "../luci-theme-tokens"); + if (existsSync(join(sibling, "build.mjs"))) { + console.warn( + `sync-tokens: WARNING registry unavailable (${error.message}); ` + + `falling back to sibling checkout -- output may not match the pinned ${version}`, + ); + return buildCheckout(sibling); + } + throw error; + } +} + +const pkgDir = await loadPackage(); +const dist = (f) => readFileSync(join(pkgDir, "dist/aurora", f), "utf8"); +const version = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")).version; + +// Stamp the engine version into theme.js: it appends ?v= when loading +// tokens.global.js so a browser can never pair new theme.js with a cached engine. +const THEME_VIEW = resolve(root, ".dev/src/resource/view/aurora/studio.js"); +const versionLine = `const TOKENS_ENGINE_VERSION = "${version}";`; +const themeSource = readFileSync(THEME_VIEW, "utf8").replace( + /const TOKENS_ENGINE_VERSION = "[^"]*";/, + versionLine, +); +if (!themeSource.includes(versionLine)) + throw new Error("TOKENS_ENGINE_VERSION marker not found in theme.js"); + +const outputs = [ + [resolve(root, ".dev/src/resource/utils/tokens.global.js"), dist("tokens.global.js")], + [resolve(root, "root/usr/share/aurora/color-tokens.conf"), dist("color-tokens.conf")], + [resolve(here, "aurora-presets.json"), dist("presets.json")], + [THEME_VIEW, themeSource], +]; + +let stale = false; +for (const [path, content] of outputs) { + const name = relative(root, path); + let current = null; + try { + current = readFileSync(path, "utf8"); + } catch {} + if (current === content) { + console.log(`sync-tokens: ${name} up to date`); + continue; + } + if (check) { + console.error(`sync-tokens: ${name} is STALE -- run: node scripts/sync-tokens.mjs`); + stale = true; + continue; + } + writeFileSync(path, content, "utf8"); + console.log(`sync-tokens: wrote ${name}`); +} + +if (check) process.exit(stale ? 1 : 0); + +// Preset templates embed resolved preset values; refresh them from the JSON. +const presets = spawnSync(process.execPath, [resolve(here, "gen-presets.mjs")], { + stdio: "inherit", +}); +process.exit(presets.status ?? 1); diff --git a/luci-app-aurora-config/.dev/scripts/translations.json b/luci-app-aurora-config/.dev/scripts/translations.json new file mode 100644 index 00000000..f16d37e2 --- /dev/null +++ b/luci-app-aurora-config/.dev/scripts/translations.json @@ -0,0 +1,3990 @@ +{ + "%d included, %s": { + "de": "%d enthalten, %s", + "es": "%d incluidos, %s", + "fr": "%d inclus, %s", + "id": "%d disertakan, %s", + "it": "%d inclusi, %s", + "ja": "%d 個を含む、%s", + "ko": "%d개 포함됨, %s", + "nl": "%d inbegrepen, %s", + "pl": "%d uwzględnionych, %s", + "ru": "%d включено, %s", + "tr": "%d dahil, %s", + "uk": "%d включено, %s", + "zh_Hans": "包含 %d 个,共 %s", + "zh_Hant": "包含 %d 個,共 %s" + }, + "%d not shared — only PNG and SVG under %s travel with a theme": { + "de": "%d werden nicht geteilt — nur PNG und SVG unter %s reisen mit einem Theme mit", + "es": "%d no se comparten: solo los PNG y SVG de menos de %s viajan con un tema", + "fr": "%d ne sont pas partagées — seuls les PNG et SVG de moins de %s accompagnent un thème", + "id": "%d tidak dibagikan — hanya PNG dan SVG di bawah %s yang ikut dengan tema", + "it": "%d non condivise — solo PNG e SVG sotto %s viaggiano con un tema", + "ja": "%d 個は共有されません — テーマと一緒に送れるのは %s 未満の PNG と SVG だけです", + "ko": "%d개는 공유되지 않음 — %s 미만의 PNG와 SVG만 테마와 함께 전달됩니다", + "nl": "%d worden niet gedeeld — alleen PNG en SVG onder %s reizen met een thema mee", + "pl": "%d nie zostaną udostępnione — z motywem podróżują tylko pliki PNG i SVG poniżej %s", + "ru": "%d не публикуются — вместе с темой передаются только PNG и SVG меньше %s", + "tr": "%d paylaşılmıyor — bir temayla yalnızca %s altındaki PNG ve SVG dosyaları gider", + "uk": "%d не буде опубліковано — разом із темою передаються лише PNG і SVG менші за %s", + "zh_Hans": "%d 个发不出去 —— 只有小于 %s 的 PNG 和 SVG 会随主题一起走", + "zh_Hant": "%d 個無法分享 —— 只有小於 %s 的 PNG 和 SVG 會隨佈景主題一起走" + }, + "%d shortcuts": { + "de": "%d Verknüpfungen", + "es": "%d accesos directos", + "fr": "%d raccourcis", + "id": "%d pintasan", + "it": "%d scorciatoie", + "ja": "%d 件のショートカット", + "ko": "바로가기 %d개", + "nl": "%d snelkoppelingen", + "pl": "%d skrótów", + "ru": "%d ярлыков", + "tr": "%d kısayol", + "uk": "%d ярликів", + "zh_Hans": "%d 个快捷方式", + "zh_Hant": "%d 個捷徑" + }, + "(disabled)": { + "de": "(deaktiviert)", + "es": "(desactivado)", + "fr": "(désactivé)", + "id": "(nonaktif)", + "it": "(disattivato)", + "ja": "(無効)", + "ko": "(사용 안 함)", + "nl": "(uitgeschakeld)", + "pl": "(wyłączony)", + "ru": "(отключён)", + "tr": "(devre dışı)", + "uk": "(вимкнено)", + "zh_Hans": "(已关闭)", + "zh_Hant": "(已關閉)" + }, + "1 shortcut": { + "de": "1 Verknüpfung", + "es": "1 acceso directo", + "fr": "1 raccourci", + "id": "1 pintasan", + "it": "1 scorciatoia", + "ja": "ショートカット 1 件", + "ko": "바로가기 1개", + "nl": "1 snelkoppeling", + "pl": "1 skrót", + "ru": "1 ярлык", + "tr": "1 kısayol", + "uk": "1 ярлик", + "zh_Hans": "1 个快捷方式", + "zh_Hant": "1 個捷徑" + }, + "Add": { + "de": "Hinzufügen", + "es": "Añadir", + "fr": "Ajouter", + "id": "Tambahkan", + "it": "Aggiungi", + "ja": "追加", + "ko": "추가", + "nl": "Toevoegen", + "pl": "Dodaj", + "ru": "Добавить", + "tr": "Ekle", + "uk": "Додати", + "zh_Hans": "添加", + "zh_Hant": "新增" + }, + "Add the Aurora update source": { + "de": "Aurora-Update-Quelle hinzufügen", + "es": "Añadir la fuente de actualizaciones de Aurora", + "fr": "Ajouter la source de mise à jour Aurora", + "id": "Tambahkan sumber pembaruan Aurora", + "it": "Aggiungi la sorgente aggiornamenti di Aurora", + "ja": "Aurora の更新元を追加", + "ko": "Aurora 업데이트 소스 추가", + "nl": "Aurora-updatebron toevoegen", + "pl": "Dodaj źródło aktualizacji Aurora", + "ru": "Добавить источник обновлений Aurora", + "tr": "Aurora güncelleme kaynağını ekle", + "uk": "Додати джерело оновлень Aurora", + "zh_Hans": "添加 Aurora 更新源", + "zh_Hant": "新增 Aurora 更新來源" + }, + "Add update source": { + "de": "Update-Quelle hinzufügen", + "es": "Añadir fuente de actualizaciones", + "fr": "Ajouter la source de mise à jour", + "id": "Tambahkan sumber pembaruan", + "it": "Aggiungi sorgente aggiornamenti", + "ja": "更新元を追加", + "ko": "업데이트 소스 추가", + "nl": "Updatebron toevoegen", + "pl": "Dodaj źródło aktualizacji", + "ru": "Добавить источник обновлений", + "tr": "Güncelleme kaynağı ekle", + "uk": "Додати джерело оновлень", + "zh_Hans": "添加更新源", + "zh_Hant": "新增更新來源" + }, + "Adding the update source…": { + "de": "Update-Quelle wird hinzugefügt …", + "es": "Añadiendo la fuente de actualizaciones…", + "fr": "Ajout de la source de mise à jour…", + "id": "Menambahkan sumber pembaruan…", + "it": "Aggiunta della sorgente aggiornamenti…", + "ja": "更新元を追加しています…", + "ko": "업데이트 소스를 추가하는 중…", + "nl": "Updatebron wordt toegevoegd…", + "pl": "Dodawanie źródła aktualizacji…", + "ru": "Добавление источника обновлений…", + "tr": "Güncelleme kaynağı ekleniyor…", + "uk": "Додавання джерела оновлень…", + "zh_Hans": "正在添加更新源…", + "zh_Hant": "正在新增更新來源…" + }, + "Afterwards you can upgrade Aurora in System → Software, like any other OpenWrt package.": { + "de": "Danach können Sie Aurora unter System → Software aktualisieren, wie jedes andere OpenWrt-Paket.", + "es": "Después podrá actualizar Aurora en Sistema → Software, como cualquier otro paquete de OpenWrt.", + "fr": "Vous pourrez ensuite mettre à jour Aurora dans Système → Logiciels, comme tout autre paquet OpenWrt.", + "id": "Setelah itu Anda dapat memperbarui Aurora di Sistem → Perangkat Lunak, seperti paket OpenWrt lainnya.", + "it": "Dopodiché potrai aggiornare Aurora da Sistema → Software, come qualsiasi altro pacchetto OpenWrt.", + "ja": "その後は、他の OpenWrt パッケージと同じように システム → ソフトウェア から Aurora を更新できます。", + "ko": "그 후에는 다른 OpenWrt 패키지와 마찬가지로 시스템 → 소프트웨어에서 Aurora를 업그레이드할 수 있습니다.", + "nl": "Daarna kunt u Aurora bijwerken via Systeem → Software, net als elk ander OpenWrt-pakket.", + "pl": "Następnie możesz aktualizować Aurorę w System → Oprogramowanie, jak każdy inny pakiet OpenWrt.", + "ru": "После этого вы сможете обновлять Aurora в разделе Система → Программы, как любой другой пакет OpenWrt.", + "tr": "Ardından Aurora'yı diğer OpenWrt paketleri gibi Sistem → Yazılım üzerinden güncelleyebilirsiniz.", + "uk": "Після цього ви зможете оновлювати Aurora в розділі Система → Програми, як і будь-який інший пакунок OpenWrt.", + "zh_Hans": "之后就能在「系统 → 软件包」里升级 Aurora,和升级其他 OpenWrt 软件一样。", + "zh_Hant": "之後就能在「系統 → 軟體套件」裡升級 Aurora,和升級其他 OpenWrt 軟體一樣。" + }, + "Anyone who applies this gets all of it. You can update or remove it later.": { + "de": "Wer dies anwendet, erhält alles davon. Sie können es später aktualisieren oder entfernen.", + "es": "Cualquiera que aplique esto obtendrá todo. Podrá actualizarlo o eliminarlo más adelante.", + "fr": "Quiconque applique ceci obtient l'ensemble. Vous pourrez le mettre à jour ou le supprimer plus tard.", + "id": "Siapa pun yang menerapkan ini akan mendapatkan semuanya. Anda bisa memperbarui atau menghapusnya nanti.", + "it": "Chiunque applichi questa configurazione ottiene tutto quanto sopra. Potrai aggiornarla o rimuoverla in seguito.", + "ja": "これを適用した人は、上記すべてを受け取ります。後から更新したり削除したりできます。", + "ko": "이것을 적용하는 사람은 전부 그대로 받습니다. 나중에 업데이트하거나 삭제할 수 있습니다.", + "nl": "Iedereen die dit toepast, krijgt dit allemaal. U kunt het later bijwerken of verwijderen.", + "pl": "Każdy, kto to zastosuje, otrzyma wszystko powyżej. Możesz to później zaktualizować lub usunąć.", + "ru": "Тот, кто применит это, получит всё перечисленное. Позже вы сможете это обновить или удалить.", + "tr": "Bunu uygulayan herkes hepsini alır. Daha sonra güncelleyebilir veya kaldırabilirsiniz.", + "uk": "Той, хто застосує це, отримає все перелічене. Пізніше ви зможете це оновити або видалити.", + "zh_Hans": "应用这套配置的人会连同上面这些一起装上。你随时可以更新或删除。", + "zh_Hant": "套用這套配置的人會連同上面這些一起裝上。你隨時可以更新或刪除。" + }, + "App Icon": { + "de": "App-Symbol", + "es": "Icono de la app", + "fr": "Icône de l'application", + "id": "Ikon aplikasi", + "it": "Icona dell'app", + "ja": "アプリアイコン", + "ko": "앱 아이콘", + "nl": "App-pictogram", + "pl": "Ikona aplikacji", + "ru": "Значок приложения", + "tr": "Uygulama simgesi", + "uk": "Значок застосунку", + "zh_Hans": "应用图标", + "zh_Hant": "應用程式圖示" + }, + "Apply My Configuration": { + "de": "Meine Konfiguration anwenden", + "es": "Aplicar mi configuración", + "fr": "Appliquer ma configuration", + "id": "Terapkan konfigurasi saya", + "it": "Applica la mia configurazione", + "ja": "自分の設定を適用", + "ko": "내 설정 적용", + "nl": "Mijn configuratie toepassen", + "pl": "Zastosuj moją konfigurację", + "ru": "Применить мою конфигурацию", + "tr": "Yapılandırmamı uygula", + "uk": "Застосувати мою конфігурацію", + "zh_Hans": "应用我的配置", + "zh_Hant": "套用我的設定" + }, + "Apply the '%s' preset now? It is saved immediately and the page reloads. A preset carries a whole look — colors, navigation, spacing, corner radius, content width and fonts. It brings no images of its own, so any that arrived with a theme from the store are removed. Your shortcuts, and anything you uploaded yourself, are left as they are.": { + "de": "Das Preset „%s“ jetzt anwenden? Es wird sofort gespeichert und die Seite neu geladen. Ein Preset bringt ein komplettes Erscheinungsbild mit – Farben, Navigation, Abstände, Eckenradius, Inhaltsbreite und Schriften. Eigene Bilder bringt es nicht mit, deshalb werden alle Bilder entfernt, die mit einem Theme aus dem Store gekommen sind. Ihre Verknüpfungen und alles, was Sie selbst hochgeladen haben, bleiben unverändert.", + "es": "¿Aplicar el preset «%s» ahora? Se guarda de inmediato y la página se recarga. Un preset trae un aspecto completo: colores, navegación, espaciado, radio de esquinas, ancho del contenido y fuentes. No trae imágenes propias, así que se eliminan las que llegaron con un tema de la tienda. Tus accesos directos, y todo lo que hayas subido tú, se quedan como están.", + "fr": "Appliquer le préréglage « %s » maintenant ? Il est enregistré immédiatement et la page se recharge. Un préréglage apporte une apparence complète : couleurs, navigation, espacement, rayon des coins, largeur du contenu et polices. Il n'apporte aucune image, donc celles arrivées avec un thème de la boutique sont supprimées. Vos raccourcis, et tout ce que vous avez importé vous-même, restent tels quels.", + "id": "Terapkan preset '%s' sekarang? Langsung disimpan dan halaman dimuat ulang. Preset membawa satu tampilan utuh — warna, navigasi, jarak, radius sudut, lebar konten, dan font. Preset tidak membawa gambar apa pun, jadi gambar yang datang bersama tema dari toko akan dihapus. Pintasan Anda, dan apa pun yang Anda unggah sendiri, dibiarkan apa adanya.", + "it": "Applicare il preset «%s» adesso? Viene salvato subito e la pagina si ricarica. Un preset porta un aspetto completo: colori, navigazione, spaziatura, raggio degli angoli, larghezza del contenuto e caratteri. Non porta immagini proprie, quindi quelle arrivate con un tema dallo store vengono rimosse. Le tue scorciatoie, e tutto ciò che hai caricato tu, restano come sono.", + "ja": "プリセット「%s」を適用しますか?すぐに保存され、ページが再読み込みされます。プリセットは配色・ナビゲーション・余白・角丸・コンテンツ幅・フォントという外観一式をまとめて持ってきます。画像は一切持っていないため、ストアのテーマと一緒に入ってきた画像は削除されます。ショートカットと、ご自身でアップロードしたものはそのまま残ります。", + "ko": "'%s' 프리셋을 지금 적용할까요? 즉시 저장되고 페이지가 새로 고쳐집니다. 프리셋은 색상, 내비게이션, 간격, 모서리 반경, 콘텐츠 너비, 글꼴까지 외형 한 벌을 통째로 가져옵니다. 자체 이미지는 없으므로 스토어 테마와 함께 들어온 이미지는 모두 제거됩니다. 바로가기와 직접 업로드한 항목은 그대로 유지됩니다.", + "nl": "Preset '%s' nu toepassen? Het wordt meteen opgeslagen en de pagina wordt herladen. Een preset brengt een compleet uiterlijk mee: kleuren, navigatie, tussenruimte, hoekradius, contentbreedte en lettertypen. Het brengt zelf geen afbeeldingen mee, dus alles wat met een thema uit de winkel is meegekomen wordt verwijderd. Uw snelkoppelingen, en alles wat u zelf hebt geüpload, blijven zoals ze zijn.", + "pl": "Zastosować preset „%s” teraz? Zostanie zapisany od razu, a strona przeładowana. Preset niesie cały wygląd — kolory, nawigację, odstępy, promień zaokrągleń, szerokość treści i czcionki. Nie ma własnych obrazów, więc te, które przyszły z motywem ze sklepu, zostaną usunięte. Twoje skróty oraz wszystko, co wgrałeś samodzielnie, pozostaną bez zmian.", + "ru": "Применить пресет «%s» сейчас? Он сохранится сразу, страница перезагрузится. Пресет несёт целый облик — цвета, навигацию, отступы, радиус скругления, ширину контента и шрифты. Своих изображений он не несёт, поэтому всё, что пришло вместе с темой из магазина, будет удалено. Ваши ярлыки и всё, что вы загрузили сами, останутся как есть.", + "tr": "'%s' ön ayarı şimdi uygulansın mı? Hemen kaydedilir ve sayfa yeniden yüklenir. Bir ön ayar görünümün tamamını getirir: renkler, gezinme, boşluk, köşe yarıçapı, içerik genişliği ve yazı tipleri. Kendine ait görsel getirmez, bu yüzden mağazadan gelen bir temayla birlikte inen görseller kaldırılır. Kısayollarınız ve kendi yüklediğiniz her şey olduğu gibi kalır.", + "uk": "Застосувати пресет «%s» зараз? Він збережеться одразу, сторінку буде перезавантажено. Пресет несе цілий вигляд — кольори, навігацію, відступи, радіус заокруглення, ширину вмісту та шрифти. Власних зображень він не несе, тож усе, що прийшло разом із темою з магазину, буде вилучено. Ваші ярлики та все, що ви завантажили самі, лишаться як є.", + "zh_Hans": "现在应用「%s」预设吗?会立即保存并重新加载页面。预设带来的是一整套外观——配色、导航、间距、圆角、内容宽度和字体。它自己不带任何图片,所以随商店主题装进来的图片都会被移除。你的快捷方式,以及你自己上传的图片,都保持原样。", + "zh_Hant": "現在套用「%s」預設嗎?會立即儲存並重新載入頁面。預設帶來的是一整套外觀——配色、導覽、間距、圓角、內容寬度和字型。它自己不帶任何圖片,所以隨商店主題裝進來的圖片都會被移除。你的捷徑,以及你自己上傳的圖片,都保持原樣。" + }, + "Apply the '%s' preset now? It is saved immediately and the page reloads. A preset carries a whole look — colors, navigation, spacing, corner radius, content width and fonts. Your shortcuts, logo, icons and login background are left as they are.": { + "de": "Das Preset '%s' jetzt anwenden? Es wird sofort gespeichert und die Seite neu geladen. Ein Preset bringt ein komplettes Erscheinungsbild mit — Farben, Navigation, Abstände, Eckenradius, Inhaltsbreite und Schriften. Verknüpfungen, Logo, Symbole und Login-Hintergrund bleiben unverändert.", + "es": "¿Aplicar el preajuste '%s' ahora? Se guardará de inmediato y la página se recargará. Un preajuste trae un aspecto completo: colores, navegación, espaciado, radio de esquina, ancho del contenido y fuentes. Tus accesos directos, logotipo, iconos y fondo de inicio de sesión se mantienen tal cual.", + "fr": "Appliquer le préréglage '%s' maintenant ? Il sera enregistré immédiatement et la page sera rechargée. Un préréglage apporte une apparence complète : couleurs, navigation, espacement, rayon des coins, largeur du contenu et polices. Vos raccourcis, votre logo, vos icônes et l'arrière-plan de connexion restent inchangés.", + "id": "Terapkan preset '%s' sekarang? Preset langsung disimpan dan halaman dimuat ulang. Sebuah preset membawa tampilan utuh — warna, navigasi, jarak, radius sudut, lebar konten, dan font. Pintasan, logo, ikon, dan latar belakang login Anda dibiarkan apa adanya.", + "it": "Applicare il preset '%s' adesso? Verrà salvato subito e la pagina si ricaricherà. Un preset porta un aspetto completo: colori, navigazione, spaziatura, raggio dell'angolo, larghezza contenuto e font. Le tue scorciatoie, il logo, le icone e lo sfondo del login restano invariati.", + "ja": "プリセット「%s」を今すぐ適用しますか?すぐに保存され、ページが再読み込みされます。プリセットは外観一式(カラー、ナビゲーション、間隔、角丸の半径、コンテンツ幅、フォント)を設定します。ショートカット、ロゴ、アイコン、ログイン背景はそのまま残ります。", + "ko": "지금 '%s' 프리셋을 적용하시겠습니까? 즉시 저장되고 페이지가 새로고침됩니다. 프리셋은 색상, 탐색, 간격, 모서리 둥글기, 콘텐츠 너비, 글꼴까지 외관 전체를 바꿉니다. 바로 가기, 로고, 아이콘, 로그인 배경은 그대로 유지됩니다.", + "nl": "Preset '%s' nu toepassen? Deze wordt meteen opgeslagen en de pagina wordt herladen. Een preset bevat een compleet uiterlijk — kleuren, navigatie, ruimte, hoekstraal, inhoudsbreedte en lettertypen. Je snelkoppelingen, logo, pictogrammen en inlogachtergrond blijven zoals ze zijn.", + "pl": "Zastosować ustawienie '%s' teraz? Zostanie od razu zapisane, a strona przeładowana. Ustawienie zmienia cały wygląd — kolory, nawigację, odstępy, promień zaokrąglenia rogów, szerokość treści i czcionki. Twoje skróty, logo, ikony i tło logowania pozostają bez zmian.", + "ru": "Применить пресет «%s» сейчас? Он сохранится сразу, а страница перезагрузится. Пресет задаёт весь внешний вид — цвета, навигацию, отступы, радиус скругления углов, ширину контента и шрифты. Ваши ярлыки, логотип, значки и фон входа остаются без изменений.", + "tr": "'%s' hazır ayarı şimdi uygulansın mı? Hemen kaydedilir ve sayfa yeniden yüklenir. Bir hazır ayar görünümün tamamını taşır: renkler, gezinme, boşluk, köşe yarıçapı, içerik genişliği ve yazı tipleri. Kısayollarınız, logonuz, simgeleriniz ve giriş arka planınız olduğu gibi kalır.", + "uk": "Застосувати пресет «%s» зараз? Його буде збережено одразу, а сторінку перезавантажено. Пресет задає весь вигляд — кольори, навігацію, відступи, радіус скруглення кутів, ширину вмісту та шрифти. Ваші ярлики, логотип, значки та фон входу залишаються незмінними.", + "zh_Hans": "立即应用“%s”预设吗?这将立即保存并重新加载页面。预设带的是一整套外观——配色、导航、间距、圆角半径、内容宽度和字体。你的快捷方式、Logo、图标和登录背景保持不变。", + "zh_Hant": "立即套用「%s」預設組嗎?系統會立即儲存並重新載入頁面。預設組帶的是一整套外觀——配色、導覽、間距、圓角半徑、內容寬度與字型。你的捷徑、Logo、圖示與登入背景維持不變。" + }, + "Apply the '%s' preset now? It is saved immediately and the page reloads. A preset carries a whole look — colors, navigation, spacing, corner radius, content width, fonts and shortcuts. Whatever the theme you are wearing now brought with it is replaced; images you uploaded yourself stay as they are.": { + "de": "Das Preset „%s“ jetzt anwenden? Es wird sofort gespeichert und die Seite neu geladen. Ein Preset bringt ein komplettes Erscheinungsbild mit – Farben, Navigation, Abstände, Eckenradius, Inhaltsbreite, Schriften und Verknüpfungen. Alles, was das aktuell getragene Theme mitgebracht hat, wird ersetzt; selbst hochgeladene Bilder bleiben unverändert.", + "es": "¿Aplicar el preset «%s» ahora? Se guarda de inmediato y la página se recarga. Un preset trae un aspecto completo: colores, navegación, espaciado, radio de esquinas, ancho del contenido, fuentes y accesos directos. Todo lo que trajo el tema que llevas ahora se sustituye; las imágenes que subiste tú se quedan como están.", + "fr": "Appliquer le préréglage « %s » maintenant ? Il est enregistré immédiatement et la page se recharge. Un préréglage apporte une apparence complète : couleurs, navigation, espacement, rayon des coins, largeur du contenu, polices et raccourcis. Tout ce qu'a apporté le thème actuel est remplacé ; les images que vous avez importées vous-même restent telles quelles.", + "id": "Terapkan preset '%s' sekarang? Langsung disimpan dan halaman dimuat ulang. Preset membawa satu tampilan utuh — warna, navigasi, jarak, radius sudut, lebar konten, font, dan pintasan. Semua yang dibawa tema yang sedang Anda pakai akan diganti; gambar yang Anda unggah sendiri tetap apa adanya.", + "it": "Applicare il preset «%s» adesso? Viene salvato subito e la pagina si ricarica. Un preset porta un aspetto completo: colori, navigazione, spaziatura, raggio degli angoli, larghezza del contenuto, caratteri e scorciatoie. Tutto ciò che ha portato il tema che indossi ora viene sostituito; le immagini che hai caricato tu restano come sono.", + "ja": "プリセット「%s」を適用しますか?すぐに保存され、ページが再読み込みされます。プリセットは配色・ナビゲーション・余白・角丸・コンテンツ幅・フォント・ショートカットという外観一式をまとめて持ってきます。いま着ているテーマが持ち込んだものはすべて置き換わります。ご自身でアップロードした画像はそのまま残ります。", + "ko": "'%s' 프리셋을 지금 적용할까요? 즉시 저장되고 페이지가 새로 고쳐집니다. 프리셋은 색상, 내비게이션, 간격, 모서리 반경, 콘텐츠 너비, 글꼴, 바로가기까지 외형 한 벌을 통째로 가져옵니다. 지금 적용된 테마가 가져온 것은 모두 교체되며, 직접 업로드한 이미지는 그대로 유지됩니다.", + "nl": "Preset '%s' nu toepassen? Het wordt meteen opgeslagen en de pagina wordt herladen. Een preset brengt een compleet uiterlijk mee: kleuren, navigatie, tussenruimte, hoekradius, contentbreedte, lettertypen en snelkoppelingen. Alles wat het thema dat u nu draagt heeft meegebracht wordt vervangen; afbeeldingen die u zelf hebt geüpload blijven zoals ze zijn.", + "pl": "Zastosować preset „%s” teraz? Zostanie zapisany od razu, a strona przeładowana. Preset niesie cały wygląd — kolory, nawigację, odstępy, promień zaokrągleń, szerokość treści, czcionki i skróty. Wszystko, co przyniósł obecnie założony motyw, zostanie zastąpione; obrazy wgrane przez Ciebie pozostaną bez zmian.", + "ru": "Применить пресет «%s» сейчас? Он сохранится сразу, страница перезагрузится. Пресет несёт целый облик — цвета, навигацию, отступы, радиус скругления, ширину контента, шрифты и ярлыки. Всё, что принесла нынешняя тема, будет заменено; загруженные вами изображения останутся как есть.", + "tr": "'%s' ön ayarı şimdi uygulansın mı? Hemen kaydedilir ve sayfa yeniden yüklenir. Bir ön ayar görünümün tamamını getirir: renkler, gezinme, boşluk, köşe yarıçapı, içerik genişliği, yazı tipleri ve kısayollar. Şu an kullandığınız temanın getirdiği her şey değiştirilir; kendi yüklediğiniz görseller olduğu gibi kalır.", + "uk": "Застосувати пресет «%s» зараз? Він збережеться одразу, сторінку буде перезавантажено. Пресет несе цілий вигляд — кольори, навігацію, відступи, радіус заокруглення, ширину вмісту, шрифти та ярлики. Усе, що принесла нинішня тема, буде замінено; завантажені вами зображення лишаться як є.", + "zh_Hans": "现在应用「%s」预设吗?会立即保存并重新加载页面。预设带来的是一整套外观——配色、导航、间距、圆角、内容宽度、字体和快捷方式。你现在这套主题带来的东西都会被替换掉;你自己上传的图片保持原样。", + "zh_Hant": "現在套用「%s」預設嗎?會立即儲存並重新載入頁面。預設帶來的是一整套外觀——配色、導覽、間距、圓角、內容寬度、字型和捷徑。你現在這套主題帶來的東西都會被替換掉;你自己上傳的圖片保持原樣。" + }, + "Apply the '%s' preset now? It is saved immediately and the page reloads. Presets set the light and dark colors only — layout, typography, branding, navigation, and toolbar are left unchanged.": { + "it": "Applicare ora il preset «%s»? Viene salvato subito e la pagina si ricarica. I preset impostano solo i colori chiari e scuri: layout, tipografia, branding, navigazione e barra degli strumenti restano invariati.", + "ru": "Применить набор «%s» сейчас? Он сохраняется сразу, и страница перезагружается. Наборы задают только светлые и тёмные цвета — макет, типографика, оформление, навигация и панель ярлыков не меняются." + }, + "Applying replaces the shortcuts above and removes the logo, icons and backgrounds the current theme brought with it. Images you uploaded yourself stay as they are.": { + "de": "Beim Anwenden werden die Verknüpfungen oben ersetzt und Logo, Symbole und Hintergründe entfernt, die das aktuelle Theme mitgebracht hat. Selbst hochgeladene Bilder bleiben unverändert.", + "es": "Al aplicar se sustituyen los accesos directos de arriba y se eliminan el logotipo, los iconos y los fondos que trajo el tema actual. Las imágenes que subiste tú se quedan como están.", + "fr": "L'application remplace les raccourcis ci-dessus et supprime le logo, les icônes et les arrière-plans apportés par le thème actuel. Les images que vous avez importées vous-même restent telles quelles.", + "id": "Menerapkan akan mengganti pintasan di atas dan menghapus logo, ikon, serta latar belakang yang dibawa tema saat ini. Gambar yang Anda unggah sendiri tetap apa adanya.", + "it": "Applicando si sostituiscono le scorciatoie qui sopra e si rimuovono logo, icone e sfondi portati dal tema attuale. Le immagini che hai caricato tu restano come sono.", + "ja": "適用すると上のショートカットが置き換わり、いまのテーマが持ち込んだロゴ・アイコン・背景は削除されます。ご自身でアップロードした画像はそのまま残ります。", + "ko": "적용하면 위 바로가기가 교체되고 현재 테마가 가져온 로고, 아이콘, 배경이 제거됩니다. 직접 업로드한 이미지는 그대로 유지됩니다.", + "nl": "Toepassen vervangt de snelkoppelingen hierboven en verwijdert het logo, de pictogrammen en de achtergronden die het huidige thema heeft meegebracht. Afbeeldingen die u zelf hebt geüpload blijven zoals ze zijn.", + "pl": "Zastosowanie zastąpi skróty powyżej i usunie logo, ikony oraz tła przyniesione przez obecny motyw. Obrazy wgrane przez Ciebie pozostaną bez zmian.", + "ru": "Применение заменит ярлыки выше и удалит логотип, значки и фоны, принесённые нынешней темой. Загруженные вами изображения останутся как есть.", + "tr": "Uygulamak yukarıdaki kısayolları değiştirir ve mevcut temanın getirdiği logo, simge ve arka planları kaldırır. Kendi yüklediğiniz görseller olduğu gibi kalır.", + "uk": "Застосування замінить ярлики вище та вилучить логотип, піктограми й фони, які принесла нинішня тема. Завантажені вами зображення лишаться як є.", + "zh_Hans": "应用会替换掉上面这几条快捷方式,并移除当前主题带来的 logo、图标和背景。你自己上传的图片保持原样。", + "zh_Hant": "套用會替換掉上面這幾條捷徑,並移除目前主題帶來的 logo、圖示和背景。你自己上傳的圖片保持原樣。" + }, + "Applying…": { + "de": "Wird angewendet…", + "es": "Aplicando…", + "fr": "Application…", + "id": "Menerapkan…", + "it": "Applicazione…", + "ja": "適用中…", + "ko": "적용 중…", + "nl": "Bezig met toepassen…", + "pl": "Stosowanie…", + "ru": "Применение…", + "tr": "Uygulanıyor…", + "uk": "Застосування…", + "zh_Hans": "正在应用…", + "zh_Hant": "正在套用…" + }, + "Aurora Theme Design": { + "de": "Aurora Theme-Design", + "es": "Diseño del tema Aurora", + "fr": "Design du thème Aurora", + "id": "Desain Tema Aurora", + "it": "Design del tema Aurora", + "ja": "Aurora テーマデザイン", + "ko": "Aurora 테마 디자인", + "nl": "Aurora-themaontwerp", + "pl": "Wygląd motywu Aurora", + "ru": "Оформление темы Aurora", + "tr": "Aurora Tema Tasarımı", + "uk": "Оформлення теми Aurora", + "zh_Hans": "Aurora 主题设计", + "zh_Hant": "Aurora 主題設計" + }, + "Back up identity": { + "de": "Identität sichern", + "es": "Hacer copia de la identidad", + "fr": "Sauvegarder l'identité", + "id": "Cadangkan identitas", + "it": "Esegui backup dell'identità", + "ja": "身元をバックアップ", + "ko": "신원 백업", + "nl": "Identiteit back-uppen", + "pl": "Utwórz kopię tożsamości", + "ru": "Сохранить личность", + "tr": "Kimliği yedekle", + "uk": "Зберегти особистість", + "zh_Hans": "备份身份", + "zh_Hant": "備份身分" + }, + "Back up key": { + "de": "Schlüssel sichern", + "es": "Guardar clave", + "fr": "Sauvegarder la clé", + "id": "Cadangkan kunci", + "it": "Salva la chiave", + "ja": "キーをバックアップ", + "ko": "키 백업", + "nl": "Sleutel back-uppen", + "pl": "Zapisz klucz", + "ru": "Сохранить ключ", + "tr": "Anahtarı yedekle", + "uk": "Зберегти ключ", + "zh_Hans": "备份密钥", + "zh_Hant": "備份金鑰" + }, + "Back up the current key first": { + "de": "Zuerst den aktuellen Schlüssel sichern", + "es": "Guardar antes la clave actual", + "fr": "Sauvegarder d'abord la clé actuelle", + "id": "Cadangkan dulu kunci saat ini", + "it": "Salva prima la chiave attuale", + "ja": "先に現在のキーをバックアップ", + "ko": "현재 키를 먼저 백업", + "nl": "Eerst de huidige sleutel back-uppen", + "pl": "Najpierw zapisz obecny klucz", + "ru": "Сначала сохранить текущий ключ", + "tr": "Önce mevcut anahtarı yedekle", + "uk": "Спочатку зберегти поточний ключ", + "zh_Hans": "先备份当前密钥", + "zh_Hant": "先備份目前金鑰" + }, + "Back up your creator identity": { + "de": "Ersteller-Identität sichern", + "es": "Haz una copia de tu identidad de creador", + "fr": "Sauvegarder votre identité de créateur", + "id": "Cadangkan identitas kreatormu", + "it": "Esegui il backup della tua identità di creatore", + "ja": "作成者としての身元をバックアップ", + "ko": "제작자 신원 백업", + "nl": "Back-up van je makersidentiteit", + "pl": "Utwórz kopię swojej tożsamości twórcy", + "ru": "Сохраните свою личность автора", + "tr": "Yaratıcı kimliğini yedekle", + "uk": "Збережіть свою особистість автора", + "zh_Hans": "备份创作者身份", + "zh_Hant": "備份創作者身分" + }, + "Back up your creator key": { + "de": "Ersteller-Schlüssel sichern", + "es": "Guarda tu clave de creador", + "fr": "Sauvegardez votre clé de créateur", + "id": "Cadangkan kunci kreator Anda", + "it": "Salva la tua chiave del creatore", + "ja": "クリエイターキーをバックアップ", + "ko": "크리에이터 키 백업", + "nl": "Back-up van je makerssleutel", + "pl": "Zapisz swój klucz twórcy", + "ru": "Сохраните ключ автора", + "tr": "Oluşturucu anahtarınızı yedekleyin", + "uk": "Збережіть свій ключ автора", + "zh_Hans": "备份你的创作者密钥", + "zh_Hant": "備份你的創作者金鑰" + }, + "Backed up": { + "de": "Gesichert", + "es": "Con copia de seguridad", + "fr": "Sauvegardée", + "id": "Sudah dicadangkan", + "it": "Con backup", + "ja": "バックアップ済み", + "ko": "백업됨", + "nl": "Back-up gemaakt", + "pl": "Kopia zapasowa zrobiona", + "ru": "Есть резервная копия", + "tr": "Yedeklendi", + "uk": "Є резервна копія", + "zh_Hans": "已备份", + "zh_Hant": "已備份" + }, + "Beyond the preview": { + "de": "Über die Vorschau hinaus", + "es": "Más allá de la vista previa", + "fr": "Au-delà de l'aperçu", + "id": "Di luar pratinjau", + "it": "Oltre l'anteprima", + "ja": "プレビューの先にあるもの", + "ko": "미리보기 너머", + "nl": "Meer dan de voorvertoning", + "pl": "Poza podglądem", + "ru": "Помимо предпросмотра", + "tr": "Önizlemenin ötesinde", + "uk": "Поза межами попереднього перегляду", + "zh_Hans": "预览之外还带了", + "zh_Hant": "預覽之外還帶了" + }, + "Branding & Shortcuts": { + "de": "Branding & Verknüpfungen", + "es": "Marca y accesos directos", + "fr": "Identité visuelle et raccourcis", + "id": "Merek & Pintasan", + "it": "Branding e scorciatoie", + "ja": "ブランディングとショートカット", + "ko": "브랜딩 및 바로가기", + "nl": "Huisstijl en snelkoppelingen", + "pl": "Marka i skróty", + "ru": "Оформление и ярлыки", + "tr": "Marka ve Kısayollar", + "uk": "Оформлення та ярлики", + "zh_Hans": "品牌与快捷", + "zh_Hant": "品牌與捷徑" + }, + "Bundled content": { + "de": "Enthaltene Inhalte", + "es": "Contenido incluido", + "fr": "Contenu inclus", + "id": "Konten yang disertakan", + "it": "Contenuti inclusi", + "ja": "同梱コンテンツ", + "ko": "포함된 콘텐츠", + "nl": "Meegeleverde inhoud", + "pl": "Dołączona zawartość", + "ru": "Вложенное содержимое", + "tr": "Birlikte gelen içerik", + "uk": "Вкладений вміст", + "zh_Hans": "随附内容", + "zh_Hant": "隨附內容" + }, + "Change": { + "de": "Ändern", + "es": "Cambiar", + "fr": "Modifier", + "id": "Ubah", + "it": "Cambia", + "ja": "変更", + "ko": "변경", + "nl": "Wijzigen", + "pl": "Zmień", + "ru": "Изменить", + "tr": "Değiştir", + "uk": "Змінити", + "zh_Hans": "更改", + "zh_Hant": "更改" + }, + "Change your name": { + "de": "Namen ändern", + "es": "Cambiar tu nombre", + "fr": "Changer de nom", + "id": "Ubah nama Anda", + "it": "Cambia il tuo nome", + "ja": "名前を変更", + "ko": "이름 변경", + "nl": "Je naam wijzigen", + "pl": "Zmień swoją nazwę", + "ru": "Изменить имя", + "tr": "Adınızı değiştirin", + "uk": "Змініть своє ім'я", + "zh_Hans": "更改你的名字", + "zh_Hant": "更改你的名稱" + }, + "Changed routers? Restore your identity": { + "de": "Router gewechselt? Identität wiederherstellen", + "es": "¿Cambiaste de router? Restaura tu identidad", + "fr": "Vous avez changé de routeur ? Restaurez votre identité", + "id": "Ganti router? Pulihkan identitasmu", + "it": "Hai cambiato router? Ripristina la tua identità", + "ja": "ルーターを変えた? 身元を復元", + "ko": "공유기를 바꿨나요? 신원 복원", + "nl": "Andere router? Herstel je identiteit", + "pl": "Zmieniłeś router? Przywróć swoją tożsamość", + "ru": "Сменили роутер? Восстановите свою личность", + "tr": "Yönlendirici mi değiştirdin? Kimliğini geri yükle", + "uk": "Змінили роутер? Відновіть свою особистість", + "zh_Hans": "换了路由器?恢复身份", + "zh_Hant": "換了路由器?恢復身分" + }, + "Changes preview here instantly; Save or Save & Apply persists them.": { + "de": "Änderungen werden hier sofort angezeigt; Speichern bzw. Speichern & Anwenden übernimmt sie dauerhaft.", + "es": "Los cambios se previsualizan aquí al instante; Guardar o Guardar y aplicar los conserva.", + "fr": "Les modifications s'affichent immédiatement ici ; Enregistrer ou Enregistrer et appliquer les conserve.", + "id": "Perubahan langsung dipratinjau di sini; Simpan atau Simpan & Terapkan menyimpannya.", + "it": "Le modifiche vengono mostrate qui all'istante; Salva o Salva e applica le rende permanenti.", + "ja": "変更はここで即座にプレビューされます。保存 または 保存して適用 で確定します。", + "ko": "변경 사항이 여기에서 즉시 미리보기 됩니다. 저장 또는 저장 후 적용으로 반영됩니다.", + "nl": "Wijzigingen worden hier direct getoond; Opslaan of Opslaan en toepassen maakt ze definitief.", + "pl": "Zmiany są tu podglądane od razu; Zapisz lub Zapisz i zastosuj utrwala je.", + "ru": "Изменения сразу видны здесь; «Сохранить» или «Сохранить и применить» закрепляет их.", + "tr": "Değişiklikler burada anında önizlenir; Kaydet ya da Kaydet ve Uygula ile kalıcı olur.", + "uk": "Зміни одразу видно тут; «Зберегти» або «Зберегти й застосувати» закріплює їх.", + "zh_Hans": "改动即时预览,保存或保存并应用后持久化。", + "zh_Hant": "變更即時預覽,儲存或儲存並套用後持久化。" + }, + "Choose backup file…": { + "de": "Sicherungsdatei wählen…", + "es": "Elegir archivo de respaldo…", + "fr": "Choisir le fichier de sauvegarde…", + "id": "Pilih berkas cadangan…", + "it": "Scegli il file di backup…", + "ja": "バックアップファイルを選択…", + "ko": "백업 파일 선택…", + "nl": "Back-upbestand kiezen…", + "pl": "Wybierz plik kopii zapasowej…", + "ru": "Выбрать файл резервной копии…", + "tr": "Yedek dosyasını seç…", + "uk": "Вибрати файл резервної копії…", + "zh_Hans": "选择备份文件…", + "zh_Hant": "選擇備份檔案…" + }, + "Choose the name to publish under.": { + "de": "Wähle den Namen, unter dem veröffentlicht wird.", + "es": "Elige el nombre con el que publicar.", + "fr": "Choisissez le nom sous lequel publier.", + "id": "Pilih nama untuk digunakan saat membagikan.", + "it": "Scegli il nome con cui pubblicare.", + "ja": "公開に使う名前を選んでください。", + "ko": "게시할 이름을 정하세요.", + "nl": "Kies de naam waaronder je publiceert.", + "pl": "Wybierz nazwę, pod którą publikujesz.", + "ru": "Выберите имя для публикации.", + "tr": "Yayınlarken kullanılacak adı seçin.", + "uk": "Виберіть ім'я для публікації.", + "zh_Hans": "选一个用来署名的名字。", + "zh_Hant": "選一個用來署名的名稱。" + }, + "Colors only": { + "de": "Nur Farben", + "es": "Solo colores", + "fr": "Couleurs seulement", + "id": "Hanya warna", + "it": "Solo colori", + "ja": "カラーのみ", + "ko": "색상만", + "nl": "Alleen kleuren", + "pl": "Tylko kolory", + "ru": "Только цвета", + "tr": "Yalnızca renkler", + "uk": "Лише кольори", + "zh_Hans": "仅配色", + "zh_Hant": "僅配色" + }, + "Computed from the source colors; leave a field empty to keep its automatic value.": { + "de": "Aus den Quellfarben berechnet; ein leeres Feld behält den automatischen Wert.", + "es": "Calculados a partir de los colores de origen; deje un campo vacío para conservar su valor automático.", + "fr": "Calculées à partir des couleurs sources ; laissez un champ vide pour conserver sa valeur automatique.", + "id": "Dihitung dari warna sumber; biarkan kosong untuk mempertahankan nilai otomatisnya.", + "it": "Calcolati dai colori sorgente; lascia un campo vuoto per mantenerne il valore automatico.", + "ja": "ソースカラーから算出されます。空欄のままにすると自動値が使われます。", + "ko": "소스 색상에서 계산됩니다. 비워 두면 자동 값이 유지됩니다.", + "nl": "Berekend uit de bronkleuren; laat een veld leeg om de automatische waarde te behouden.", + "pl": "Obliczane z kolorów źródłowych; puste pole zachowuje wartość automatyczną.", + "ru": "Вычисляются из исходных цветов; пустое поле сохраняет автоматическое значение.", + "tr": "Kaynak renklerden hesaplanır; alanı boş bırakmak otomatik değeri korur.", + "uk": "Обчислюються з початкових кольорів; порожнє поле зберігає автоматичне значення.", + "zh_Hans": "由源色自动推导;留空即保持自动值。", + "zh_Hant": "由來源色自動推導;留空即保持自動值。" + }, + "Content": { + "de": "Inhalt", + "es": "Contenido", + "fr": "Contenu", + "id": "Konten", + "it": "Contenuto", + "ja": "内容", + "ko": "항목", + "nl": "Inhoud", + "pl": "Zawartość", + "ru": "Содержимое", + "tr": "İçerik", + "uk": "Вміст", + "zh_Hans": "内容", + "zh_Hant": "內容" + }, + "Copied": { + "de": "Kopiert", + "es": "Copiado", + "fr": "Copié", + "id": "Tersalin", + "it": "Copiato", + "ja": "コピーしました", + "ko": "복사됨", + "nl": "Gekopieerd", + "pl": "Skopiowano", + "ru": "Скопировано", + "tr": "Kopyalandı", + "uk": "Скопійовано", + "zh_Hans": "已复制", + "zh_Hant": "已複製" + }, + "Copy": { + "de": "Kopieren", + "es": "Copiar", + "fr": "Copier", + "id": "Salin", + "it": "Copia", + "ja": "コピー", + "ko": "복사", + "nl": "Kopiëren", + "pl": "Kopiuj", + "ru": "Копировать", + "tr": "Kopyala", + "uk": "Копіювати", + "zh_Hans": "复制", + "zh_Hant": "複製" + }, + "Could not add the update source: %s": { + "de": "Update-Quelle konnte nicht hinzugefügt werden: %s", + "es": "No se pudo añadir la fuente de actualizaciones: %s", + "fr": "Impossible d'ajouter la source de mise à jour : %s", + "id": "Tidak dapat menambahkan sumber pembaruan: %s", + "it": "Impossibile aggiungere la sorgente aggiornamenti: %s", + "ja": "更新元を追加できませんでした: %s", + "ko": "업데이트 소스를 추가하지 못했습니다: %s", + "nl": "Kan de updatebron niet toevoegen: %s", + "pl": "Nie udało się dodać źródła aktualizacji: %s", + "ru": "Не удалось добавить источник обновлений: %s", + "tr": "Güncelleme kaynağı eklenemedi: %s", + "uk": "Не вдалося додати джерело оновлень: %s", + "zh_Hans": "添加更新源失败:%s", + "zh_Hant": "新增更新來源失敗:%s" + }, + "Couldn't copy. The identity is shown above — select it and copy it manually.": { + "de": "Kopieren nicht möglich. Die Identität ist oben eingeblendet – markiere sie und kopiere sie von Hand.", + "es": "No se pudo copiar. La identidad se muestra arriba: selecciónala y cópiala a mano.", + "fr": "Copie impossible. L'identité est affichée ci-dessus — sélectionnez-la et copiez-la manuellement.", + "id": "Tidak bisa menyalin. Identitas ditampilkan di atas — pilih dan salin secara manual.", + "it": "Impossibile copiare. L'identità è mostrata sopra: selezionala e copiala a mano.", + "ja": "コピーできませんでした。身元を上に表示しました。選択して手動でコピーしてください。", + "ko": "복사하지 못했습니다. 신원을 위에 표시했으니 직접 선택해 복사하세요.", + "nl": "Kopiëren lukte niet. De identiteit staat hierboven — selecteer en kopieer haar handmatig.", + "pl": "Nie udało się skopiować. Tożsamość jest pokazana powyżej — zaznacz ją i skopiuj ręcznie.", + "ru": "Не удалось скопировать. Личность показана выше — выделите её и скопируйте вручную.", + "tr": "Kopyalanamadı. Kimlik yukarıda gösteriliyor — seçip elle kopyala.", + "uk": "Не вдалося скопіювати. Особистість показано вище — виділіть її та скопіюйте вручну.", + "zh_Hans": "复制不了。身份已显示在上面,请手动选中复制。", + "zh_Hant": "複製不了。身分已顯示在上面,請手動選取複製。" + }, + "Couldn't read one of the images from this router. Try reloading the page.": { + "de": "Eines der Bilder konnte nicht vom Router gelesen werden. Lade die Seite neu und versuch es noch einmal.", + "es": "No se ha podido leer una de las imágenes del router. Vuelve a cargar la página e inténtalo otra vez.", + "fr": "Impossible de lire l’une des images depuis le routeur. Rechargez la page et réessayez.", + "id": "Salah satu gambar tidak terbaca dari router. Muat ulang halaman lalu coba lagi.", + "it": "Non è stato possibile leggere una delle immagini dal router. Ricarica la pagina e riprova.", + "ja": "ルーターから画像を一つ読み取れませんでした。ページを再読み込みしてやり直してください。", + "ko": "공유기에서 이미지 하나를 읽지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.", + "nl": "Een van de afbeeldingen kon niet van de router worden gelezen. Herlaad de pagina en probeer het opnieuw.", + "pl": "Nie udało się odczytać jednego z obrazów z routera. Odśwież stronę i spróbuj ponownie.", + "ru": "Не удалось прочитать одно из изображений с роутера. Обновите страницу и попробуйте снова.", + "tr": "Görsellerden biri yönlendiriciden okunamadı. Sayfayı yenileyip tekrar dene.", + "uk": "Не вдалося прочитати одне із зображень з роутера. Перезавантажте сторінку та спробуйте ще раз.", + "zh_Hans": "从路由器上读不到其中一张图,刷新页面再试。", + "zh_Hant": "從路由器上讀不到其中一張圖,重新整理頁面再試。" + }, + "Couldn't read your identity. Please try again.": { + "de": "Deine Identität konnte nicht gelesen werden. Bitte versuche es erneut.", + "es": "No se pudo leer tu identidad. Inténtalo de nuevo.", + "fr": "Impossible de lire votre identité. Veuillez réessayer.", + "id": "Identitasmu tidak bisa dibaca. Coba lagi.", + "it": "Impossibile leggere la tua identità. Riprova.", + "ja": "身元を読み取れませんでした。もう一度お試しください。", + "ko": "신원을 읽지 못했습니다. 다시 시도하세요.", + "nl": "Je identiteit kon niet worden gelezen. Probeer het opnieuw.", + "pl": "Nie udało się odczytać twojej tożsamości. Spróbuj ponownie.", + "ru": "Не удалось прочитать вашу личность. Попробуйте ещё раз.", + "tr": "Kimliğin okunamadı. Lütfen tekrar dene.", + "uk": "Не вдалося прочитати вашу особистість. Спробуйте ще раз.", + "zh_Hans": "读不到你的身份,请重试。", + "zh_Hant": "讀不到你的身分,請重試。" + }, + "Couldn't read your key. Please try again.": { + "de": "Schlüssel konnte nicht gelesen werden. Bitte erneut versuchen.", + "es": "No se pudo leer tu clave. Inténtalo de nuevo.", + "fr": "Impossible de lire votre clé. Réessayez.", + "id": "Kunci tidak dapat dibaca. Coba lagi.", + "it": "Impossibile leggere la chiave. Riprova.", + "ja": "キーを読み取れませんでした。もう一度お試しください。", + "ko": "키를 읽지 못했습니다. 다시 시도하세요.", + "nl": "Kan je sleutel niet lezen. Probeer het opnieuw.", + "pl": "Nie udało się odczytać klucza. Spróbuj ponownie.", + "ru": "Не удалось прочитать ключ. Попробуйте ещё раз.", + "tr": "Anahtarınız okunamadı. Lütfen tekrar deneyin.", + "uk": "Не вдалося прочитати ключ. Спробуйте ще раз.", + "zh_Hans": "读不到你的密钥,请重试。", + "zh_Hant": "讀不到你的金鑰,請重試。" + }, + "Couldn't restore that identity. Check it and try again.": { + "de": "Diese Identität konnte nicht wiederhergestellt werden. Prüfe sie und versuche es erneut.", + "es": "No se pudo restaurar esa identidad. Compruébala e inténtalo de nuevo.", + "fr": "Impossible de restaurer cette identité. Vérifiez-la et réessayez.", + "id": "Identitas itu tidak bisa dipulihkan. Periksa lalu coba lagi.", + "it": "Impossibile ripristinare quell'identità. Controllala e riprova.", + "ja": "その身元は復元できませんでした。確認してもう一度お試しください。", + "ko": "그 신원을 복원하지 못했습니다. 확인 후 다시 시도하세요.", + "nl": "Die identiteit kon niet worden hersteld. Controleer haar en probeer opnieuw.", + "pl": "Nie udało się przywrócić tej tożsamości. Sprawdź ją i spróbuj ponownie.", + "ru": "Не удалось восстановить эту личность. Проверьте её и попробуйте снова.", + "tr": "O kimlik geri yüklenemedi. Kontrol edip tekrar dene.", + "uk": "Не вдалося відновити цю особистість. Перевірте її та спробуйте ще раз.", + "zh_Hans": "恢复不了。检查一下再试。", + "zh_Hant": "恢復不了。檢查一下再試。" + }, + "Couldn't save that name. Try again.": { + "de": "Name konnte nicht gespeichert werden. Bitte erneut versuchen.", + "es": "No se pudo guardar ese nombre. Inténtalo de nuevo.", + "fr": "Impossible d'enregistrer ce nom. Réessayez.", + "id": "Nama itu tidak dapat disimpan. Coba lagi.", + "it": "Impossibile salvare quel nome. Riprova.", + "ja": "その名前を保存できませんでした。もう一度お試しください。", + "ko": "이름을 저장하지 못했습니다. 다시 시도하세요.", + "nl": "Kan die naam niet opslaan. Probeer het opnieuw.", + "pl": "Nie udało się zapisać tej nazwy. Spróbuj ponownie.", + "ru": "Не удалось сохранить это имя. Попробуйте ещё раз.", + "tr": "Bu ad kaydedilemedi. Tekrar deneyin.", + "uk": "Не вдалося зберегти це ім'я. Спробуйте ще раз.", + "zh_Hans": "这个名字没能保存,请重试。", + "zh_Hant": "這個名稱沒能儲存,請重試。" + }, + "Couldn't use that key. Check it and try again.": { + "de": "Schlüssel nicht verwendbar. Bitte prüfen und erneut versuchen.", + "es": "No se pudo usar esa clave. Revísala e inténtalo de nuevo.", + "fr": "Cette clé n'a pas fonctionné. Vérifiez-la et réessayez.", + "id": "Kunci itu tidak dapat digunakan. Periksa lalu coba lagi.", + "it": "Impossibile usare quella chiave. Controllala e riprova.", + "ja": "そのキーは使えませんでした。確認してもう一度お試しください。", + "ko": "그 키를 사용할 수 없습니다. 확인 후 다시 시도하세요.", + "nl": "Kan die sleutel niet gebruiken. Controleer hem en probeer opnieuw.", + "pl": "Nie udało się użyć tego klucza. Sprawdź go i spróbuj ponownie.", + "ru": "Не удалось использовать этот ключ. Проверьте его и попробуйте снова.", + "tr": "Bu anahtar kullanılamadı. Kontrol edip tekrar deneyin.", + "uk": "Не вдалося використати цей ключ. Перевірте його та спробуйте ще раз.", + "zh_Hans": "这个密钥用不了,检查后重试。", + "zh_Hant": "這個金鑰無法使用,檢查後重試。" + }, + "Created automatically the first time you publish — no sign-up, no password. It decides which configurations in the store are yours, and who can change or remove them. Backing it up saves an identity backup file; import that file after a reflash, or on a new router, and your work comes back to you.": { + "de": "Wird beim ersten Veröffentlichen automatisch erstellt – ohne Registrierung, ohne Passwort. Sie bestimmt, welche Konfigurationen im Store dir gehören und wer sie ändern oder entfernen darf. Beim Sichern wird eine Identitäts-Sicherungsdatei gespeichert; importiere sie nach einem Neuflashen oder auf einem neuen Router, und deine Arbeit gehört wieder dir.", + "es": "Se crea automáticamente la primera vez que publicas: sin registro ni contraseña. Decide qué configuraciones de la tienda son tuyas y quién puede cambiarlas o eliminarlas. Al hacer una copia se guarda un archivo de respaldo de identidad; impórtalo tras reinstalar el firmware, o en un router nuevo, y tu trabajo vuelve a ser tuyo.", + "fr": "Créée automatiquement lors de votre première publication — sans inscription ni mot de passe. Elle détermine quelles configurations de la boutique vous appartiennent et qui peut les modifier ou les supprimer. La sauvegarde enregistre un fichier de sauvegarde d'identité ; importez-le après un reflash, ou sur un nouveau routeur, et votre travail vous revient.", + "id": "Dibuat otomatis saat kamu pertama kali menerbitkan — tanpa pendaftaran, tanpa kata sandi. Identitas ini menentukan konfigurasi mana di toko yang milikmu, dan siapa yang boleh mengubah atau menghapusnya. Mencadangkannya menyimpan berkas cadangan identitas; impor berkas itu setelah flash ulang, atau di router baru, dan karyamu kembali menjadi milikmu.", + "it": "Creata automaticamente la prima volta che pubblichi: nessuna registrazione, nessuna password. Decide quali configurazioni nello store sono tue e chi può modificarle o rimuoverle. Il backup salva un file di backup dell'identità; importalo dopo un reflash, o su un nuovo router, e il tuo lavoro torna tuo.", + "ja": "初めて公開したときに自動で作られます。登録もパスワードも不要です。ストア内のどの設定があなたのものか、誰が変更・削除できるかを決めるのがこれです。バックアップすると身元バックアップファイルが保存されます。再フラッシュ後や新しいルーターでそのファイルを読み込めば、作品が手元に戻ります。", + "ko": "처음 게시할 때 자동으로 만들어집니다. 가입도 비밀번호도 없습니다. 스토어의 어떤 설정이 내 것인지, 누가 그것을 바꾸거나 내릴 수 있는지를 정합니다. 백업하면 신원 백업 파일이 저장되며, 펌웨어를 다시 올린 뒤나 새 공유기에서 그 파일을 가져오면 작업물이 다시 내 것이 됩니다.", + "nl": "Wordt automatisch aangemaakt zodra je voor het eerst publiceert — geen registratie, geen wachtwoord. Ze bepaalt welke configuraties in de winkel van jou zijn en wie ze mag wijzigen of verwijderen. Een back-up slaat een identiteitsback-upbestand op; importeer dat na een herinstallatie of op een nieuwe router en je werk komt weer bij je terug.", + "pl": "Tworzona automatycznie przy pierwszej publikacji — bez rejestracji i bez hasła. Decyduje, które konfiguracje w sklepie należą do ciebie i kto może je zmieniać lub usuwać. Kopia zapasowa zapisuje plik kopii tożsamości; zaimportuj go po ponownym wgraniu firmware'u albo na nowym routerze, a twoje prace wrócą do ciebie.", + "ru": "Создаётся автоматически при первой публикации — без регистрации и пароля. Она определяет, какие конфигурации в магазине ваши и кто может их менять или удалять. Резервное копирование сохраняет файл резервной копии личности; импортируйте его после перепрошивки или на новом роутере, и ваши работы вернутся к вам.", + "tr": "İlk yayınladığında otomatik olarak oluşturulur — kayıt yok, şifre yok. Mağazadaki hangi yapılandırmaların sana ait olduğunu ve onları kimin değiştirip kaldırabileceğini belirler. Yedeklemek bir kimlik yedek dosyası kaydeder; yeniden flash'ladıktan sonra ya da yeni bir yönlendiricide o dosyayı içe aktar, çalışmaların sana geri döner.", + "uk": "Створюється автоматично під час першої публікації — без реєстрації та пароля. Вона визначає, які конфігурації в магазині ваші й хто може їх змінювати чи вилучати. Резервне копіювання зберігає файл резервної копії особистості; імпортуйте його після перепрошивки або на новому роутері, і ваші роботи повернуться до вас.", + "zh_Hans": "第一次发布配置时自动生成,不用注册、没有密码。它决定商店里哪些配置属于你、谁能修改或下架它们。备份会存成一个身份备份文件;重刷系统之后、或者换了新路由器,导入这个文件,作品就回到你名下。", + "zh_Hant": "第一次發布配置時自動生成,不用註冊、沒有密碼。它決定商店裡哪些配置屬於你、誰能修改或下架它們。備份會存成一個身分備份檔案;重刷系統之後、或者換了新路由器,匯入這個檔案,作品就回到你名下。" + }, + "Created the first time you publish — or restore one you backed up.": { + "de": "Wird bei der ersten Veröffentlichung erstellt — oder stelle eine gesicherte wieder her.", + "es": "Se crea la primera vez que publicas, o restaura una de la que tengas copia.", + "fr": "Créée lors de votre première publication — ou restaurez-en une sauvegardée.", + "id": "Dibuat saat pertama kali kamu memublikasikan — atau pulihkan dari cadangan.", + "it": "Creata alla prima pubblicazione — oppure ripristinane una di cui hai il backup.", + "ja": "初めて公開したときに作成されます。バックアップしたものを復元することもできます。", + "ko": "처음 공개할 때 만들어집니다 — 백업해 둔 것을 복원할 수도 있습니다.", + "nl": "Wordt aangemaakt bij je eerste publicatie — of herstel er een uit een back-up.", + "pl": "Tworzona przy pierwszej publikacji — albo przywróć zapisaną kopię.", + "ru": "Создаётся при первой публикации — или восстановите сохранённую.", + "tr": "İlk yayınladığınızda oluşturulur — ya da yedeklediğiniz birini geri yükleyin.", + "uk": "Створюється під час першої публікації — або відновіть збережену.", + "zh_Hans": "第一次发布时自动创建,也可以恢复你备份过的那一个。", + "zh_Hant": "第一次發布時自動建立,也可以還原你備份過的那一個。" + }, + "Custom app icons": { + "de": "Eigene App-Symbole", + "es": "Iconos personalizados de la app", + "fr": "Icônes personnalisées de l'application", + "id": "Ikon aplikasi khusus", + "it": "Icone personalizzate dell'app", + "ja": "アプリ独自のアイコン", + "ko": "앱 사용자 지정 아이콘", + "nl": "Aangepaste app-pictogrammen", + "pl": "Własne ikony aplikacji", + "ru": "Собственные значки приложения", + "tr": "Uygulamaya özel simgeler", + "uk": "Власні значки застосунку", + "zh_Hans": "自定义应用图标", + "zh_Hant": "自訂應用程式圖示" + }, + "Custom login page background": { + "de": "Eigener Hintergrund für die Login-Seite", + "es": "Fondo personalizado de la página de inicio de sesión", + "fr": "Arrière-plan personnalisé de la page de connexion", + "id": "Latar belakang khusus untuk halaman login", + "it": "Sfondo personalizzato della pagina di login", + "ja": "ログインページ用のカスタム背景", + "ko": "로그인 페이지의 사용자 지정 배경", + "nl": "Aangepaste achtergrond voor de inlogpagina", + "pl": "Własne tło strony logowania", + "ru": "Собственный фон страницы входа", + "tr": "Giriş sayfası için özel arka plan", + "uk": "Власний фон сторінки входу", + "zh_Hans": "自定义登录页背景图", + "zh_Hant": "自訂登入頁背景圖" + }, + "Custom logo": { + "de": "Eigenes Logo", + "es": "Logotipo personalizado", + "fr": "Logo personnalisé", + "id": "Logo khusus", + "it": "Logo personalizzato", + "ja": "カスタムロゴ", + "ko": "사용자 지정 로고", + "nl": "Aangepast logo", + "pl": "Własne logo", + "ru": "Собственный логотип", + "tr": "Özel logo", + "uk": "Власний логотип", + "zh_Hans": "自定义 Logo", + "zh_Hant": "自訂 Logo" + }, + "Custom site icons": { + "de": "Eigene Website-Symbole", + "es": "Iconos personalizados del sitio", + "fr": "Icônes personnalisées du site", + "id": "Ikon situs khusus", + "it": "Icone personalizzate del sito", + "ja": "サイト独自のアイコン", + "ko": "사이트 사용자 지정 아이콘", + "nl": "Aangepaste sitepictogrammen", + "pl": "Własne ikony witryny", + "ru": "Собственные значки сайта", + "tr": "Siteye özel simgeler", + "uk": "Власні значки сайту", + "zh_Hans": "自定义站点图标", + "zh_Hant": "自訂網站圖示" + }, + "Custom toolbar icons": { + "de": "Eigene Werkzeugleisten-Symbole", + "es": "Iconos personalizados de la barra de herramientas", + "fr": "Icônes personnalisées de la barre d'outils", + "id": "Ikon bilah alat khusus", + "it": "Icone personalizzate della barra degli strumenti", + "ja": "ツールバー独自のアイコン", + "ko": "도구 모음 사용자 지정 아이콘", + "nl": "Aangepaste werkbalkpictogrammen", + "pl": "Własne ikony paska narzędzi", + "ru": "Собственные значки панели инструментов", + "tr": "Araç çubuğuna özel simgeler", + "uk": "Власні значки панелі інструментів", + "zh_Hans": "自定义工具栏图标", + "zh_Hant": "自訂工具列圖示" + }, + "Dark": { + "de": "Dunkel", + "es": "Oscuro", + "fr": "Sombre", + "id": "Gelap", + "it": "Scuro", + "ja": "ダーク", + "ko": "다크", + "nl": "Donker", + "pl": "Ciemny", + "ru": "Тёмная", + "tr": "Koyu", + "uk": "Темна", + "zh_Hans": "深色", + "zh_Hant": "深色" + }, + "Derived Colors": { + "de": "Abgeleitete Farben", + "es": "Colores derivados", + "fr": "Couleurs dérivées", + "id": "Warna Turunan", + "it": "Colori derivati", + "ja": "派生カラー", + "ko": "파생 색상", + "nl": "Afgeleide kleuren", + "pl": "Kolory pochodne", + "ru": "Производные цвета", + "tr": "Türetilmiş Renkler", + "uk": "Похідні кольори", + "zh_Hans": "派生色", + "zh_Hant": "衍生色" + }, + "Design Studio": { + "de": "Designstudio", + "es": "Estudio de diseño", + "fr": "Studio de design", + "id": "Studio Desain", + "it": "Studio di design", + "ja": "デザインスタジオ", + "ko": "디자인 스튜디오", + "nl": "Ontwerpstudio", + "pl": "Studio projektowe", + "ru": "Студия дизайна", + "tr": "Tasarım Stüdyosu", + "uk": "Студія дизайну", + "zh_Hans": "设计工作台", + "zh_Hant": "設計工作區" + }, + "Details": { + "de": "Details", + "es": "Detalles", + "fr": "Détails", + "id": "Detail", + "it": "Dettagli", + "ja": "詳細", + "ko": "세부 정보", + "nl": "Details", + "pl": "Szczegóły", + "ru": "Подробности", + "tr": "Ayrıntılar", + "uk": "Подробиці", + "zh_Hans": "明细", + "zh_Hant": "明細" + }, + "Dismiss": { + "de": "Ausblenden", + "es": "Descartar", + "fr": "Ignorer", + "id": "Tutup", + "it": "Ignora", + "ja": "閉じる", + "ko": "닫기", + "nl": "Sluiten", + "pl": "Odrzuć", + "ru": "Скрыть", + "tr": "Kapat", + "uk": "Приховати", + "zh_Hans": "关闭", + "zh_Hant": "關閉" + }, + "Download": { + "de": "Herunterladen", + "es": "Descargar", + "fr": "Télécharger", + "id": "Unduh", + "it": "Scarica", + "ja": "ダウンロード", + "ko": "다운로드", + "nl": "Downloaden", + "pl": "Pobierz", + "ru": "Скачать", + "tr": "İndir", + "uk": "Завантажити", + "zh_Hans": "下载", + "zh_Hant": "下載" + }, + "Download backup file": { + "de": "Sicherungsdatei herunterladen", + "es": "Descargar archivo de respaldo", + "fr": "Télécharger le fichier de sauvegarde", + "id": "Unduh berkas cadangan", + "it": "Scarica il file di backup", + "ja": "バックアップファイルをダウンロード", + "ko": "백업 파일 내려받기", + "nl": "Back-upbestand downloaden", + "pl": "Pobierz plik kopii zapasowej", + "ru": "Скачать файл резервной копии", + "tr": "Yedek dosyasını indir", + "uk": "Завантажити файл резервної копії", + "zh_Hans": "下载备份文件", + "zh_Hant": "下載備份檔案" + }, + "File is %s, exceeding the 8MB limit.": { + "de": "Datei ist %s und überschreitet das Limit von 8 MB.", + "es": "El archivo ocupa %s y supera el límite de 8 MB.", + "fr": "Le fichier fait %s et dépasse la limite de 8 Mo.", + "id": "Berkas berukuran %s, melebihi batas 8 MB.", + "it": "Il file è di %s e supera il limite di 8 MB.", + "ja": "ファイルは %s で、上限の 8MB を超えています。", + "ko": "파일 크기가 %s로 8MB 제한을 초과합니다.", + "nl": "Bestand is %s en overschrijdt de limiet van 8 MB.", + "pl": "Plik ma %s i przekracza limit 8 MB.", + "ru": "Файл занимает %s, что превышает лимит 8 МБ.", + "tr": "Dosya %s boyutunda ve 8 MB sınırını aşıyor.", + "uk": "Файл займає %s, що перевищує ліміт 8 МБ.", + "zh_Hans": "文件为 %s,超过 8MB 的上限。", + "zh_Hant": "檔案為 %s,超過 8MB 的上限。" + }, + "Finishing…": { + "zh_Hans": "收尾中…", + "zh_Hant": "收尾中…", + "de": "Wird abgeschlossen…", + "es": "Finalizando…", + "fr": "Finalisation…", + "it": "Completamento…", + "nl": "Afronden…", + "pl": "Kończenie…", + "ru": "Завершение…", + "tr": "Tamamlanıyor…", + "uk": "Завершення…", + "id": "Menyelesaikan…", + "ja": "仕上げ中…", + "ko": "마무리 중…" + }, + "Floating toolbar shortcuts": { + "de": "Schwebende Symbolleisten-Verknüpfungen", + "es": "Accesos directos de la barra de herramientas flotante", + "fr": "Raccourcis de la barre d'outils flottante", + "id": "Pintasan bilah alat mengambang", + "it": "Scorciatoie della barra degli strumenti flottante", + "ja": "フローティングツールバーのショートカット", + "ko": "플로팅 도구 모음의 바로가기", + "nl": "Snelkoppelingen van de zwevende werkbalk", + "pl": "Skróty pływającego paska narzędzi", + "ru": "Ярлыки плавающей панели инструментов", + "tr": "Kayan araç çubuğu kısayolları", + "uk": "Ярлики плаваючої панелі інструментів", + "zh_Hans": "悬浮工具栏快捷方式", + "zh_Hant": "懸浮工具列的捷徑" + }, + "Fonts": { + "de": "Schriftarten", + "es": "Fuentes", + "fr": "Polices", + "id": "Font", + "it": "Font", + "ja": "フォント", + "ko": "글꼴", + "nl": "Lettertypen", + "pl": "Czcionki", + "ru": "Шрифты", + "tr": "Yazı tipleri", + "uk": "Шрифти", + "zh_Hans": "字体", + "zh_Hant": "字型" + }, + "From openwrt.eamonxg.fun; the signing key ships with this package.": { + "de": "Von openwrt.eamonxg.fun; der Signaturschlüssel ist in diesem Paket enthalten.", + "es": "Desde openwrt.eamonxg.fun; la clave de firma se incluye en este paquete.", + "fr": "Depuis openwrt.eamonxg.fun ; la clé de signature est fournie avec ce paquet.", + "id": "Dari openwrt.eamonxg.fun; kunci penandatanganan disertakan dalam paket ini.", + "it": "Da openwrt.eamonxg.fun; la chiave di firma è inclusa in questo pacchetto.", + "ja": "openwrt.eamonxg.fun から取得します。署名鍵は本パッケージに同梱されています。", + "ko": "openwrt.eamonxg.fun에서 가져옵니다. 서명 키는 이 패키지에 포함되어 있습니다.", + "nl": "Van openwrt.eamonxg.fun; de ondertekeningssleutel zit in dit pakket.", + "pl": "Z openwrt.eamonxg.fun; klucz podpisujący jest dołączony do tego pakietu.", + "ru": "С openwrt.eamonxg.fun; ключ подписи поставляется вместе с этим пакетом.", + "tr": "openwrt.eamonxg.fun adresinden; imzalama anahtarı bu pakete dâhildir.", + "uk": "З openwrt.eamonxg.fun; ключ підпису постачається разом із цим пакунком.", + "zh_Hans": "来自 openwrt.eamonxg.fun,签名公钥随本插件发布。", + "zh_Hant": "來自 openwrt.eamonxg.fun,簽章公鑰隨本套件發佈。" + }, + "Give it a name": { + "de": "Geben Sie ihm einen Namen", + "es": "Póngale un nombre", + "fr": "Donnez-lui un nom", + "id": "Beri nama", + "it": "Assegna un nome", + "ja": "名前を付ける", + "ko": "이름을 입력하세요", + "nl": "Geef het een naam", + "pl": "Nadaj nazwę", + "ru": "Дайте название", + "tr": "Bir ad verin", + "uk": "Дайте назву", + "zh_Hans": "起个名字", + "zh_Hant": "取個名字" + }, + "Go to Software": { + "de": "Zu Software", + "es": "Ir a Software", + "fr": "Aller à Logiciels", + "id": "Buka Perangkat Lunak", + "it": "Vai a Software", + "ja": "ソフトウェアへ", + "ko": "소프트웨어로 이동", + "nl": "Naar Software", + "pl": "Przejdź do Oprogramowania", + "ru": "Перейти к «Программам»", + "tr": "Yazılım'a git", + "uk": "Перейти до «Програм»", + "zh_Hans": "去软件包页面", + "zh_Hant": "前往軟體套件頁面" + }, + "Go to the design studio": { + "zh_Hans": "去主题工作台", + "zh_Hant": "去主題工作台", + "de": "Zum Design-Studio", + "es": "Ir al estudio de diseño", + "fr": "Aller au studio de design", + "it": "Vai allo studio di design", + "nl": "Naar de designstudio", + "pl": "Przejdź do studia projektowego", + "ru": "Перейти в студию дизайна", + "tr": "Tasarım stüdyosuna git", + "uk": "Перейти до студії дизайну", + "id": "Ke studio desain", + "ja": "デザインスタジオへ", + "ko": "디자인 스튜디오로" + }, + "Hidden": { + "de": "Ausgeblendet", + "es": "Oculta", + "fr": "Masquée", + "id": "Disembunyikan", + "it": "Nascosta", + "ja": "非表示", + "ko": "숨김", + "nl": "Verborgen", + "pl": "Ukryty", + "ru": "Скрыта", + "tr": "Gizli", + "uk": "Приховано", + "zh_Hans": "隐藏", + "zh_Hant": "隱藏" + }, + "Hide": { + "de": "Verbergen", + "es": "Ocultar", + "fr": "Masquer", + "id": "Sembunyikan", + "it": "Nascondi", + "ja": "隠す", + "ko": "숨기기", + "nl": "Verbergen", + "pl": "Ukryj", + "ru": "Скрыть", + "tr": "Gizle", + "uk": "Сховати", + "zh_Hans": "隐藏", + "zh_Hant": "隱藏" + }, + "How it looks in the store": { + "de": "So sieht es im Store aus", + "es": "Cómo se verá en la tienda", + "fr": "Son apparence dans la boutique", + "id": "Tampilannya di etalase", + "it": "Come apparirà nello store", + "ja": "ストアでの見え方", + "ko": "스토어에서 보이는 모습", + "nl": "Hoe het eruitziet in de winkel", + "pl": "Jak będzie wyglądać w sklepie", + "ru": "Как это будет выглядеть в магазине", + "tr": "Mağazada nasıl görünecek", + "uk": "Як це виглядатиме в магазині", + "zh_Hans": "在商店里的样子", + "zh_Hant": "在商店裡的樣子" + }, + "How this router looked before you applied a theme from the store.": { + "de": "So sah dieser Router aus, bevor du ein Theme aus dem Store angewendet hast.", + "es": "Así se veía este router antes de que aplicaras un tema de la tienda.", + "fr": "L'aspect de ce routeur avant que vous n'appliquiez un thème de la boutique.", + "id": "Tampilan router ini sebelum kamu menerapkan tema dari toko.", + "it": "Com'era questo router prima che applicassi un tema dallo store.", + "ja": "ストアのテーマを適用する前の、このルーターの見た目です。", + "ko": "스토어의 테마를 적용하기 전 이 공유기의 모습입니다.", + "nl": "Zo zag deze router eruit voordat je een thema uit de store toepaste.", + "pl": "Tak wyglądał ten router, zanim zastosowałeś motyw ze sklepu.", + "ru": "Так этот роутер выглядел до применения темы из магазина.", + "tr": "Mağazadan bir tema uygulamadan önce bu yönlendirici böyle görünüyordu.", + "uk": "Так цей роутер виглядав до застосування теми з магазину.", + "zh_Hans": "你在商店里应用主题之前,这台路由器的样子。", + "zh_Hant": "你在商店中套用主題之前,這台路由器的樣子。" + }, + "Icons": { + "de": "Symbole", + "es": "Iconos", + "fr": "Icônes", + "id": "Ikon", + "it": "Icone", + "ja": "アイコン", + "ko": "아이콘", + "nl": "Pictogrammen", + "pl": "Ikony", + "ru": "Значки", + "tr": "Simgeler", + "uk": "Значки", + "zh_Hans": "图标", + "zh_Hant": "圖示" + }, + "Images not approved — colours and layout still work. Swap the image, then update.": { + "de": "Bilder nicht freigegeben – Farben und Layout funktionieren weiterhin. Tausche das Bild und aktualisiere.", + "es": "Imágenes no aprobadas: los colores y el diseño siguen funcionando. Cambia la imagen y actualiza.", + "fr": "Images refusées — les couleurs et la mise en page fonctionnent toujours. Changez l'image, puis mettez à jour.", + "id": "Gambar tidak disetujui — warna dan tata letak tetap berfungsi. Ganti gambarnya, lalu perbarui.", + "it": "Immagini non approvate: colori e layout funzionano ancora. Sostituisci l'immagine e aggiorna.", + "ja": "画像は承認されませんでした — 配色とレイアウトはそのまま使えます。画像を差し替えて更新してください。", + "ko": "이미지가 승인되지 않았습니다 — 색상과 레이아웃은 그대로 작동합니다. 이미지를 바꾼 뒤 업데이트하세요.", + "nl": "Afbeeldingen afgewezen — kleuren en lay-out werken nog steeds. Vervang de afbeelding en werk bij.", + "pl": "Obrazy odrzucone — kolory i układ nadal działają. Podmień obraz i zaktualizuj.", + "ru": "Изображения отклонены — цвета и макет по-прежнему работают. Замените изображение и обновите.", + "tr": "Görseller onaylanmadı — renkler ve düzen hâlâ çalışıyor. Görseli değiştirip güncelle.", + "uk": "Зображення відхилено — кольори та макет усе ще працюють. Замініть зображення й оновіть.", + "zh_Hans": "图片未通过审核 —— 配色和布局仍然可用。换一张图,然后更新。", + "zh_Hant": "圖片未通過審核 —— 配色和版面仍然可用。換一張圖,然後更新。" + }, + "Images that came with a theme from the store — logo, icons, backgrounds — are removed. Your shortcuts, and anything you uploaded yourself, are left exactly as they are.": { + "de": "Bilder, die mit einem Theme aus dem Store gekommen sind – Logo, Symbole, Hintergründe – werden entfernt. Ihre Verknüpfungen und alles, was Sie selbst hochgeladen haben, bleiben genau so, wie sie sind.", + "es": "Las imágenes que llegaron con un tema de la tienda —logotipo, iconos, fondos— se eliminan. Tus accesos directos, y todo lo que hayas subido tú, se quedan exactamente como están.", + "fr": "Les images arrivées avec un thème de la boutique — logo, icônes, arrière-plans — sont supprimées. Vos raccourcis, et tout ce que vous avez importé vous-même, restent exactement tels quels.", + "id": "Gambar yang datang bersama tema dari toko — logo, ikon, latar belakang — akan dihapus. Pintasan Anda, dan apa pun yang Anda unggah sendiri, dibiarkan persis seperti semula.", + "it": "Le immagini arrivate con un tema dallo store — logo, icone, sfondi — vengono rimosse. Le tue scorciatoie, e tutto ciò che hai caricato tu, restano esattamente come sono.", + "ja": "ストアのテーマと一緒に入ってきた画像(ロゴ・アイコン・背景)は削除されます。ショートカットと、ご自身でアップロードしたものはそのまま残ります。", + "ko": "스토어 테마와 함께 들어온 이미지(로고, 아이콘, 배경)는 제거됩니다. 바로가기와 직접 업로드한 항목은 그대로 유지됩니다.", + "nl": "Afbeeldingen die met een thema uit de winkel zijn meegekomen — logo, pictogrammen, achtergronden — worden verwijderd. Uw snelkoppelingen, en alles wat u zelf hebt geüpload, blijven precies zoals ze zijn.", + "pl": "Obrazy, które przyszły z motywem ze sklepu — logo, ikony, tła — zostaną usunięte. Twoje skróty oraz wszystko, co wgrałeś samodzielnie, pozostaną dokładnie takie, jakie są.", + "ru": "Изображения, пришедшие вместе с темой из магазина — логотип, значки, фоны — будут удалены. Ваши ярлыки и всё, что вы загрузили сами, останутся ровно такими, как есть.", + "tr": "Mağazadan gelen bir temayla birlikte inen görseller — logo, simgeler, arka planlar — kaldırılır. Kısayollarınız ve kendi yüklediğiniz her şey aynen olduğu gibi kalır.", + "uk": "Зображення, що прийшли разом із темою з магазину — логотип, піктограми, фони — буде вилучено. Ваші ярлики та все, що ви завантажили самі, лишаться точно такими, як є.", + "zh_Hans": "随商店主题装进来的图片——logo、图标、背景——都会被移除。你的快捷方式,以及你自己上传的图片,都原封不动。", + "zh_Hant": "隨商店主題裝進來的圖片——logo、圖示、背景——都會被移除。你的捷徑,以及你自己上傳的圖片,都原封不動。" + }, + "Images: %s in total.": { + "de": "Bilder: %s insgesamt.", + "es": "Imágenes: %s en total.", + "fr": "Images : %s au total.", + "id": "Gambar: %s seluruhnya.", + "it": "Immagini: %s in totale.", + "ja": "画像:合計 %s。", + "ko": "이미지: 총 %s.", + "nl": "Afbeeldingen: %s in totaal.", + "pl": "Obrazy: łącznie %s.", + "ru": "Изображения: всего %s.", + "tr": "Görseller: toplam %s.", + "uk": "Зображення: усього %s.", + "zh_Hans": "图片共 %s。", + "zh_Hant": "圖片共 %s。" + }, + "Import a creator key": { + "de": "Ersteller-Schlüssel importieren", + "es": "Importar una clave de creador", + "fr": "Importer une clé de créateur", + "id": "Impor kunci kreator", + "it": "Importa una chiave del creatore", + "ja": "クリエイターキーをインポート", + "ko": "크리에이터 키 가져오기", + "nl": "Een makerssleutel importeren", + "pl": "Zaimportuj klucz twórcy", + "ru": "Импортировать ключ автора", + "tr": "Oluşturucu anahtarı içe aktar", + "uk": "Імпортувати ключ автора", + "zh_Hans": "导入创作者密钥", + "zh_Hant": "匯入創作者金鑰" + }, + "Import a key": { + "de": "Schlüssel importieren", + "es": "Importar una clave", + "fr": "Importer une clé", + "id": "Impor kunci", + "it": "Importa una chiave", + "ja": "キーをインポート", + "ko": "키 가져오기", + "nl": "Sleutel importeren", + "pl": "Zaimportuj klucz", + "ru": "Импортировать ключ", + "tr": "Anahtar içe aktar", + "uk": "Імпортувати ключ", + "zh_Hans": "导入密钥", + "zh_Hant": "匯入金鑰" + }, + "In review — appears in the store once its images are approved.": { + "de": "In Prüfung – erscheint im Store, sobald die Bilder freigegeben sind.", + "es": "En revisión: aparece en la tienda cuando se aprueben sus imágenes.", + "fr": "En cours d'examen — apparaît dans la boutique une fois ses images approuvées.", + "id": "Sedang ditinjau — muncul di toko setelah gambarnya disetujui.", + "it": "In revisione: compare nello store una volta approvate le sue immagini.", + "ja": "審査中 — 画像が承認されるとストアに表示されます。", + "ko": "검토 중 — 이미지가 승인되면 스토어에 표시됩니다.", + "nl": "In beoordeling — verschijnt in de store zodra de afbeeldingen zijn goedgekeurd.", + "pl": "W trakcie weryfikacji — pojawi się w sklepie, gdy obrazy zostaną zatwierdzone.", + "ru": "На проверке — появится в магазине, когда изображения одобрят.", + "tr": "İncelemede — görselleri onaylandığında mağazada görünür.", + "uk": "На перевірці — з'явиться в магазині, коли зображення схвалять.", + "zh_Hans": "审核中 —— 图片通过后才会出现在商店里。", + "zh_Hant": "審核中 —— 圖片通過後才會出現在商店裡。" + }, + "In use": { + "de": "In Verwendung", + "es": "En uso", + "fr": "Utilisé", + "id": "Sedang dipakai", + "it": "In uso", + "ja": "使用中", + "ko": "사용 중", + "nl": "In gebruik", + "pl": "W użyciu", + "ru": "Используется", + "tr": "Kullanımda", + "uk": "Використовується", + "zh_Hans": "正在使用", + "zh_Hant": "正在使用" + }, + "Included, %s": { + "de": "Enthalten, %s", + "es": "Incluido, %s", + "fr": "Inclus, %s", + "id": "Disertakan, %s", + "it": "Incluso, %s", + "ja": "含む、%s", + "ko": "포함됨, %s", + "nl": "Inbegrepen, %s", + "pl": "Uwzględnione, %s", + "ru": "Включено, %s", + "tr": "Dahil, %s", + "uk": "Включено, %s", + "zh_Hans": "包含,%s", + "zh_Hant": "包含,%s" + }, + "Includes %s of font files. They are stored in the router's writable partition, which is shared with installed packages — on devices with little flash, check the free space first. Fonts are not kept across a firmware upgrade and have to be applied again.": { + "de": "Enthält %s an Schriftdateien. Sie liegen auf der beschreibbaren Partition des Routers, die sich den Platz mit installierten Paketen teilt — prüfe bei Geräten mit wenig Flash zuerst den freien Speicher. Schriften überstehen ein Firmware-Update nicht und müssen danach erneut angewendet werden.", + "es": "Incluye %s de archivos de fuente. Se guardan en la partición escribible del router, compartida con los paquetes instalados: en dispositivos con poca flash, comprueba antes el espacio libre. Las fuentes no se conservan al actualizar el firmware y hay que volver a aplicarlas.", + "fr": "Comprend %s de fichiers de police. Ils sont stockés sur la partition inscriptible du routeur, partagée avec les paquets installés — sur les appareils à faible mémoire flash, vérifiez d'abord l'espace libre. Les polices ne survivent pas à une mise à jour du firmware et doivent être réappliquées.", + "id": "Menyertakan %s berkas font. Berkas disimpan di partisi yang dapat ditulis pada router, yang dipakai bersama paket terpasang — pada perangkat dengan flash kecil, periksa dulu sisa ruangnya. Font tidak bertahan setelah pembaruan firmware dan harus diterapkan ulang.", + "it": "Include %s di file dei caratteri. Vengono salvati nella partizione scrivibile del router, condivisa con i pacchetti installati: sui dispositivi con poca flash controlla prima lo spazio libero. I caratteri non sopravvivono a un aggiornamento del firmware e vanno riapplicati.", + "ja": "%s のフォントファイルを含みます。ルーターの書き込み可能領域に保存され、その領域はインストール済みパッケージと共用です — フラッシュの少ない機器では先に空き容量を確認してください。フォントはファームウェア更新では引き継がれず、再度適用が必要です。", + "ko": "%s의 글꼴 파일이 포함됩니다. 공유기의 쓰기 가능 파티션에 저장되며, 이 공간은 설치된 패키지와 함께 씁니다 — 플래시가 작은 기기에서는 남은 공간을 먼저 확인하세요. 글꼴은 펌웨어 업그레이드 후에는 남지 않으므로 다시 적용해야 합니다.", + "nl": "Bevat %s aan lettertypebestanden. Ze staan op de beschrijfbare partitie van de router, die gedeeld wordt met geïnstalleerde pakketten — controleer op apparaten met weinig flash eerst de vrije ruimte. Lettertypen blijven niet behouden bij een firmware-update en moeten opnieuw worden toegepast.", + "pl": "Zawiera %s plików czcionek. Są przechowywane na zapisywalnej partycji routera, współdzielonej z zainstalowanymi pakietami — na urządzeniach z małą pamięcią flash sprawdź najpierw wolne miejsce. Czcionki nie przetrwają aktualizacji firmware'u i trzeba je zastosować ponownie.", + "ru": "Включает %s файлов шрифтов. Они хранятся в записываемом разделе роутера, который делит место с установленными пакетами — на устройствах с малым объёмом flash сначала проверьте свободное место. Шрифты не сохраняются при обновлении прошивки, их придётся применить заново.", + "tr": "%s yazı tipi dosyası içerir. Dosyalar yönlendiricinin yazılabilir bölümünde tutulur ve bu alan kurulu paketlerle paylaşılır — flash belleği az olan cihazlarda önce boş alanı kontrol edin. Yazı tipleri bellenim güncellemesinden sonra kalmaz, yeniden uygulanmaları gerekir.", + "uk": "Містить %s файлів шрифтів. Вони зберігаються в записуваному розділі маршрутизатора, який ділить місце зі встановленими пакунками — на пристроях із малим обсягом flash спершу перевірте вільне місце. Шрифти не зберігаються після оновлення прошивки, їх доведеться застосувати знову.", + "zh_Hans": "包含 %s 的字体文件。字体存放在路由器的可写分区,与已安装的软件包共用空间——闪存较小的设备请先确认剩余空间。固件升级后字体不会保留,需要重新应用。", + "zh_Hant": "包含 %s 的字型檔。字型存放在路由器的可寫分割區,與已安裝的套件共用空間——快閃記憶體較小的裝置請先確認剩餘空間。韌體升級後字型不會保留,需要重新套用。" + }, + "Includes custom content": { + "de": "Enthält eigene Inhalte", + "es": "Incluye contenido personalizado", + "fr": "Comprend du contenu personnalisé", + "id": "Berisi konten khusus", + "it": "Include contenuti personalizzati", + "ja": "カスタム要素を含む", + "ko": "맞춤 콘텐츠 포함", + "nl": "Bevat aangepaste inhoud", + "pl": "Zawiera własną zawartość", + "ru": "Содержит собственные материалы", + "tr": "Özel içerik içerir", + "uk": "Містить власний вміст", + "zh_Hans": "含自定义内容", + "zh_Hant": "含自訂內容" + }, + "It exists only on this router. Reflashing without keeping settings erases it.": { + "de": "Er existiert nur auf diesem Router. Ein Neuflashen ohne Beibehalten der Einstellungen löscht ihn.", + "es": "Solo existe en este router. Reinstalar el firmware sin conservar la configuración la borra.", + "fr": "Elle n'existe que sur ce routeur. Réinstaller le firmware sans conserver la configuration l'efface.", + "id": "Kunci ini hanya ada di router ini. Flash ulang tanpa menyimpan pengaturan akan menghapusnya.", + "it": "Esiste solo su questo router. Riflashare senza conservare le impostazioni la cancella.", + "ja": "このルーターにしか存在しません。設定を残さずに再フラッシュすると消えます。", + "ko": "이 공유기에만 있습니다. 설정을 유지하지 않고 다시 플래시하면 사라집니다.", + "nl": "Hij bestaat alleen op deze router. Opnieuw flashen zonder instellingen te behouden wist hem.", + "pl": "Istnieje tylko na tym routerze. Ponowne wgranie firmware bez zachowania ustawień go usunie.", + "ru": "Он существует только на этом роутере. Перепрошивка без сохранения настроек удалит его.", + "tr": "Yalnızca bu yönlendiricide bulunur. Ayarları korumadan yeniden yüklemek onu siler.", + "uk": "Він існує лише на цьому роутері. Перепрошивка без збереження налаштувань видалить його.", + "zh_Hans": "它只存在于这台路由器上。刷机时不保留配置就会没。", + "zh_Hant": "它只存在於這台路由器上。刷機時不保留設定就會消失。" + }, + "Large corners": { + "de": "Stark abgerundete Ecken", + "es": "Esquinas muy redondeadas", + "fr": "Coins très arrondis", + "id": "Sudut sangat membulat", + "it": "Angoli molto arrotondati", + "ja": "大きな角丸", + "ko": "많이 둥근 모서리", + "nl": "Sterk afgeronde hoeken", + "pl": "Mocno zaokrąglone rogi", + "ru": "Сильно скруглённые углы", + "tr": "Belirgin yuvarlak köşeler", + "uk": "Сильно скруглені кути", + "zh_Hans": "大圆角", + "zh_Hant": "大圓角" + }, + "Layout & Typography": { + "de": "Layout & Typografie", + "es": "Diseño y tipografía", + "fr": "Mise en page et typographie", + "id": "Tata Letak & Tipografi", + "it": "Layout e tipografia", + "ja": "レイアウトと書体", + "ko": "레이아웃 및 서체", + "nl": "Lay-out en typografie", + "pl": "Układ i typografia", + "ru": "Макет и типографика", + "tr": "Düzen ve tipografi", + "uk": "Макет і типографіка", + "zh_Hans": "布局与排版", + "zh_Hant": "版面與排版" + }, + "Layout (unchanged by this preset)": { + "de": "Layout (von dieser Vorlage nicht verändert)", + "es": "Diseño (este preajuste no lo cambia)", + "fr": "Mise en page (inchangée par ce préréglage)", + "id": "Tata letak (tidak diubah oleh preset ini)", + "it": "Layout (non modificato da questo preset)", + "ja": "レイアウト(このプリセットでは変更されません)", + "ko": "레이아웃 (이 프리셋은 변경하지 않음)", + "nl": "Lay-out (wordt niet gewijzigd door deze preset)", + "pl": "Układ (ten zestaw go nie zmienia)", + "ru": "Макет (этот набор его не меняет)", + "tr": "Düzen (bu ön ayar değiştirmez)", + "uk": "Макет (цей набір його не змінює)", + "zh_Hans": "布局(本预设不改动)", + "zh_Hant": "版面(本預設不變更)" + }, + "Light": { + "de": "Hell", + "es": "Claro", + "fr": "Clair", + "id": "Terang", + "it": "Chiaro", + "ja": "ライト", + "ko": "라이트", + "nl": "Licht", + "pl": "Jasny", + "ru": "Светлая", + "tr": "Açık", + "uk": "Світла", + "zh_Hans": "浅色", + "zh_Hant": "淺色" + }, + "Light and dark, 62 values": { + "de": "Hell und dunkel, 62 Werte", + "es": "Claro y oscuro, 62 valores", + "fr": "Clair et sombre, 62 valeurs", + "id": "Terang dan gelap, 62 nilai", + "it": "Chiaro e scuro, 62 valori", + "ja": "ライト/ダーク、62 項目", + "ko": "라이트・다크, 62개 값", + "nl": "Licht en donker, 62 waarden", + "pl": "Jasny i ciemny, 62 wartości", + "ru": "Светлая и тёмная, 62 значения", + "tr": "Açık ve koyu, 62 değer", + "uk": "Світла і темна, 62 значення", + "zh_Hans": "浅色 + 深色,共 62 项", + "zh_Hant": "淺色 + 深色,共 62 項" + }, + "Links marked ↗ open sites outside your router. You'll be asked to confirm before applying.": { + "de": "Mit ↗ markierte Links öffnen Seiten außerhalb Ihres Routers. Vor dem Anwenden werden Sie um Bestätigung gebeten.", + "es": "Los enlaces marcados con ↗ abren sitios fuera de su router. Se le pedirá confirmación antes de aplicar.", + "fr": "Les liens marqués ↗ ouvrent des sites extérieurs à votre routeur. Une confirmation vous sera demandée avant application.", + "id": "Tautan bertanda ↗ membuka situs di luar router Anda. Anda akan diminta konfirmasi sebelum menerapkan.", + "it": "I link contrassegnati con ↗ aprono siti esterni al router. Ti verrà chiesta conferma prima di applicare.", + "ja": "↗ の付いたリンクはルーター外のサイトを開きます。適用前に確認を求められます。", + "ko": "↗ 표시가 있는 링크는 라우터 외부 사이트를 엽니다. 적용하기 전에 확인을 요청합니다.", + "nl": "Links met ↗ openen sites buiten uw router. U wordt om bevestiging gevraagd voordat er wordt toegepast.", + "pl": "Odnośniki oznaczone ↗ otwierają witryny poza routerem. Przed zastosowaniem zostaniesz poproszony o potwierdzenie.", + "ru": "Ссылки со значком ↗ ведут на сайты за пределами роутера. Перед применением потребуется подтверждение.", + "tr": "↗ işaretli bağlantılar yönlendiricinizin dışındaki siteleri açar. Uygulamadan önce onayınız istenir.", + "uk": "Посилання зі знаком ↗ ведуть на сайти поза межами роутера. Перед застосуванням буде запит на підтвердження.", + "zh_Hans": "标 ↗ 的会打开路由器以外的网站,应用前会再确认一次。", + "zh_Hant": "標 ↗ 的會開啟路由器以外的網站,套用前會再確認一次。" + }, + "Mine": { + "de": "Meine", + "es": "Mías", + "fr": "Les miennes", + "id": "Milik saya", + "it": "I miei", + "ja": "自分", + "ko": "내 것", + "nl": "Mijn", + "pl": "Moje", + "ru": "Мои", + "tr": "Benimkiler", + "uk": "Мої", + "zh_Hans": "我的", + "zh_Hant": "我的" + }, + "Mono Font File": { + "de": "Schriftdatei (Mono)", + "es": "Archivo de fuente monoespaciada", + "fr": "Fichier de police monospace", + "id": "Berkas font monospace", + "it": "File del carattere monospaziato", + "ja": "等幅フォントファイル", + "ko": "고정폭 글꼴 파일", + "nl": "Monospace-lettertypebestand", + "pl": "Plik czcionki o stałej szerokości", + "ru": "Файл моноширинного шрифта", + "tr": "Eş aralıklı yazı tipi dosyası", + "uk": "Файл моноширинного шрифту", + "zh_Hans": "等宽字体文件", + "zh_Hant": "等寬字型檔" + }, + "My Shares": { + "de": "Meine Freigaben", + "es": "Mis publicaciones", + "fr": "Mes partages", + "id": "Bagikan saya", + "it": "Le mie condivisioni", + "ja": "自分の共有", + "ko": "내 공유", + "nl": "Mijn gedeelde items", + "pl": "Moje udostępnione", + "ru": "Мои публикации", + "tr": "Paylaşımlarım", + "uk": "Мої публікації", + "zh_Hans": "我的分享", + "zh_Hant": "我的分享" + }, + "My configuration": { + "de": "Meine Konfiguration", + "es": "Mi configuración", + "fr": "Ma configuration", + "id": "Konfigurasi saya", + "it": "La mia configurazione", + "ja": "自分の設定", + "ko": "내 설정", + "nl": "Mijn configuratie", + "pl": "Moja konfiguracja", + "ru": "Моя конфигурация", + "tr": "Yapılandırmam", + "uk": "Моя конфігурація", + "zh_Hans": "我的配置", + "zh_Hant": "我的設定" + }, + "My creator identity": { + "de": "Meine Ersteller-Identität", + "es": "Mi identidad de creador", + "fr": "Mon identité de créateur", + "id": "Identitas kreator saya", + "it": "La mia identità di creatore", + "ja": "作成者としての自分", + "ko": "내 제작자 신원", + "nl": "Mijn makersidentiteit", + "pl": "Moja tożsamość twórcy", + "ru": "Моя личность автора", + "tr": "Yaratıcı kimliğim", + "uk": "Моя особистість автора", + "zh_Hans": "我的创作者身份", + "zh_Hant": "我的創作者身分" + }, + "No creator identity yet": { + "de": "Noch keine Ersteller-Identität", + "es": "Aún no hay identidad de creador", + "fr": "Pas encore d'identité de créateur", + "id": "Belum ada identitas kreator", + "it": "Nessuna identità di autore", + "ja": "作成者 ID はまだありません", + "ko": "아직 제작자 ID가 없습니다", + "nl": "Nog geen makersidentiteit", + "pl": "Brak tożsamości twórcy", + "ru": "Личности автора пока нет", + "tr": "Henüz oluşturan kimliği yok", + "uk": "Особистості автора ще немає", + "zh_Hans": "还没有创作者身份", + "zh_Hant": "還沒有創作者身分" + }, + "Not backed up": { + "de": "Nicht gesichert", + "es": "Sin copia de seguridad", + "fr": "Non sauvegardée", + "id": "Belum dicadangkan", + "it": "Senza backup", + "ja": "未バックアップ", + "ko": "백업 안 됨", + "nl": "Geen back-up", + "pl": "Bez kopii zapasowej", + "ru": "Нет резервной копии", + "tr": "Yedeklenmedi", + "uk": "Немає резервної копії", + "zh_Hans": "未备份", + "zh_Hant": "未備份" + }, + "Not in use": { + "de": "Nicht in Verwendung", + "es": "Sin usar", + "fr": "Non utilisé", + "id": "Tidak dipakai", + "it": "Non in uso", + "ja": "未使用", + "ko": "사용 안 함", + "nl": "Niet in gebruik", + "pl": "Nieużywane", + "ru": "Не используется", + "tr": "Kullanımda değil", + "uk": "Не використовується", + "zh_Hans": "未在使用", + "zh_Hant": "未在使用" + }, + "Not named yet": { + "de": "Noch kein Name", + "es": "Aún sin nombre", + "fr": "Pas encore nommé", + "id": "Belum diberi nama", + "it": "Ancora senza nome", + "ja": "名前未設定", + "ko": "이름 없음", + "nl": "Nog geen naam", + "pl": "Jeszcze bez nazwy", + "ru": "Имя не задано", + "tr": "Henüz adlandırılmadı", + "uk": "Ще без імені", + "zh_Hans": "还没取名", + "zh_Hant": "還沒取名" + }, + "Nothing shared yet": { + "de": "Noch nichts geteilt", + "es": "Aún no has compartido nada", + "fr": "Rien de partagé pour l'instant", + "id": "Belum ada yang dibagikan", + "it": "Non hai ancora condiviso nulla", + "ja": "まだ何も共有していません", + "ko": "아직 공유한 것이 없습니다", + "nl": "Nog niets gedeeld", + "pl": "Nic jeszcze nie udostępniono", + "ru": "Пока ничего не опубликовано", + "tr": "Henüz bir şey paylaşmadın", + "uk": "Поки нічого не опубліковано", + "zh_Hans": "还没有分享过", + "zh_Hant": "還沒有分享過" + }, + "Nothing shared yet — publish your current configuration to see it here.": { + "de": "Noch nichts geteilt — veröffentlichen Sie Ihre aktuelle Konfiguration, dann erscheint sie hier.", + "es": "Aún no has compartido nada: publica tu configuración actual y aparecerá aquí.", + "fr": "Rien de partagé pour l'instant — publiez votre configuration actuelle et elle apparaîtra ici.", + "id": "Belum ada yang dibagikan — terbitkan konfigurasi Anda saat ini dan akan muncul di sini.", + "it": "Non hai ancora condiviso nulla: pubblica la tua configurazione attuale e comparirà qui.", + "ja": "まだ何も共有していません。現在の設定を公開するとここに表示されます。", + "ko": "아직 공유한 것이 없습니다. 현재 구성을 게시하면 여기에 표시됩니다.", + "nl": "Nog niets gedeeld — publiceer uw huidige configuratie, dan verschijnt die hier.", + "pl": "Jeszcze nic nie udostępniono — opublikuj bieżącą konfigurację, a pojawi się tutaj.", + "ru": "Вы пока ничем не поделились — опубликуйте текущую конфигурацию, и она появится здесь.", + "tr": "Henüz bir şey paylaşmadınız — mevcut yapılandırmanızı yayınlayın, burada görünecek.", + "uk": "Ви поки нічим не поділилися — опублікуйте поточну конфігурацію, і вона з'явиться тут.", + "zh_Hans": "还没有分享过——发布你当前的配置,它就会出现在这里。", + "zh_Hant": "還沒有分享過——發佈你目前的設定,它就會出現在這裡。" + }, + "Nothing shared yet — publish your current configuration, or import a creator key to bring back what you shared before.": { + "de": "Noch nichts geteilt — veröffentliche deine aktuelle Konfiguration oder importiere einen Ersteller-Schlüssel, um früher Geteiltes zurückzuholen.", + "es": "Aún no has compartido nada: publica tu configuración actual o importa una clave de creador para recuperar lo que compartiste antes.", + "fr": "Rien de partagé pour l'instant — publiez votre configuration actuelle, ou importez une clé de créateur pour retrouver ce que vous aviez partagé.", + "id": "Belum ada yang dibagikan — bagikan konfigurasi Anda saat ini, atau impor kunci kreator untuk mengembalikan yang pernah dibagikan.", + "it": "Non hai ancora condiviso nulla: pubblica la configurazione attuale, oppure importa una chiave del creatore per recuperare quanto avevi condiviso.", + "ja": "まだ何も共有していません。現在の設定を公開するか、クリエイターキーをインポートして以前の共有を取り戻せます。", + "ko": "아직 공유한 것이 없습니다. 지금 설정을 공유하거나, 크리에이터 키를 가져와 예전에 공유한 것을 되찾으세요.", + "nl": "Nog niets gedeeld — publiceer je huidige configuratie, of importeer een makerssleutel om terug te halen wat je eerder deelde.", + "pl": "Nic jeszcze nie udostępniono — opublikuj bieżącą konfigurację albo zaimportuj klucz twórcy, aby odzyskać wcześniejsze udostępnienia.", + "ru": "Пока ничего не опубликовано — опубликуйте текущую конфигурацию или импортируйте ключ автора, чтобы вернуть опубликованное ранее.", + "tr": "Henüz bir paylaşım yok — mevcut yapılandırmanızı yayınlayın ya da daha önce paylaştıklarınızı geri getirmek için bir oluşturucu anahtarı içe aktarın.", + "uk": "Ще нічого не опубліковано — опублікуйте поточну конфігурацію або імпортуйте ключ автора, щоб повернути раніше опубліковане.", + "zh_Hans": "还没有分享过 — 发布当前配置,或者导入创作者密钥找回以前分享的。", + "zh_Hant": "還沒有分享過 — 發布目前設定,或者匯入創作者金鑰找回以前分享的。" + }, + "Nothing shared yet. Publish the configuration above and it shows up here.": { + "de": "Noch nichts geteilt. Veröffentliche die Konfiguration oben, dann erscheint sie hier.", + "es": "Aún no has compartido nada. Publica la configuración de arriba y aparecerá aquí.", + "fr": "Rien de partagé pour l'instant. Publiez la configuration ci-dessus et elle apparaîtra ici.", + "id": "Belum ada yang dibagikan. Publikasikan konfigurasi di atas dan akan muncul di sini.", + "it": "Non hai ancora condiviso nulla. Pubblica la configurazione qui sopra e comparirà qui.", + "ja": "まだ何も共有していません。上の設定を公開すると、ここに表示されます。", + "ko": "아직 공유한 것이 없습니다. 위의 구성을 게시하면 여기에 표시됩니다.", + "nl": "Nog niets gedeeld. Publiceer de configuratie hierboven en die verschijnt hier.", + "pl": "Nic jeszcze nie udostępniono. Opublikuj konfigurację powyżej, a pojawi się tutaj.", + "ru": "Вы ещё ничего не опубликовали. Опубликуйте конфигурацию выше — она появится здесь.", + "tr": "Henüz bir şey paylaşmadınız. Yukarıdaki yapılandırmayı yayımlayın, burada görünecek.", + "uk": "Ви ще нічого не опублікували. Опублікуйте конфігурацію вище — вона з'явиться тут.", + "zh_Hans": "还没有分享过。把上面的配置发布出去,它就会出现在这里。", + "zh_Hant": "還沒有分享過。把上面的設定發布出去,它就會出現在這裡。" + }, + "On this router only": { + "de": "Nur auf diesem Router", + "es": "Solo en este router", + "fr": "Uniquement sur ce routeur", + "id": "Hanya di router ini", + "it": "Solo su questo router", + "ja": "このルーターだけにあります", + "ko": "이 공유기에만 있음", + "nl": "Alleen op deze router", + "pl": "Tylko na tym routerze", + "ru": "Только на этом роутере", + "tr": "Yalnızca bu yönlendiricide", + "uk": "Лише на цьому роутері", + "zh_Hans": "仅存在于这台路由器", + "zh_Hant": "僅存在於這台路由器" + }, + "Once published, anyone can find it and apply it in one click. You can update or remove it later.": { + "de": "Nach dem Veröffentlichen kann sie jeder finden und mit einem Klick übernehmen. Du kannst sie später aktualisieren oder entfernen.", + "es": "Una vez publicada, cualquiera puede encontrarla y aplicarla con un clic. Puedes actualizarla o quitarla más adelante.", + "fr": "Une fois publiée, n’importe qui peut la trouver et l’appliquer en un clic. Vous pourrez la mettre à jour ou la retirer plus tard.", + "id": "Setelah diterbitkan, siapa pun bisa menemukannya dan menerapkannya sekali klik. Kamu bisa memperbaruinya atau menghapusnya nanti.", + "it": "Una volta pubblicata, chiunque può trovarla e applicarla con un clic. Puoi aggiornarla o rimuoverla in seguito.", + "ja": "公開すると誰でも見つけてワンクリックで適用できます。あとから更新も削除もできます。", + "ko": "게시하면 누구나 찾아서 한 번의 클릭으로 적용할 수 있습니다. 나중에 수정하거나 삭제할 수 있습니다.", + "nl": "Eenmaal gepubliceerd kan iedereen hem vinden en met één klik toepassen. Je kunt hem later bijwerken of verwijderen.", + "pl": "Po opublikowaniu każdy może ją znaleźć i zastosować jednym kliknięciem. Później możesz ją zaktualizować albo usunąć.", + "ru": "После публикации её сможет найти и применить в один клик кто угодно. Позже вы сможете обновить или удалить её.", + "tr": "Yayımladıktan sonra herkes bulup tek tıkla uygulayabilir. Daha sonra güncelleyebilir veya kaldırabilirsin.", + "uk": "Після публікації її зможе знайти й застосувати одним кліком будь-хто. Пізніше ви зможете оновити або прибрати її.", + "zh_Hans": "发布后所有人都能搜到并一键套用;你随时可以更新或删除。", + "zh_Hant": "發布後所有人都能搜到並一鍵套用;你隨時可以更新或刪除。" + }, + "One line about where this configuration fits.": { + "de": "Ein Satz dazu, wofür diese Konfiguration gedacht ist.", + "es": "Una línea sobre para qué sirve esta configuración.", + "fr": "Une ligne sur l'usage de cette configuration.", + "id": "Satu kalimat tentang kegunaan konfigurasi ini.", + "it": "Una riga su dove si colloca questa configurazione.", + "ja": "このコンフィグが向いている場面を一言で。", + "ko": "이 구성이 어울리는 상황을 한 줄로.", + "nl": "Eén regel over waar deze configuratie voor past.", + "pl": "Jedno zdanie o tym, do czego pasuje ta konfiguracja.", + "ru": "Одна строка о том, для чего подходит эта конфигурация.", + "tr": "Bu yapılandırmanın nereye uygun olduğuna dair tek satır.", + "uk": "Один рядок про те, для чого підходить ця конфігурація.", + "zh_Hans": "一句话说说这套配置适合什么场景。", + "zh_Hant": "一句話說說這套配置適合什麼場景。" + }, + "One more piece in the store": { + "de": "Ein Stück mehr im Store", + "es": "Una pieza más en la tienda", + "fr": "Une création de plus dans la boutique", + "id": "Satu karya lagi di toko", + "it": "Un pezzo in più nello store", + "ja": "ストアに作品がもう一つ", + "ko": "스토어에 작품 하나 더", + "nl": "Nog een stuk in de store", + "pl": "Jeszcze jedna praca w sklepie", + "ru": "Ещё одна работа в магазине", + "tr": "Mağazada bir eser daha", + "uk": "Ще одна робота в магазині", + "zh_Hans": "商店里多一件作品", + "zh_Hant": "商店裡多一件作品" + }, + "Only .woff2 files up to 8MB are accepted.": { + "de": "Es werden nur .woff2-Dateien bis 8 MB akzeptiert.", + "es": "Solo se aceptan archivos .woff2 de hasta 8 MB.", + "fr": "Seuls les fichiers .woff2 jusqu'à 8 Mo sont acceptés.", + "id": "Hanya berkas .woff2 hingga 8 MB yang diterima.", + "it": "Sono accettati solo file .woff2 fino a 8 MB.", + "ja": "8MB までの .woff2 ファイルのみ使用できます。", + "ko": "8MB 이하의 .woff2 파일만 사용할 수 있습니다.", + "nl": "Alleen .woff2-bestanden tot 8 MB worden geaccepteerd.", + "pl": "Akceptowane są tylko pliki .woff2 do 8 MB.", + "ru": "Принимаются только файлы .woff2 размером до 8 МБ.", + "tr": "Yalnızca 8 MB'a kadar .woff2 dosyaları kabul edilir.", + "uk": "Приймаються лише файли .woff2 розміром до 8 МБ.", + "zh_Hans": "仅接受 8MB 以内的 .woff2 文件。", + "zh_Hant": "僅接受 8MB 以內的 .woff2 檔案。" + }, + "Open this configuration to see everything it carries.": { + "de": "Öffnen Sie diese Konfiguration, um alles zu sehen, was sie enthält.", + "es": "Abra esta configuración para ver todo lo que incluye.", + "fr": "Ouvrez cette configuration pour voir tout ce qu'elle contient.", + "id": "Buka konfigurasi ini untuk melihat semua yang dibawanya.", + "it": "Apri questa configurazione per vedere tutto ciò che contiene.", + "ja": "このコンフィグを開くと、含まれる内容がすべて確認できます。", + "ko": "이 구성을 열면 포함된 모든 항목을 볼 수 있습니다.", + "nl": "Open deze configuratie om alles te zien wat erin zit.", + "pl": "Otwórz tę konfigurację, aby zobaczyć wszystko, co zawiera.", + "ru": "Откройте эту конфигурацию, чтобы увидеть всё, что в ней есть.", + "tr": "Bunun içerdiği her şeyi görmek için yapılandırmayı açın.", + "uk": "Відкрийте цю конфігурацію, щоб побачити все, що в ній є.", + "zh_Hans": "打开这套配置即可查看它包含的全部内容。", + "zh_Hant": "打開這套配置即可查看它包含的全部內容。" + }, + "Opens an external site": { + "de": "Öffnet eine externe Website", + "es": "Abre un sitio externo", + "fr": "Ouvre un site externe", + "id": "Membuka situs eksternal", + "it": "Apre un sito esterno", + "ja": "外部サイトを開きます", + "ko": "외부 사이트를 엽니다", + "nl": "Opent een externe site", + "pl": "Otwiera witrynę zewnętrzną", + "ru": "Открывает внешний сайт", + "tr": "Harici bir siteyi açar", + "uk": "Відкриває зовнішній сайт", + "zh_Hans": "会打开外部网站", + "zh_Hant": "會開啟外部網站" + }, + "Paste the creator key you backed up, or pick the file you downloaded.": { + "de": "Füge den gesicherten Ersteller-Schlüssel ein oder wähle die heruntergeladene Datei.", + "es": "Pega la clave de creador que guardaste, o elige el archivo que descargaste.", + "fr": "Collez la clé de créateur sauvegardée, ou choisissez le fichier téléchargé.", + "id": "Tempel kunci kreator yang Anda cadangkan, atau pilih berkas yang Anda unduh.", + "it": "Incolla la chiave del creatore che hai salvato, oppure scegli il file scaricato.", + "ja": "バックアップしたクリエイターキーを貼り付けるか、ダウンロードしたファイルを選んでください。", + "ko": "백업한 크리에이터 키를 붙여넣거나, 내려받은 파일을 선택하세요.", + "nl": "Plak de makerssleutel die je hebt bewaard, of kies het gedownloade bestand.", + "pl": "Wklej zapisany klucz twórcy albo wybierz pobrany plik.", + "ru": "Вставьте сохранённый ключ автора или выберите скачанный файл.", + "tr": "Yedeklediğiniz oluşturucu anahtarını yapıştırın ya da indirdiğiniz dosyayı seçin.", + "uk": "Вставте збережений ключ автора або виберіть завантажений файл.", + "zh_Hans": "粘贴你备份的创作者密钥,或者选择下载的文件。", + "zh_Hant": "貼上你備份的創作者金鑰,或者選擇下載的檔案。" + }, + "Paste the identity you backed up, or pick the backup file you downloaded. What you shared before comes back under this router.": { + "de": "Füge die gesicherte Identität ein oder wähle die heruntergeladene Sicherungsdatei. Was du zuvor geteilt hast, gehört danach wieder zu diesem Router.", + "es": "Pega la identidad que respaldaste o elige el archivo de respaldo que descargaste. Lo que compartiste antes volverá a este router.", + "fr": "Collez l'identité que vous avez sauvegardée, ou choisissez le fichier de sauvegarde téléchargé. Ce que vous aviez partagé revient sur ce routeur.", + "id": "Tempel identitas yang kamu cadangkan, atau pilih berkas cadangan yang kamu unduh. Apa yang dulu kamu bagikan akan kembali ke router ini.", + "it": "Incolla l'identità di cui hai fatto il backup, oppure scegli il file di backup che hai scaricato. Ciò che avevi condiviso torna su questo router.", + "ja": "バックアップした身元を貼り付けるか、ダウンロードしたバックアップファイルを選んでください。以前共有したものがこのルーターに戻ります。", + "ko": "백업해 둔 신원을 붙여넣거나, 내려받은 백업 파일을 고르세요. 예전에 공유한 것들이 이 공유기로 돌아옵니다.", + "nl": "Plak de identiteit waarvan je een back-up hebt gemaakt, of kies het gedownloade back-upbestand. Wat je eerder deelde, komt terug op deze router.", + "pl": "Wklej tożsamość, której kopię zapisałeś, albo wybierz pobrany plik kopii. To, co udostępniłeś wcześniej, wróci na ten router.", + "ru": "Вставьте сохранённую личность или выберите скачанный файл резервной копии. То, чем вы делились раньше, вернётся на этот роутер.", + "tr": "Yedeklediğin kimliği yapıştır ya da indirdiğin yedek dosyasını seç. Daha önce paylaştıkların bu yönlendiriciye geri döner.", + "uk": "Вставте збережену особистість або виберіть завантажений файл резервної копії. Те, чим ви ділилися раніше, повернеться на цей роутер.", + "zh_Hans": "粘贴你备份过的身份,或者选择下载的备份文件。恢复之后,你以前分享的作品就回到这台路由器上管理。", + "zh_Hant": "貼上你備份過的身分,或者選擇下載的備份檔案。恢復之後,你以前分享的作品就回到這台路由器上管理。" + }, + "Pick a name between 1 and 40 characters.": { + "de": "Wähle einen Namen mit 1 bis 40 Zeichen.", + "es": "Elige un nombre de entre 1 y 40 caracteres.", + "fr": "Choisissez un nom de 1 à 40 caractères.", + "id": "Pilih nama sepanjang 1 sampai 40 karakter.", + "it": "Scegli un nome tra 1 e 40 caratteri.", + "ja": "1〜40文字の名前にしてください。", + "ko": "1자에서 40자 사이의 이름을 정하세요.", + "nl": "Kies een naam van 1 tot 40 tekens.", + "pl": "Wybierz nazwę o długości od 1 do 40 znaków.", + "ru": "Выберите имя длиной от 1 до 40 символов.", + "tr": "1 ile 40 karakter arasında bir ad seçin.", + "uk": "Виберіть ім'я довжиною від 1 до 40 символів.", + "zh_Hans": "取一个 1 到 40 个字的名字。", + "zh_Hant": "取一個 1 到 40 個字的名稱。" + }, + "Preparing…": { + "zh_Hans": "准备中…", + "zh_Hant": "準備中…", + "de": "Wird vorbereitet…", + "es": "Preparando…", + "fr": "Préparation…", + "it": "Preparazione…", + "nl": "Voorbereiden…", + "pl": "Przygotowywanie…", + "ru": "Подготовка…", + "tr": "Hazırlanıyor…", + "uk": "Підготовка…", + "id": "Menyiapkan…", + "ja": "準備中…", + "ko": "준비 중…" + }, + "Publish as": { + "de": "Veröffentlichen als", + "es": "Publicar como", + "fr": "Publier en tant que", + "id": "Bagikan sebagai", + "it": "Pubblica come", + "ja": "公開名", + "ko": "게시 이름", + "nl": "Publiceren als", + "pl": "Publikuj jako", + "ru": "Публиковать как", + "tr": "Şu adla yayınla", + "uk": "Публікувати як", + "zh_Hans": "署名", + "zh_Hant": "署名" + }, + "Publish as a new one": { + "de": "Als neue veröffentlichen", + "es": "Publicar como una nueva", + "fr": "Publier comme nouvelle", + "id": "Terbitkan sebagai yang baru", + "it": "Pubblica come nuova", + "ja": "新しいものとして公開", + "ko": "새 항목으로 게시", + "nl": "Als nieuwe publiceren", + "pl": "Opublikuj jako nową", + "ru": "Опубликовать как новую", + "tr": "Yeni olarak yayımla", + "uk": "Опублікувати як нову", + "zh_Hans": "发布为新的一条", + "zh_Hant": "發布為新的一條" + }, + "Publish current configuration": { + "de": "Aktuelle Konfiguration veröffentlichen", + "es": "Publicar la configuración actual", + "fr": "Publier la configuration actuelle", + "id": "Terbitkan konfigurasi saat ini", + "it": "Pubblica la configurazione attuale", + "ja": "現在のコンフィグを公開", + "ko": "현재 구성 게시", + "nl": "Huidige configuratie publiceren", + "pl": "Opublikuj bieżącą konfigurację", + "ru": "Опубликовать текущую конфигурацию", + "tr": "Geçerli yapılandırmayı yayınla", + "uk": "Опублікувати поточну конфігурацію", + "zh_Hans": "发布当前配置", + "zh_Hant": "發佈目前配置" + }, + "Publish this router's whole appearance to the store and anyone can apply it in one click. Publishing creates your creator identity automatically.": { + "de": "Veröffentliche das gesamte Erscheinungsbild dieses Routers im Store, und jeder kann es mit einem Klick übernehmen. Beim Veröffentlichen wird deine Ersteller-Identität automatisch angelegt.", + "es": "Publica todo el aspecto de este router en la tienda y cualquiera podrá aplicarlo con un clic. Al publicar se crea automáticamente tu identidad de creador.", + "fr": "Publiez toute l'apparence de ce routeur dans la boutique : n'importe qui pourra l'appliquer en un clic. La publication crée automatiquement votre identité de créateur.", + "id": "Terbitkan seluruh tampilan router ini ke toko, dan siapa pun bisa menerapkannya sekali klik. Menerbitkan akan membuat identitas kreatormu secara otomatis.", + "it": "Pubblica nello store l'intero aspetto di questo router: chiunque potrà applicarlo con un clic. La pubblicazione crea automaticamente la tua identità di creatore.", + "ja": "このルーターの外観をまるごとストアに公開すると、誰でもワンクリックで適用できます。公開すると作成者としての身元が自動で作られます。", + "ko": "이 공유기의 외관 전체를 스토어에 게시하면 누구나 한 번의 클릭으로 적용할 수 있습니다. 게시하면 제작자 신원이 자동으로 만들어집니다.", + "nl": "Publiceer het volledige uiterlijk van deze router in de winkel, dan kan iedereen het met één klik toepassen. Bij het publiceren wordt je makersidentiteit automatisch aangemaakt.", + "pl": "Opublikuj cały wygląd tego routera w sklepie, a każdy będzie mógł zastosować go jednym kliknięciem. Publikacja automatycznie tworzy twoją tożsamość twórcy.", + "ru": "Опубликуйте весь внешний вид этого роутера в магазине — и любой сможет применить его в один клик. При публикации ваша личность автора создаётся автоматически.", + "tr": "Bu yönlendiricinin tüm görünümünü mağazada yayınla; herkes tek tıkla uygulayabilsin. Yayınladığında yaratıcı kimliğin otomatik olarak oluşturulur.", + "uk": "Опублікуйте весь вигляд цього роутера в магазині — і будь-хто зможе застосувати його одним кліком. Під час публікації ваша особистість автора створюється автоматично.", + "zh_Hans": "把这台路由器的整套外观发布到商店,别人就能一键套用。发布时会自动为你建立创作者身份。", + "zh_Hant": "把這台路由器的整套外觀發布到商店,別人就能一鍵套用。發布時會自動為你建立創作者身分。" + }, + "Publish your current configuration and it shows up here.": { + "de": "Veröffentliche deine aktuelle Konfiguration, dann erscheint sie hier.", + "es": "Publica tu configuración actual y aparecerá aquí.", + "fr": "Publiez votre configuration actuelle et elle apparaîtra ici.", + "id": "Terbitkan konfigurasi kamu saat ini dan ia akan muncul di sini.", + "it": "Pubblica la tua configurazione attuale e comparirà qui.", + "ja": "現在の設定を公開すると、ここに表示されます。", + "ko": "현재 설정을 게시하면 여기에 나타납니다.", + "nl": "Publiceer je huidige configuratie, dan verschijnt die hier.", + "pl": "Opublikuj swoją bieżącą konfigurację, a pojawi się tutaj.", + "ru": "Опубликуйте текущую конфигурацию — и она появится здесь.", + "tr": "Mevcut yapılandırmanı yayınla, burada görünsün.", + "uk": "Опублікуйте поточну конфігурацію — і вона з'явиться тут.", + "zh_Hans": "发布当前配置,它就会出现在这里。", + "zh_Hant": "發布當前配置,它就會出現在這裡。" + }, + "Published. Its images are queued for review — it appears in the store once they are approved.": { + "de": "Veröffentlicht. Die Bilder stehen in der Prüfung – im Store erscheint es, sobald sie freigegeben sind.", + "es": "Publicado. Sus imágenes están en cola de revisión: aparecerá en la tienda cuando se aprueben.", + "fr": "Publié. Ses images sont en attente d'examen — il apparaîtra dans la boutique une fois approuvées.", + "id": "Diterbitkan. Gambarnya sedang antre ditinjau — akan muncul di toko setelah disetujui.", + "it": "Pubblicato. Le sue immagini sono in coda per la revisione: comparirà nello store una volta approvate.", + "ja": "公開しました。画像は審査待ちです — 承認されるとストアに表示されます。", + "ko": "게시했습니다. 이미지는 검토 대기 중입니다 — 승인되면 스토어에 표시됩니다.", + "nl": "Gepubliceerd. De afbeeldingen wachten op beoordeling — het verschijnt in de store zodra ze zijn goedgekeurd.", + "pl": "Opublikowano. Obrazy czekają na weryfikację — pojawi się w sklepie, gdy zostaną zatwierdzone.", + "ru": "Опубликовано. Изображения ждут проверки — появится в магазине после одобрения.", + "tr": "Yayınlandı. Görseller inceleme sırasında — onaylandığında mağazada görünecek.", + "uk": "Опубліковано. Зображення чекають на перевірку — з'явиться в магазині після схвалення.", + "zh_Hans": "已发布。图片正在排队等审核 —— 通过后才会出现在商店里。", + "zh_Hant": "已發布。圖片正在排隊等審核 —— 通過後才會出現在商店裡。" + }, + "Published. Your creator account lives only on this router — back up the key so a reflash can't take it.": { + "de": "Veröffentlicht. Dein Ersteller-Konto existiert nur auf diesem Router — sichere den Schlüssel, damit ein Neuflashen es nicht mitnimmt.", + "es": "Publicado. Tu cuenta de creador solo existe en este router: guarda la clave para que reinstalar el firmware no se la lleve.", + "fr": "Publié. Votre compte de créateur n'existe que sur ce routeur — sauvegardez la clé pour qu'une réinstallation ne l'emporte pas.", + "id": "Dibagikan. Akun kreator Anda hanya ada di router ini — cadangkan kuncinya agar tidak hilang saat flash ulang.", + "it": "Pubblicato. Il tuo account creatore esiste solo su questo router: salva la chiave, così un riflash non se lo porta via.", + "ja": "公開しました。クリエイターアカウントはこのルーターにしかありません。再フラッシュで失わないようキーをバックアップしてください。", + "ko": "게시했습니다. 크리에이터 계정은 이 공유기에만 있습니다. 다시 플래시해도 잃지 않도록 키를 백업하세요.", + "nl": "Gepubliceerd. Je makersaccount bestaat alleen op deze router — maak een back-up van de sleutel, zodat opnieuw flashen hem niet meeneemt.", + "pl": "Opublikowano. Twoje konto twórcy istnieje tylko na tym routerze — zapisz klucz, aby nie zniknęło przy ponownym wgraniu firmware.", + "ru": "Опубликовано. Ваш аккаунт автора существует только на этом роутере — сохраните ключ, чтобы перепрошивка его не забрала.", + "tr": "Yayınlandı. Oluşturucu hesabınız yalnızca bu yönlendiricide var — yeniden yükleme onu alıp götürmesin diye anahtarı yedekleyin.", + "uk": "Опубліковано. Ваш обліковий запис автора існує лише на цьому роутері — збережіть ключ, щоб перепрошивка його не забрала.", + "zh_Hans": "已发布。你的创作者账号只存在于这台路由器上 — 备份密钥,刷机就带不走它。", + "zh_Hant": "已發布。你的創作者帳號只存在於這台路由器上 — 備份金鑰,刷機就帶不走它。" + }, + "Published. Your creator identity lives only on this router — back it up so a reflash can't take it.": { + "de": "Veröffentlicht. Deine Ersteller-Identität existiert nur auf diesem Router – sichere sie, damit ein Neuflashen sie nicht mitnimmt.", + "es": "Publicado. Tu identidad de creador solo existe en este router: haz una copia para que reinstalar el firmware no se la lleve.", + "fr": "Publié. Votre identité de créateur n'existe que sur ce routeur — sauvegardez-la pour qu'un reflash ne l'emporte pas.", + "id": "Diterbitkan. Identitas kreatormu hanya ada di router ini — cadangkan agar flash ulang tidak membawanya pergi.", + "it": "Pubblicato. La tua identità di creatore esiste solo su questo router: fanne un backup, così un reflash non se la porta via.", + "ja": "公開しました。作成者としての身元はこのルーターにしか存在しません。再フラッシュで失わないようバックアップしてください。", + "ko": "게시했습니다. 제작자 신원은 이 공유기에만 존재합니다. 펌웨어를 다시 올려도 잃지 않도록 백업하세요.", + "nl": "Gepubliceerd. Je makersidentiteit bestaat alleen op deze router — maak een back-up, dan neemt een herinstallatie haar niet mee.", + "pl": "Opublikowano. Twoja tożsamość twórcy istnieje tylko na tym routerze — zrób kopię, żeby ponowne wgranie firmware'u jej nie zabrało.", + "ru": "Опубликовано. Ваша личность автора существует только на этом роутере — сохраните её, чтобы перепрошивка её не унесла.", + "tr": "Yayınlandı. Yaratıcı kimliğin yalnızca bu yönlendiricide bulunuyor — yeniden flash'lamanın onu alıp götürmemesi için yedekle.", + "uk": "Опубліковано. Ваша особистість автора існує лише на цьому роутері — збережіть її, щоб перепрошивка її не забрала.", + "zh_Hans": "已发布。你的创作者身份只存在这台路由器上 —— 备份一下,刷机就带不走它。", + "zh_Hant": "已發布。你的創作者身分只存在這台路由器上 —— 備份一下,刷機就帶不走它。" + }, + "Re-apply": { + "de": "Erneut anwenden", + "es": "Volver a aplicar", + "fr": "Réappliquer", + "id": "Terapkan ulang", + "it": "Riapplica", + "ja": "再適用", + "ko": "다시 적용", + "nl": "Opnieuw toepassen", + "pl": "Zastosuj ponownie", + "ru": "Применить снова", + "tr": "Yeniden uygula", + "uk": "Застосувати знову", + "zh_Hans": "重新应用", + "zh_Hant": "重新套用" + }, + "Rename": { + "de": "Umbenennen", + "es": "Cambiar nombre", + "fr": "Renommer", + "id": "Ganti nama", + "it": "Rinomina", + "ja": "名前を変更", + "ko": "이름 변경", + "nl": "Naam wijzigen", + "pl": "Zmień nazwę", + "ru": "Переименовать", + "tr": "Yeniden adlandır", + "uk": "Перейменувати", + "zh_Hans": "改名", + "zh_Hant": "改名" + }, + "Replace “%s”": { + "de": "„%s“ ersetzen", + "es": "Reemplazar «%s»", + "fr": "Remplacer « %s »", + "id": "Ganti “%s”", + "it": "Sostituisci «%s»", + "ja": "「%s」を差し替える", + "ko": "“%s” 교체", + "nl": "“%s” vervangen", + "pl": "Zastąp „%s”", + "ru": "Заменить «%s»", + "tr": "“%s” yerine koy", + "uk": "Замінити «%s»", + "zh_Hans": "更新「%s」", + "zh_Hant": "更新「%s」" + }, + "Replaces the light and dark colors; everything else stays as it is.": { + "de": "Ersetzt nur die hellen und dunklen Farben; alles andere bleibt unverändert.", + "es": "Sustituye los colores claros y oscuros; todo lo demás permanece igual.", + "fr": "Remplace uniquement les couleurs claires et sombres ; le reste reste inchangé.", + "id": "Hanya mengganti warna terang dan gelap; sisanya tetap sama.", + "it": "Sostituisce solo i colori chiari e scuri; tutto il resto resta invariato.", + "ja": "ライトとダークの配色だけを置き換えます。それ以外はそのままです。", + "ko": "라이트・다크 색상만 바꿉니다. 나머지는 그대로 유지됩니다.", + "nl": "Vervangt alleen de lichte en donkere kleuren; al het andere blijft ongewijzigd.", + "pl": "Zmienia tylko kolory jasne i ciemne; reszta pozostaje bez zmian.", + "ru": "Заменяет только светлые и тёмные цвета — всё остальное остаётся без изменений.", + "tr": "Yalnızca açık ve koyu renkleri değiştirir; geri kalan her şey aynı kalır.", + "uk": "Замінює лише світлі та темні кольори — все інше залишається без змін.", + "zh_Hans": "只替换浅色和深色配色,其余部分保持不变。", + "zh_Hant": "只替換淺色和深色配色,其餘部分保持不變。" + }, + "Restore your creator identity": { + "de": "Ersteller-Identität wiederherstellen", + "es": "Restaura tu identidad de creador", + "fr": "Restaurer votre identité de créateur", + "id": "Pulihkan identitas kreatormu", + "it": "Ripristina la tua identità di creatore", + "ja": "作成者としての身元を復元", + "ko": "제작자 신원 복원", + "nl": "Je makersidentiteit herstellen", + "pl": "Przywróć swoją tożsamość twórcy", + "ru": "Восстановите свою личность автора", + "tr": "Yaratıcı kimliğini geri yükle", + "uk": "Відновіть свою особистість автора", + "zh_Hans": "恢复创作者身份", + "zh_Hant": "恢復創作者身分" + }, + "Restored.": { + "de": "Wiederhergestellt.", + "es": "Restaurado.", + "fr": "Restauré.", + "id": "Dipulihkan.", + "it": "Ripristinato.", + "ja": "復元しました。", + "ko": "복구했습니다.", + "nl": "Hersteld.", + "pl": "Przywrócono.", + "ru": "Восстановлено.", + "tr": "Geri yüklendi.", + "uk": "Відновлено.", + "zh_Hans": "已恢复。", + "zh_Hant": "已恢復。" + }, + "Restored. What you shared before is back under this account.": { + "de": "Wiederhergestellt. Was du zuvor geteilt hast, gehört wieder zu diesem Konto.", + "es": "Restaurado. Lo que compartiste antes vuelve a estar en esta cuenta.", + "fr": "Restauré. Ce que vous aviez partagé est de nouveau rattaché à ce compte.", + "id": "Dipulihkan. Yang pernah Anda bagikan kembali berada di akun ini.", + "it": "Ripristinato. Quello che avevi condiviso è di nuovo su questo account.", + "ja": "復元しました。以前共有したものがこのアカウントに戻りました。", + "ko": "복구했습니다. 예전에 공유한 것이 이 계정으로 돌아왔습니다.", + "nl": "Hersteld. Wat je eerder deelde staat weer onder dit account.", + "pl": "Przywrócono. To, co udostępniono wcześniej, znów należy do tego konta.", + "ru": "Восстановлено. Опубликованное ранее снова принадлежит этому аккаунту.", + "tr": "Geri yüklendi. Daha önce paylaştıklarınız yeniden bu hesapta.", + "uk": "Відновлено. Опубліковане раніше знову належить цьому обліковому запису.", + "zh_Hans": "已恢复。你以前分享的东西回到这个账号名下了。", + "zh_Hant": "已恢復。你以前分享的東西回到這個帳號名下了。" + }, + "Restored. What you shared before is back under this identity.": { + "de": "Wiederhergestellt. Was du zuvor geteilt hast, gehört wieder zu dieser Identität.", + "es": "Restaurada. Lo que compartiste antes vuelve a estar bajo esta identidad.", + "fr": "Restaurée. Ce que vous aviez partagé est de nouveau sous cette identité.", + "id": "Dipulihkan. Apa yang dulu kamu bagikan kembali di bawah identitas ini.", + "it": "Ripristinata. Ciò che avevi condiviso è di nuovo sotto questa identità.", + "ja": "復元しました。以前共有したものがこの身元に戻りました。", + "ko": "복원했습니다. 예전에 공유한 것들이 이 신원으로 돌아왔습니다.", + "nl": "Hersteld. Wat je eerder deelde, staat weer onder deze identiteit.", + "pl": "Przywrócono. To, co udostępniłeś wcześniej, znów należy do tej tożsamości.", + "ru": "Восстановлено. То, чем вы делились раньше, снова под этой личностью.", + "tr": "Geri yüklendi. Daha önce paylaştıkların yeniden bu kimliğin altında.", + "uk": "Відновлено. Те, чим ви ділилися раніше, знову під цією особистістю.", + "zh_Hans": "已恢复。你以前分享的作品回来了。", + "zh_Hant": "已恢復。你以前分享的作品回來了。" + }, + "Restore…": { + "de": "Wiederherstellen…", + "es": "Restaurar…", + "fr": "Restaurer…", + "id": "Pulihkan…", + "it": "Ripristina…", + "ja": "復元…", + "ko": "복원…", + "nl": "Herstellen…", + "pl": "Przywróć…", + "ru": "Восстановить…", + "tr": "Geri yükle…", + "uk": "Відновити…", + "zh_Hans": "恢复…", + "zh_Hant": "復原…" + }, + "Rounded corners": { + "de": "Abgerundete Ecken", + "es": "Esquinas redondeadas", + "fr": "Coins arrondis", + "id": "Sudut membulat", + "it": "Angoli arrotondati", + "ja": "角丸", + "ko": "둥근 모서리", + "nl": "Afgeronde hoeken", + "pl": "Zaokrąglone rogi", + "ru": "Скруглённые углы", + "tr": "Yuvarlak köşeler", + "uk": "Скруглені кути", + "zh_Hans": "圆角", + "zh_Hant": "圓角" + }, + "Sans Font File": { + "de": "Schriftdatei (Sans)", + "es": "Archivo de fuente sans", + "fr": "Fichier de police sans", + "id": "Berkas font sans", + "it": "File del carattere sans", + "ja": "Sans フォントファイル", + "ko": "산세리프 글꼴 파일", + "nl": "Sans-lettertypebestand", + "pl": "Plik czcionki bezszeryfowej", + "ru": "Файл шрифта без засечек", + "tr": "Sans yazı tipi dosyası", + "uk": "Файл шрифту без засічок", + "zh_Hans": "无衬线字体文件", + "zh_Hant": "無襯線字型檔" + }, + "Save": { + "de": "Speichern", + "es": "Guardar", + "fr": "Enregistrer", + "id": "Simpan", + "it": "Salva", + "ja": "保存", + "ko": "저장", + "nl": "Opslaan", + "pl": "Zapisz", + "ru": "Сохранить", + "tr": "Kaydet", + "uk": "Зберегти", + "zh_Hans": "保存", + "zh_Hant": "儲存" + }, + "Shape": { + "de": "Form", + "es": "Forma", + "fr": "Forme", + "id": "Bentuk", + "it": "Forma", + "ja": "形状", + "ko": "모양", + "nl": "Vorm", + "pl": "Kształt", + "ru": "Форма", + "tr": "Şekil", + "uk": "Форма", + "zh_Hans": "外形", + "zh_Hant": "外形" + }, + "Share to the store": { + "zh_Hans": "分享到商店", + "zh_Hant": "分享到商店", + "de": "Im Store teilen", + "es": "Compartir en la tienda", + "fr": "Partager dans la boutique", + "it": "Condividi nello store", + "nl": "Delen in de store", + "pl": "Udostępnij w sklepie", + "ru": "Поделиться в магазине", + "tr": "Mağazada paylaş", + "uk": "Поділитися в магазині", + "id": "Bagikan ke toko", + "ja": "ストアで共有", + "ko": "스토어에 공유" + }, + "Shared by other people": { + "de": "Von anderen geteilt", + "es": "Compartidas por otras personas", + "fr": "Partagées par d'autres", + "id": "Dibagikan oleh orang lain", + "it": "Condivise da altri", + "ja": "他のユーザーが共有したもの", + "ko": "다른 사용자가 공유한 것", + "nl": "Gedeeld door anderen", + "pl": "Udostępnione przez innych", + "ru": "Поделились другие пользователи", + "tr": "Başkalarının paylaştıkları", + "uk": "Поділилися інші користувачі", + "zh_Hans": "由其他人分享", + "zh_Hant": "由其他人分享" + }, + "Shared on another router before? Restore my identity": { + "de": "Schon auf einem anderen Router geteilt? Identität wiederherstellen", + "es": "¿Ya compartiste desde otro router? Restaurar mi identidad", + "fr": "Déjà partagé depuis un autre routeur ? Restaurer mon identité", + "id": "Pernah berbagi dari router lain? Pulihkan identitasku", + "it": "Hai già condiviso da un altro router? Ripristina la mia identità", + "ja": "別のルーターで共有したことがある? 身元を復元する", + "ko": "다른 공유기에서 공유한 적이 있나요? 내 신원 복원", + "nl": "Eerder gedeeld vanaf een andere router? Mijn identiteit herstellen", + "pl": "Udostępniałeś już z innego routera? Przywróć moją tożsamość", + "ru": "Уже публиковали с другого роутера? Восстановить мою личность", + "tr": "Daha önce başka bir yönlendiriciden paylaştın mı? Kimliğimi geri yükle", + "uk": "Уже публікували з іншого роутера? Відновити мою особистість", + "zh_Hans": "在别的路由器分享过?恢复我的身份", + "zh_Hant": "在別的路由器分享過?恢復我的身分" + }, + "Sharing starts in the design studio: make the appearance what you want there, then publish from that page.": { + "de": "Teilen beginnt im Designstudio: bring das Aussehen dort in Form und veröffentliche dann von jener Seite.", + "es": "Compartir empieza en el Estudio de diseño: deja ahí el aspecto como lo quieres y publica desde esa página.", + "fr": "Le partage commence dans le Studio de design : donnez-lui l’apparence voulue, puis publiez depuis cette page.", + "id": "Berbagi dimulai di Studio Desain: atur tampilannya sesukamu di sana, lalu terbitkan dari halaman itu.", + "it": "La condivisione parte dallo Studio di design: dai all’aspetto la forma che vuoi e pubblica da quella pagina.", + "ja": "共有はデザインスタジオから始まります。そこで見た目を思いどおりに整えてから、そのページで公開してください。", + "ko": "공유는 디자인 스튜디오에서 시작합니다. 거기서 원하는 모습으로 만든 다음 그 화면에서 게시하세요.", + "nl": "Delen begint in de Ontwerpstudio: geef het uiterlijk daar de vorm die je wilt en publiceer vanaf die pagina.", + "pl": "Udostępnianie zaczyna się w Studiu projektowym: nadaj tam wyglądowi taką formę, jakiej chcesz, i opublikuj z tamtej strony.", + "ru": "Публикация начинается в Студии дизайна: доведите там внешний вид до нужного и опубликуйте с той страницы.", + "tr": "Paylaşım Tasarım Stüdyosu’nda başlar: görünümü orada istediğin hâle getir, sonra o sayfadan yayımla.", + "uk": "Поділитися можна зі Студії дизайну: доведіть там вигляд до потрібного й опублікуйте з тієї сторінки.", + "zh_Hans": "分享从主题工作台开始:在那里把外观调成你要的样子,再从那一页发布出去。", + "zh_Hant": "分享從主題工作台開始:在那裡把外觀調成你要的樣子,再從那一頁發布出去。" + }, + "Ships with the theme, works offline": { + "de": "Im Theme enthalten, funktioniert offline", + "es": "Incluido con el tema, funciona sin conexión", + "fr": "Fourni avec le thème, fonctionne hors ligne", + "id": "Disertakan dengan tema, berfungsi offline", + "it": "Incluso nel tema, funziona offline", + "ja": "テーマに同梱、オフラインでも使えます", + "ko": "테마에 포함되어 있으며 오프라인에서도 작동합니다", + "nl": "Meegeleverd met het thema, werkt offline", + "pl": "Dołączone do motywu, działa offline", + "ru": "Входит в состав темы, работает офлайн", + "tr": "Temayla birlikte gelir, çevrimdışı çalışır", + "uk": "Входить до складу теми, працює офлайн", + "zh_Hans": "随主题附带,离线可用", + "zh_Hant": "隨主題附帶,離線可用" + }, + "Shortcut Icons": { + "de": "Verknüpfungssymbole", + "es": "Iconos de accesos directos", + "fr": "Icônes des raccourcis", + "id": "Ikon pintasan", + "it": "Icone delle scorciatoie", + "ja": "ショートカットのアイコン", + "ko": "바로가기 아이콘", + "nl": "Snelkoppelingspictogrammen", + "pl": "Ikony skrótów", + "ru": "Значки ярлыков", + "tr": "Kısayol simgeleri", + "uk": "Піктограми ярликів", + "zh_Hans": "快捷方式图标", + "zh_Hant": "捷徑圖示" + }, + "Shortcuts %d": { + "de": "Verknüpfungen %d", + "es": "Accesos directos %d", + "fr": "Raccourcis %d", + "id": "Pintasan %d", + "it": "Scorciatoie %d", + "ja": "ショートカット %d", + "ko": "바로 가기 %d", + "nl": "Snelkoppelingen %d", + "pl": "Skróty %d", + "ru": "Ярлыки %d", + "tr": "Kısayollar %d", + "uk": "Ярлики %d", + "zh_Hans": "快捷方式 %d", + "zh_Hant": "捷徑 %d" + }, + "Show": { + "de": "Anzeigen", + "es": "Mostrar", + "fr": "Afficher", + "id": "Tampilkan", + "it": "Mostra", + "ja": "表示", + "ko": "표시", + "nl": "Tonen", + "pl": "Pokaż", + "ru": "Показать", + "tr": "Göster", + "uk": "Показати", + "zh_Hans": "显示", + "zh_Hant": "顯示" + }, + "Show key": { + "de": "Schlüssel anzeigen", + "es": "Mostrar clave", + "fr": "Afficher la clé", + "id": "Tampilkan kunci", + "it": "Mostra la chiave", + "ja": "キーを表示", + "ko": "키 보기", + "nl": "Sleutel tonen", + "pl": "Pokaż klucz", + "ru": "Показать ключ", + "tr": "Anahtarı göster", + "uk": "Показати ключ", + "zh_Hans": "显示密钥", + "zh_Hant": "顯示金鑰" + }, + "Shown": { + "de": "Sichtbar", + "es": "Visible", + "fr": "Affichée", + "id": "Ditampilkan", + "it": "Visibile", + "ja": "表示", + "ko": "표시", + "nl": "Zichtbaar", + "pl": "Widoczny", + "ru": "Показана", + "tr": "Görünür", + "uk": "Показано", + "zh_Hans": "显示", + "zh_Hant": "顯示" + }, + "Shown as Anonymous if left blank": { + "de": "Wird als „Anonym“ angezeigt, wenn leer gelassen", + "es": "Se mostrará como Anónimo si se deja en blanco", + "fr": "Affiché comme Anonyme si laissé vide", + "id": "Ditampilkan sebagai Anonim jika dibiarkan kosong", + "it": "Se lasciato vuoto verrà mostrato come Anonimo", + "ja": "空欄の場合は「匿名」と表示されます", + "ko": "비워 두면 익명으로 표시됩니다", + "nl": "Wordt getoond als Anoniem indien leeg gelaten", + "pl": "Puste pole zostanie pokazane jako Anonim", + "ru": "Если оставить пустым, будет показано как «Аноним»", + "tr": "Boş bırakılırsa Anonim olarak gösterilir", + "uk": "Якщо залишити порожнім, буде показано як «Анонім»", + "zh_Hans": "留空则显示为匿名", + "zh_Hant": "留空則顯示為匿名" + }, + "Signs everything you share. It lives only on this router.": { + "de": "Signiert alles, was du teilst. Sie existiert nur auf diesem Router.", + "es": "Firma todo lo que compartes. Solo existe en este router.", + "fr": "Signe tout ce que vous partagez. Elle n'existe que sur ce routeur.", + "id": "Menandatangani semua yang kamu bagikan. Hanya ada di router ini.", + "it": "Firma tutto ciò che condividi. Esiste solo su questo router.", + "ja": "共有するすべてに署名します。このルーターにしか存在しません。", + "ko": "공유하는 모든 것에 서명합니다. 이 공유기에만 존재합니다.", + "nl": "Ondertekent alles wat je deelt. Bestaat alleen op deze router.", + "pl": "Podpisuje wszystko, co udostępniasz. Istnieje tylko na tym routerze.", + "ru": "Подписывает всё, чем вы делитесь. Существует только на этом роутере.", + "tr": "Paylaştığın her şeyi imzalar. Yalnızca bu yönlendiricide bulunur.", + "uk": "Підписує все, чим ви ділитеся. Існує лише на цьому роутері.", + "zh_Hans": "你分享的一切都以它署名。它只存在这台路由器上。", + "zh_Hant": "你分享的一切都以它署名。它只存在這台路由器上。" + }, + "Site Icon": { + "de": "Website-Symbol", + "es": "Icono del sitio", + "fr": "Icône du site", + "id": "Ikon situs", + "it": "Icona del sito", + "ja": "サイトアイコン", + "ko": "사이트 아이콘", + "nl": "Sitepictogram", + "pl": "Ikona witryny", + "ru": "Значок сайта", + "tr": "Site simgesi", + "uk": "Значок сайту", + "zh_Hans": "站点图标", + "zh_Hant": "網站圖示" + }, + "Small corners": { + "de": "Leicht abgerundete Ecken", + "es": "Esquinas ligeramente redondeadas", + "fr": "Coins légèrement arrondis", + "id": "Sudut sedikit membulat", + "it": "Angoli leggermente arrotondati", + "ja": "小さめの角丸", + "ko": "약간 둥근 모서리", + "nl": "Licht afgeronde hoeken", + "pl": "Lekko zaokrąglone rogi", + "ru": "Слегка скруглённые углы", + "tr": "Hafif yuvarlak köşeler", + "uk": "Злегка скруглені кути", + "zh_Hans": "小圆角", + "zh_Hant": "小圓角" + }, + "Square corners": { + "de": "Eckige Ecken", + "es": "Esquinas cuadradas", + "fr": "Coins carrés", + "id": "Sudut persegi", + "it": "Angoli squadrati", + "ja": "角なし", + "ko": "각진 모서리", + "nl": "Rechte hoeken", + "pl": "Ostre rogi", + "ru": "Прямые углы", + "tr": "Keskin köşeler", + "uk": "Прямі кути", + "zh_Hans": "直角", + "zh_Hant": "直角" + }, + "Taken down — no longer in the store. Nothing you can do from here.": { + "de": "Entfernt — nicht mehr im Store. Von hier aus lässt sich nichts tun.", + "es": "Retirada — ya no está en la tienda. No puedes hacer nada desde aquí.", + "fr": "Retirée — elle n'est plus dans la boutique. Rien à faire d'ici.", + "id": "Ditarik — tidak ada lagi di toko. Tidak ada yang bisa dilakukan dari sini.", + "it": "Rimossa — non è più nello store. Da qui non puoi fare nulla.", + "ja": "公開停止されました。ストアには表示されません。ここからできることはありません。", + "ko": "게시가 중단되었습니다. 스토어에 더 이상 없으며, 여기서 할 수 있는 일은 없습니다.", + "nl": "Verwijderd — staat niet meer in de store. Hiervandaan kun je niets doen.", + "pl": "Wycofano — nie ma jej już w sklepie. Nic tu nie zrobisz.", + "ru": "Снято с публикации — в магазине его больше нет. Отсюда ничего сделать нельзя.", + "tr": "Yayından kaldırıldı — mağazada yok. Buradan yapabileceğiniz bir şey yok.", + "uk": "Знято з публікації — у крамниці його більше немає. Звідси нічого зробити не можна.", + "zh_Hans": "已被下架,商店里不再显示。这里没有可以操作的了。", + "zh_Hant": "已被下架,商店裡不再顯示。這裡沒有可以操作的了。" + }, + "That doesn't look like a creator identity.": { + "de": "Das sieht nicht nach einer Ersteller-Identität aus.", + "es": "Eso no parece una identidad de creador.", + "fr": "Cela ne ressemble pas à une identité de créateur.", + "id": "Itu sepertinya bukan identitas kreator.", + "it": "Non sembra un'identità di creatore.", + "ja": "作成者の身元には見えません。", + "ko": "제작자 신원처럼 보이지 않습니다.", + "nl": "Dat lijkt geen makersidentiteit.", + "pl": "To nie wygląda na tożsamość twórcy.", + "ru": "Это не похоже на личность автора.", + "tr": "Bu bir yaratıcı kimliğine benzemiyor.", + "uk": "Це не схоже на особистість автора.", + "zh_Hans": "这看起来不像创作者身份。", + "zh_Hant": "這看起來不像創作者身分。" + }, + "That doesn't look like a creator key.": { + "de": "Das sieht nicht nach einem Ersteller-Schlüssel aus.", + "es": "Eso no parece una clave de creador.", + "fr": "Cela ne ressemble pas à une clé de créateur.", + "id": "Itu sepertinya bukan kunci kreator.", + "it": "Non sembra una chiave del creatore.", + "ja": "クリエイターキーではないようです。", + "ko": "크리에이터 키가 아닌 것 같습니다.", + "nl": "Dat lijkt geen makerssleutel te zijn.", + "pl": "To nie wygląda na klucz twórcy.", + "ru": "Это не похоже на ключ автора.", + "tr": "Bu bir oluşturucu anahtarına benzemiyor.", + "uk": "Це не схоже на ключ автора.", + "zh_Hans": "这看起来不像创作者密钥。", + "zh_Hant": "這看起來不像創作者金鑰。" + }, + "That name is already taken. Try another.": { + "de": "Dieser Name ist bereits vergeben. Bitte einen anderen wählen.", + "es": "Ese nombre ya está en uso. Prueba con otro.", + "fr": "Ce nom est déjà pris. Essayez-en un autre.", + "id": "Nama itu sudah dipakai. Coba yang lain.", + "it": "Quel nome è già occupato. Provane un altro.", + "ja": "その名前はすでに使われています。別の名前をお試しください。", + "ko": "이미 사용 중인 이름입니다. 다른 이름을 사용하세요.", + "nl": "Die naam is al bezet. Probeer een andere.", + "pl": "Ta nazwa jest już zajęta. Wybierz inną.", + "ru": "Это имя уже занято. Выберите другое.", + "tr": "Bu ad zaten alınmış. Başka bir tane deneyin.", + "uk": "Це ім'я вже зайняте. Спробуйте інше.", + "zh_Hans": "这个名字已经被占用了,换一个。", + "zh_Hant": "這個名稱已經被占用了,換一個。" + }, + "The font file comes from whoever shared this theme; the store does not verify its licence.": { + "de": "Die Schriftdatei stammt von der Person, die dieses Theme geteilt hat; der Store prüft ihre Lizenz nicht.", + "es": "El archivo de fuente proviene de quien compartió este tema; la tienda no verifica su licencia.", + "fr": "Le fichier de police provient de la personne qui a partagé ce thème ; la boutique ne vérifie pas sa licence.", + "id": "Berkas font berasal dari orang yang membagikan tema ini; toko tidak memverifikasi lisensinya.", + "it": "Il file del carattere proviene da chi ha condiviso questo tema; lo store non ne verifica la licenza.", + "ja": "フォントファイルはこのテーマを共有した人によるものです。ストアはそのライセンスを確認していません。", + "ko": "글꼴 파일은 이 테마를 공유한 사람이 올린 것으로, 스토어는 라이선스를 확인하지 않습니다.", + "nl": "Het lettertypebestand komt van degene die dit thema heeft gedeeld; de store controleert de licentie niet.", + "pl": "Plik czcionki pochodzi od osoby, która udostępniła ten motyw; sklep nie weryfikuje jego licencji.", + "ru": "Файл шрифта загружен тем, кто поделился этой темой; магазин не проверяет его лицензию.", + "tr": "Yazı tipi dosyası bu temayı paylaşan kişiden gelir; mağaza lisansını doğrulamaz.", + "uk": "Файл шрифту завантажив той, хто поділився цією темою; крамниця не перевіряє його ліцензію.", + "zh_Hans": "字体文件来自分享这份主题的人,商店不核实它的授权状态。", + "zh_Hant": "字型檔來自分享這份佈景主題的人,商店不核實它的授權狀態。" + }, + "The look this router is wearing right now.": { + "de": "Das Aussehen, das dieser Router gerade trägt.", + "es": "El aspecto que este router lleva ahora mismo.", + "fr": "L'apparence que ce routeur porte en ce moment.", + "id": "Tampilan yang sedang dipakai router ini.", + "it": "L'aspetto che questo router indossa in questo momento.", + "ja": "このルーターが今まとっている見た目です。", + "ko": "이 공유기가 지금 입고 있는 모습입니다.", + "nl": "Het uiterlijk dat deze router nu draagt.", + "pl": "Wygląd, który ten router ma w tej chwili.", + "ru": "Как этот роутер выглядит прямо сейчас.", + "tr": "Bu yönlendiricinin şu anda taşıdığı görünüm.", + "uk": "Як цей роутер виглядає просто зараз.", + "zh_Hans": "这台路由器现在的样子。", + "zh_Hant": "這台路由器目前的樣子。" + }, + "The name your shares are signed with": { + "de": "Der Name, mit dem deine Freigaben signiert werden", + "es": "El nombre con el que se firman tus publicaciones", + "fr": "Le nom qui signe vos partages", + "id": "Nama yang tercantum pada bagikan Anda", + "it": "Il nome con cui vengono firmate le tue condivisioni", + "ja": "共有に表示される名前", + "ko": "공유에 표시되는 이름", + "nl": "De naam waarmee je gedeelde configuraties zijn ondertekend", + "pl": "Nazwa, którą podpisywane są Twoje udostępnienia", + "ru": "Имя, которым подписаны ваши публикации", + "tr": "Paylaşımlarınızın imzalandığı ad", + "uk": "Ім'я, яким підписані ваші публікації", + "zh_Hans": "你分享的东西署这个名", + "zh_Hant": "你分享的東西署這個名" + }, + "The router did not respond. Please try again.": { + "de": "Der Router hat nicht geantwortet. Bitte erneut versuchen.", + "es": "El router no respondió. Inténtalo de nuevo.", + "fr": "Le routeur n'a pas répondu. Veuillez réessayer.", + "id": "Router tidak merespons. Coba lagi.", + "it": "Il router non ha risposto. Riprova.", + "ja": "ルーターが応答しませんでした。もう一度お試しください。", + "ko": "공유기가 응답하지 않았습니다. 다시 시도하세요.", + "nl": "De router reageerde niet. Probeer het opnieuw.", + "pl": "Router nie odpowiedział. Spróbuj ponownie.", + "ru": "Роутер не ответил. Попробуйте ещё раз.", + "tr": "Yönlendirici yanıt vermedi. Lütfen tekrar deneyin.", + "uk": "Маршрутизатор не відповів. Спробуйте ще раз.", + "zh_Hans": "路由器没有响应,请重试。", + "zh_Hant": "路由器沒有回應,請重試。" + }, + "The theme store refused this configuration. Nothing was published.": { + "de": "Der Store hat diese Konfiguration abgelehnt. Es wurde nichts veröffentlicht.", + "es": "La tienda ha rechazado esta configuración. No se ha publicado nada.", + "fr": "La boutique a refusé cette configuration. Rien n’a été publié.", + "id": "Toko menolak konfigurasi ini. Tidak ada yang diterbitkan.", + "it": "Lo store ha rifiutato questa configurazione. Non è stato pubblicato nulla.", + "ja": "ストアがこの設定を受け付けませんでした。何も公開されていません。", + "ko": "스토어가 이 구성을 거부했습니다. 아무것도 게시되지 않았습니다.", + "nl": "De store heeft deze configuratie geweigerd. Er is niets gepubliceerd.", + "pl": "Sklep odrzucił tę konfigurację. Nic nie zostało opublikowane.", + "ru": "Магазин отклонил эту конфигурацию. Ничего не опубликовано.", + "tr": "Mağaza bu yapılandırmayı kabul etmedi. Hiçbir şey yayımlanmadı.", + "uk": "Магазин відхилив цю конфігурацію. Нічого не опубліковано.", + "zh_Hans": "商店拒绝了这套配置,什么都没有发布出去。", + "zh_Hant": "商店拒絕了這套配置,什麼都沒有發布出去。" + }, + "The toolbar itself is turned off in this configuration.": { + "de": "Die Werkzeugleiste selbst ist in dieser Konfiguration ausgeschaltet.", + "es": "La barra de herramientas está desactivada en esta configuración.", + "fr": "La barre d'outils elle-même est désactivée dans cette configuration.", + "id": "Bilah alat itu sendiri dimatikan dalam konfigurasi ini.", + "it": "In questa configurazione la barra degli strumenti è disattivata.", + "ja": "この構成ではツールバー自体がオフになっています。", + "ko": "이 구성에서는 툴바 자체가 꺼져 있습니다.", + "nl": "De werkbalk zelf staat uit in deze configuratie.", + "pl": "W tej konfiguracji sam pasek narzędzi jest wyłączony.", + "ru": "В этой конфигурации сама панель инструментов отключена.", + "tr": "Bu yapılandırmada araç çubuğunun kendisi kapalı.", + "uk": "У цій конфігурації сама панель інструментів вимкнена.", + "zh_Hans": "这套配置里工具栏本身是关闭的。", + "zh_Hant": "這套設定裡工具列本身是關閉的。" + }, + "The update source was written, but refreshing the index failed. Hit Refresh on the Software page later.": { + "de": "Die Update-Quelle wurde eingetragen, aber die Paketliste konnte nicht aktualisiert werden. Klicken Sie später auf der Software-Seite auf Aktualisieren.", + "es": "La fuente de actualizaciones se escribió, pero no se pudo actualizar el índice. Pulse Actualizar en la página Software más tarde.", + "fr": "La source de mise à jour a été écrite, mais l'actualisation de l'index a échoué. Cliquez sur Actualiser dans la page Logiciels plus tard.", + "id": "Sumber pembaruan tertulis, tetapi penyegaran indeks gagal. Klik Segarkan di halaman Perangkat Lunak nanti.", + "it": "La sorgente aggiornamenti è stata scritta, ma l'aggiornamento dell'indice non è riuscito. Premi Aggiorna nella pagina Software più tardi.", + "ja": "更新元は書き込まれましたが、インデックスの更新に失敗しました。後でソフトウェアページの「更新」を押してください。", + "ko": "업데이트 소스는 기록되었지만 색인 새로 고침에 실패했습니다. 나중에 소프트웨어 페이지에서 새로 고침을 누르세요.", + "nl": "De updatebron is weggeschreven, maar het vernieuwen van de index is mislukt. Klik later op Vernieuwen op de Software-pagina.", + "pl": "Źródło aktualizacji zostało zapisane, ale odświeżenie indeksu się nie powiodło. Kliknij później Odśwież na stronie Oprogramowanie.", + "ru": "Источник обновлений записан, но обновить индекс не удалось. Позже нажмите «Обновить» на странице «Программы».", + "tr": "Güncelleme kaynağı yazıldı, ancak dizin yenilenemedi. Daha sonra Yazılım sayfasında Yenile'ye basın.", + "uk": "Джерело оновлень записано, але оновити індекс не вдалося. Пізніше натисніть «Оновити» на сторінці «Програми».", + "zh_Hans": "更新源已写入,但索引刷新失败。稍后在软件包页面点一次「刷新列表」即可。", + "zh_Hant": "更新來源已寫入,但索引重新整理失敗。稍後在軟體套件頁面按一次「重新整理」即可。" + }, + "Theme Marketplace": { + "de": "Theme-Marktplatz", + "es": "Mercado de temas", + "fr": "Marketplace de thèmes", + "id": "Marketplace Tema", + "it": "Marketplace dei temi", + "ja": "テーマ マーケットプレイス", + "ko": "테마 마켓플레이스", + "nl": "Themamarktplaats", + "pl": "Marketplace motywów", + "ru": "Маркетплейс тем", + "tr": "Tema Pazaryeri", + "uk": "Маркетплейс тем", + "zh_Hans": "主题市场", + "zh_Hant": "主題市場" + }, + "This account exists only on this router — reflashing without keeping settings erases it.": { + "de": "Dieses Konto existiert nur auf diesem Router — ein Neuflashen ohne Beibehalten der Einstellungen löscht es.", + "es": "Esta cuenta solo existe en este router: reinstalar el firmware sin conservar la configuración la borra.", + "fr": "Ce compte n'existe que sur ce routeur — réinstaller le firmware sans conserver la configuration l'efface.", + "id": "Akun ini hanya ada di router ini — flash ulang tanpa menyimpan pengaturan akan menghapusnya.", + "it": "Questo account esiste solo su questo router: riflashare senza conservare le impostazioni lo cancella.", + "ja": "このアカウントはこのルーターにしか存在しません。設定を残さずに再フラッシュすると消えます。", + "ko": "이 계정은 이 공유기에만 있습니다 — 설정을 유지하지 않고 다시 플래시하면 사라집니다.", + "nl": "Dit account bestaat alleen op deze router — opnieuw flashen zonder instellingen te behouden wist het.", + "pl": "To konto istnieje tylko na tym routerze — ponowne wgranie firmware bez zachowania ustawień je usunie.", + "ru": "Этот аккаунт существует только на этом роутере — перепрошивка без сохранения настроек удалит его.", + "tr": "Bu hesap yalnızca bu yönlendiricide var — ayarları korumadan yeniden yüklemek onu siler.", + "uk": "Цей обліковий запис існує лише на цьому роутері — перепрошивка без збереження налаштувань видалить його.", + "zh_Hans": "这个账号只存在于这台路由器上 — 刷机时不保留配置就会没。", + "zh_Hant": "這個帳號只存在於這台路由器上 — 刷機時不保留設定就會消失。" + }, + "This account lives on this router.": { + "de": "Dieses Konto liegt auf diesem Router.", + "es": "Esta cuenta vive en este router.", + "fr": "Ce compte est stocké sur ce routeur.", + "id": "Akun ini tersimpan di router ini.", + "it": "Questo account risiede su questo router.", + "ja": "このアカウントはこのルーターに保存されています。", + "ko": "이 계정은 이 공유기에 있습니다.", + "nl": "Dit account staat op deze router.", + "pl": "To konto znajduje się na tym routerze.", + "ru": "Этот аккаунт хранится на этом роутере.", + "tr": "Bu hesap bu yönlendiricide tutuluyor.", + "uk": "Цей обліковий запис зберігається на цьому роутері.", + "zh_Hans": "这个账号存在这台路由器上。", + "zh_Hant": "這個帳號存在這台路由器上。" + }, + "This key is your account. Anyone who has it can publish under your name and delete what you have shared.": { + "de": "Dieser Schlüssel ist dein Konto. Wer ihn hat, kann unter deinem Namen veröffentlichen und deine Freigaben löschen.", + "es": "Esta clave es tu cuenta. Quien la tenga puede publicar con tu nombre y borrar lo que has compartido.", + "fr": "Cette clé est votre compte. Quiconque la possède peut publier en votre nom et supprimer vos partages.", + "id": "Kunci ini adalah akun Anda. Siapa pun yang memilikinya bisa membagikan atas nama Anda dan menghapus bagikan Anda.", + "it": "Questa chiave è il tuo account. Chi la possiede può pubblicare a tuo nome ed eliminare ciò che hai condiviso.", + "ja": "このキーはあなたのアカウントそのものです。手に入れた人はあなたの名前で公開でき、共有したものを削除できます。", + "ko": "이 키가 곧 당신의 계정입니다. 키를 가진 사람은 당신 이름으로 게시하고 공유한 것을 지울 수 있습니다.", + "nl": "Deze sleutel is je account. Wie hem heeft, kan onder jouw naam publiceren en verwijderen wat je hebt gedeeld.", + "pl": "Ten klucz to Twoje konto. Kto go ma, może publikować pod Twoją nazwą i usuwać to, co udostępniłeś.", + "ru": "Этот ключ и есть ваш аккаунт. Тот, у кого он есть, может публиковать от вашего имени и удалять опубликованное вами.", + "tr": "Bu anahtar sizin hesabınızdır. Ona sahip olan herkes sizin adınıza yayın yapabilir ve paylaştıklarınızı silebilir.", + "uk": "Цей ключ — це ваш обліковий запис. Той, хто його має, може публікувати від вашого імені та видаляти опубліковане вами.", + "zh_Hans": "这个密钥就是你的账号。拿到它的人能用你的名义发布,也能删掉你分享的东西。", + "zh_Hant": "這個金鑰就是你的帳號。拿到它的人能用你的名義發布,也能刪掉你分享的東西。" + }, + "This name signs everything you have published, including what is already in the store.": { + "de": "Dieser Name signiert alles Veröffentlichte, auch das, was bereits im Store steht.", + "es": "Este nombre firma todo lo que has publicado, incluido lo que ya está en la tienda.", + "fr": "Ce nom signe tout ce que vous avez publié, y compris ce qui est déjà dans la boutique.", + "id": "Nama ini tercantum pada semua yang Anda bagikan, termasuk yang sudah ada di toko.", + "it": "Questo nome firma tutto ciò che hai pubblicato, compreso quanto è già nello store.", + "ja": "この名前は、すでにストアにあるものも含め、公開したすべてに表示されます。", + "ko": "이 이름은 이미 스토어에 올라간 것까지 포함해, 게시한 모든 것에 표시됩니다.", + "nl": "Deze naam ondertekent alles wat je hebt gepubliceerd, ook wat al in de winkel staat.", + "pl": "Ta nazwa podpisuje wszystko, co opublikowałeś, łącznie z tym, co już jest w sklepie.", + "ru": "Это имя подписывает всё опубликованное вами, включая то, что уже есть в магазине.", + "tr": "Bu ad, mağazada zaten bulunanlar dâhil yayınladığınız her şeyi imzalar.", + "uk": "Це ім'я підписує все опубліковане вами, зокрема те, що вже є в магазині.", + "zh_Hans": "这个名字会署在你发布过的所有东西上,包括商店里已经有的。", + "zh_Hant": "這個名稱會署在你發布過的所有東西上,包括商店裡已經有的。" + }, + "This preset uses a downloaded typeface. The router fetches it once after applying; until then the text falls back to the built-in font.": { + "de": "Dieses Preset verwendet eine Schrift zum Herunterladen. Der Router lädt sie nach dem Anwenden einmalig; bis dahin wird die integrierte Schrift angezeigt.", + "es": "Este preajuste usa una tipografía que se descarga. El router la obtiene una vez tras aplicarlo; hasta entonces el texto usa la fuente integrada.", + "fr": "Ce préréglage utilise une police à télécharger. Le routeur la récupère une seule fois après l'application ; en attendant, le texte utilise la police intégrée.", + "id": "Preset ini memakai font yang perlu diunduh. Router mengunduhnya sekali setelah diterapkan; sampai itu selesai, teks memakai font bawaan.", + "it": "Questo preset usa un carattere da scaricare. Il router lo scarica una volta dopo l'applicazione; fino ad allora il testo usa il font integrato.", + "ja": "このプリセットはダウンロードが必要なフォントを使用します。適用後にルーターが一度だけ取得し、それまでは組み込みフォントで表示されます。", + "ko": "이 프리셋은 내려받아야 하는 글꼴을 사용합니다. 적용 후 공유기가 한 번 내려받으며, 그전까지는 내장 글꼴로 표시됩니다.", + "nl": "Deze preset gebruikt een lettertype dat gedownload moet worden. De router haalt het na het toepassen eenmalig op; tot die tijd wordt het ingebouwde lettertype getoond.", + "pl": "To ustawienie używa czcionki pobieranej z sieci. Router pobierze ją raz po zastosowaniu; do tego czasu tekst korzysta z czcionki wbudowanej.", + "ru": "Этот пресет использует загружаемый шрифт. Роутер скачает его один раз после применения; до этого текст отображается встроенным шрифтом.", + "tr": "Bu hazır ayar indirilen bir yazı tipi kullanır. Yönlendirici uyguladıktan sonra onu bir kez indirir; o ana kadar metin yerleşik yazı tipiyle görünür.", + "uk": "Цей пресет використовує шрифт, який завантажується. Маршрутизатор завантажить його один раз після застосування; доти текст показується вбудованим шрифтом.", + "zh_Hans": "此预设使用需要下载的字体。应用后路由器会下载一次,在那之前文字会先用内置字体显示。", + "zh_Hant": "此預設組使用需要下載的字型。套用後路由器會下載一次,在那之前文字會先以內建字型顯示。" + }, + "This puts back the look you had before applying a theme from the store.": { + "de": "Damit wird das Aussehen wiederhergestellt, das du hattest, bevor du ein Theme aus dem Store angewendet hast.", + "es": "Esto restablece el aspecto que tenías antes de aplicar un tema de la tienda.", + "fr": "Cela rétablit l'apparence que vous aviez avant d'appliquer un thème de la boutique.", + "id": "Ini mengembalikan tampilan yang kamu miliki sebelum menerapkan tema dari toko.", + "it": "Ripristina l'aspetto che avevi prima di applicare un tema dallo store.", + "ja": "ストアのテーマを適用する前の見た目に戻します。", + "ko": "스토어의 테마를 적용하기 전 모습으로 되돌립니다.", + "nl": "Hiermee komt het uiterlijk terug dat je had voordat je een thema uit de store toepaste.", + "pl": "Przywraca wygląd sprzed zastosowania motywu ze sklepu.", + "ru": "Вернёт вид, который был до применения темы из магазина.", + "tr": "Mağazadan bir tema uygulamadan önceki görünümü geri getirir.", + "uk": "Поверне вигляд, який був до застосування теми з магазину.", + "zh_Hans": "这会把你在商店里应用主题之前的样子放回来。", + "zh_Hant": "這會把你在商店中套用主題之前的樣子放回來。" + }, + "This router already has shared configurations of its own. Switching to another identity gives them up for good — back up the current identity first if you still want them.": { + "de": "Dieser Router hat bereits eigene geteilte Konfigurationen. Der Wechsel zu einer anderen Identität gibt sie endgültig auf – sichere zuerst die aktuelle Identität, wenn du sie behalten willst.", + "es": "Este router ya tiene configuraciones compartidas propias. Cambiar a otra identidad las abandona para siempre: haz primero una copia de la identidad actual si aún las quieres.", + "fr": "Ce routeur a déjà ses propres configurations partagées. Passer à une autre identité y renonce définitivement — sauvegardez d'abord l'identité actuelle si vous y tenez.", + "id": "Router ini sudah punya konfigurasi yang dibagikan sendiri. Beralih ke identitas lain melepaskannya selamanya — cadangkan dulu identitas saat ini kalau kamu masih menginginkannya.", + "it": "Questo router ha già configurazioni condivise proprie. Passare a un'altra identità le abbandona per sempre: fai prima il backup dell'identità attuale se le vuoi ancora.", + "ja": "このルーターにはすでに自分で共有した設定があります。別の身元に切り替えると、それらを完全に手放すことになります。まだ必要なら、先に現在の身元をバックアップしてください。", + "ko": "이 공유기에는 이미 직접 공유한 설정이 있습니다. 다른 신원으로 바꾸면 그것들을 영영 포기하게 됩니다. 계속 갖고 싶다면 먼저 현재 신원을 백업하세요.", + "nl": "Deze router heeft al eigen gedeelde configuraties. Overschakelen naar een andere identiteit geeft ze definitief op — maak eerst een back-up van de huidige identiteit als je ze wilt houden.", + "pl": "Ten router ma już własne udostępnione konfiguracje. Przejście na inną tożsamość oznacza ich bezpowrotną utratę — najpierw zrób kopię obecnej tożsamości, jeśli nadal ich chcesz.", + "ru": "У этого роутера уже есть собственные опубликованные конфигурации. Переход на другую личность отдаёт их навсегда — сначала сохраните текущую личность, если они вам ещё нужны.", + "tr": "Bu yönlendiricinin zaten kendi paylaştığı yapılandırmaları var. Başka bir kimliğe geçmek onlardan temelli vazgeçmek demek — hâlâ istiyorsan önce mevcut kimliği yedekle.", + "uk": "Цей роутер уже має власні опубліковані конфігурації. Перехід на іншу особистість віддає їх назавжди — спершу збережіть поточну особистість, якщо вони вам ще потрібні.", + "zh_Hans": "这台路由器已经有自己的作品。换成别的身份会永久放弃它们 —— 还想要就先备份当前身份。", + "zh_Hant": "這台路由器已經有自己的作品。換成別的身分會永久放棄它們 —— 還想要就先備份目前身分。" + }, + "This router already has shares of its own. Importing a different key gives them up for good — back up the current key first if you still want them.": { + "de": "Dieser Router hat bereits eigene Freigaben. Ein anderer Schlüssel gibt sie endgültig auf — sichere zuerst den aktuellen Schlüssel, wenn du sie behalten willst.", + "es": "Este router ya tiene publicaciones propias. Importar otra clave las cede para siempre: guarda antes la clave actual si aún las quieres.", + "fr": "Ce routeur a déjà ses propres partages. Importer une autre clé y renonce définitivement — sauvegardez d'abord la clé actuelle si vous y tenez.", + "id": "Router ini sudah punya bagikan sendiri. Mengimpor kunci lain melepaskannya untuk selamanya — cadangkan dulu kunci saat ini jika masih Anda inginkan.", + "it": "Questo router ha già condivisioni proprie. Importare un'altra chiave le cede per sempre: salva prima la chiave attuale se le vuoi ancora.", + "ja": "このルーターにはすでに自分の共有があります。別のキーをインポートすると、それらを完全に手放すことになります。まだ必要なら先に現在のキーをバックアップしてください。", + "ko": "이 공유기에는 이미 자체 공유가 있습니다. 다른 키를 가져오면 그것들을 영영 포기하게 됩니다 — 아직 필요하면 현재 키를 먼저 백업하세요.", + "nl": "Deze router heeft al eigen gedeelde configuraties. Een andere sleutel importeren geeft ze definitief op — maak eerst een back-up van de huidige sleutel als je ze wilt houden.", + "pl": "Ten router ma już własne udostępnienia. Zaimportowanie innego klucza oddaje je na zawsze — najpierw zapisz obecny klucz, jeśli nadal ich chcesz.", + "ru": "У этого роутера уже есть свои публикации. Импорт другого ключа отдаёт их навсегда — сначала сохраните текущий ключ, если они вам ещё нужны.", + "tr": "Bu yönlendiricinin zaten kendi paylaşımları var. Farklı bir anahtar içe aktarmak onlardan temelli vazgeçer — hâlâ istiyorsanız önce mevcut anahtarı yedekleyin.", + "uk": "У цього роутера вже є власні публікації. Імпорт іншого ключа віддає їх назавжди — спочатку збережіть поточний ключ, якщо вони вам ще потрібні.", + "zh_Hans": "这台路由器已经有自己分享的东西了。导入别的密钥就等于永久放弃它们 — 还想要就先备份当前密钥。", + "zh_Hant": "這台路由器已經有自己分享的東西了。匯入別的金鑰就等於永久放棄它們 — 還想要就先備份目前金鑰。" + }, + "This share belongs to another identity.": { + "de": "Diese Veröffentlichung gehört zu einer anderen Identität.", + "es": "Esta publicación pertenece a otra identidad.", + "fr": "Ce partage appartient à une autre identité.", + "id": "Bagikan ini milik identitas lain.", + "it": "Questa condivisione appartiene a un’altra identità.", + "ja": "この共有は別の身元のものです。", + "ko": "이 공유는 다른 신원의 것입니다.", + "nl": "Deze publicatie hoort bij een andere identiteit.", + "pl": "Ta publikacja należy do innej tożsamości.", + "ru": "Эта публикация принадлежит другой личности.", + "tr": "Bu paylaşım başka bir kimliğe ait.", + "uk": "Ця публікація належить іншій особистості.", + "zh_Hans": "这条分享属于另一个身份。", + "zh_Hant": "這條分享屬於另一個身分。" + }, + "This share includes your uploaded font file. Please make sure you have the right to redistribute it — most commercial font licences do not allow it. Configurations reported for infringement are taken down.": { + "de": "Diese Freigabe enthält die von dir hochgeladene Schriftdatei. Stelle sicher, dass du sie weitergeben darfst — die meisten kommerziellen Schriftlizenzen erlauben das nicht. Bei gemeldeten Rechtsverletzungen wird die Konfiguration entfernt.", + "es": "Esta publicación incluye el archivo de fuente que subiste. Asegúrate de tener derecho a redistribuirlo: la mayoría de las licencias comerciales de fuentes no lo permiten. Las configuraciones denunciadas por infracción se retiran.", + "fr": "Ce partage inclut le fichier de police que vous avez envoyé. Assurez-vous d'avoir le droit de le redistribuer — la plupart des licences de polices commerciales l'interdisent. Les configurations signalées pour contrefaçon sont retirées.", + "id": "Bagikan ini menyertakan berkas font yang Anda unggah. Pastikan Anda berhak mendistribusikannya kembali — sebagian besar lisensi font komersial melarangnya. Konfigurasi yang dilaporkan melanggar akan diturunkan.", + "it": "Questa condivisione include il file del carattere che hai caricato. Assicurati di avere il diritto di ridistribuirlo: la maggior parte delle licenze commerciali non lo consente. Le configurazioni segnalate per violazione vengono rimosse.", + "ja": "この共有にはアップロードしたフォントファイルが含まれます。再配布する権利があるか確認してください — 商用フォントのライセンスの多くは再配布を認めていません。権利侵害の報告があった構成は公開停止となります。", + "ko": "이 공유에는 업로드한 글꼴 파일이 포함됩니다. 재배포할 권리가 있는지 확인하세요 — 대부분의 상용 글꼴 라이선스는 재배포를 허용하지 않습니다. 침해 신고가 접수된 구성은 내려갑니다.", + "nl": "Deze deling bevat het lettertypebestand dat je hebt geüpload. Zorg dat je het mag verspreiden — de meeste commerciële lettertypelicenties staan dat niet toe. Configuraties waarover een inbreukmelding binnenkomt worden verwijderd.", + "pl": "To udostępnienie zawiera przesłany przez Ciebie plik czcionki. Upewnij się, że masz prawo go rozpowszechniać — większość komercyjnych licencji na czcionki tego zabrania. Konfiguracje zgłoszone za naruszenie są usuwane.", + "ru": "В эту публикацию входит загруженный вами файл шрифта. Убедитесь, что у вас есть право на его распространение — большинство коммерческих лицензий на шрифты этого не допускают. Конфигурации, на которые поступила жалоба о нарушении, снимаются с публикации.", + "tr": "Bu paylaşım yüklediğiniz yazı tipi dosyasını içerir. Onu yeniden dağıtma hakkınız olduğundan emin olun — çoğu ticari yazı tipi lisansı buna izin vermez. İhlal bildirimi yapılan yapılandırmalar yayından kaldırılır.", + "uk": "У цю публікацію входить завантажений вами файл шрифту. Переконайтеся, що маєте право на його поширення — більшість комерційних ліцензій на шрифти цього не дозволяють. Конфігурації, на які надійшла скарга про порушення, знімаються з публікації.", + "zh_Hans": "这次分享包含你上传的字体文件。请确认你有权再分发它——多数商业字体授权并不允许。收到侵权举报的配置会被下架。", + "zh_Hant": "這次分享包含你上傳的字型檔。請確認你有權再散布它——多數商業字型授權並不允許。收到侵權檢舉的設定會被下架。" + }, + "This text is your identity. Anyone who has it can publish under your name and delete what you have shared — don't post it anywhere public.": { + "de": "Dieser Text ist deine Identität. Wer ihn hat, kann unter deinem Namen veröffentlichen und löschen, was du geteilt hast – poste ihn nirgendwo öffentlich.", + "es": "Este texto es tu identidad. Quien lo tenga puede publicar con tu nombre y borrar lo que has compartido: no lo publiques en ningún sitio público.", + "fr": "Ce texte est votre identité. Quiconque le possède peut publier en votre nom et supprimer ce que vous avez partagé — ne le publiez nulle part en public.", + "id": "Teks ini adalah identitasmu. Siapa pun yang memilikinya bisa menerbitkan atas namamu dan menghapus apa yang kamu bagikan — jangan tempel di tempat umum.", + "it": "Questo testo è la tua identità. Chi lo possiede può pubblicare a tuo nome ed eliminare ciò che hai condiviso: non pubblicarlo in luoghi pubblici.", + "ja": "この文字列があなたの身元です。手に入れた人はあなたの名前で公開でき、共有したものを削除もできます。公開の場に貼らないでください。", + "ko": "이 문자열이 곧 당신의 신원입니다. 이것을 가진 사람은 당신 이름으로 게시할 수 있고, 공유한 것을 삭제할 수도 있습니다. 공개된 곳에 올리지 마세요.", + "nl": "Deze tekst is je identiteit. Wie hem heeft, kan onder jouw naam publiceren en verwijderen wat je hebt gedeeld — plaats hem nergens openbaar.", + "pl": "Ten tekst to twoja tożsamość. Kto go ma, może publikować pod twoim imieniem i usuwać to, co udostępniłeś — nie wklejaj go w miejscach publicznych.", + "ru": "Этот текст — ваша личность. Любой, у кого он есть, может публиковать от вашего имени и удалять то, чем вы поделились. Не выкладывайте его в открытый доступ.", + "tr": "Bu metin senin kimliğin. Eline geçen herkes senin adınla yayın yapabilir ve paylaştıklarını silebilir — herkese açık hiçbir yere yapıştırma.", + "uk": "Цей текст — ваша особистість. Будь-хто, хто його має, може публікувати від вашого імені та видаляти те, чим ви поділилися. Не викладайте його в загальний доступ.", + "zh_Hans": "这串文字就是你的身份。拿到它的人可以用你的名字发布,也能删掉你已分享的作品 —— 别贴到公开的地方。", + "zh_Hant": "這串文字就是你的身分。拿到它的人可以用你的名字發布,也能刪掉你已分享的作品 —— 別貼到公開的地方。" + }, + "This theme uses a downloaded typeface. The router fetches it once after applying; until then the text falls back to the built-in font.": { + "de": "Dieses Theme verwendet eine Schrift zum Herunterladen. Der Router lädt sie nach dem Anwenden einmalig; bis dahin wird die integrierte Schrift angezeigt.", + "es": "Este tema usa una tipografía que se descarga. El router la obtiene una vez tras aplicarlo; hasta entonces el texto usa la fuente integrada.", + "fr": "Ce thème utilise une police à télécharger. Le routeur la récupère une seule fois après l'application ; en attendant, le texte utilise la police intégrée.", + "id": "Tema ini memakai font yang perlu diunduh. Router mengunduhnya sekali setelah diterapkan; sampai itu selesai, teks memakai font bawaan.", + "it": "Questo tema usa un carattere da scaricare. Il router lo scarica una volta dopo l'applicazione; fino ad allora il testo usa il font integrato.", + "ja": "このテーマはダウンロードが必要なフォントを使用します。適用後にルーターが一度だけ取得し、それまでは組み込みフォントで表示されます。", + "ko": "이 테마는 내려받아야 하는 글꼴을 사용합니다. 적용 후 공유기가 한 번 내려받으며, 그전까지는 내장 글꼴로 표시됩니다.", + "nl": "Dit thema gebruikt een lettertype dat gedownload moet worden. De router haalt het na het toepassen eenmalig op; tot die tijd wordt het ingebouwde lettertype getoond.", + "pl": "Ten motyw używa czcionki pobieranej z sieci. Router pobierze ją raz po zastosowaniu; do tego czasu tekst korzysta z czcionki wbudowanej.", + "ru": "Эта тема использует загружаемый шрифт. Роутер скачает его один раз после применения; до этого текст отображается встроенным шрифтом.", + "tr": "Bu tema indirilen bir yazı tipi kullanır. Yönlendirici uyguladıktan sonra onu bir kez indirir; o ana kadar metin yerleşik yazı tipiyle görünür.", + "uk": "Ця тема використовує шрифт, який завантажується. Маршрутизатор завантажить його один раз після застосування; доти текст показується вбудованим шрифтом.", + "zh_Hans": "此主题使用需要下载的字体。应用后路由器会下载一次,在那之前文字会先用内置字体显示。", + "zh_Hant": "此佈景主題使用需要下載的字型。套用後路由器會下載一次,在那之前文字會先以內建字型顯示。" + }, + "Too large to share (%s; the store's limit is %s)": { + "de": "Zu groß zum Teilen (%s; das Limit des Stores liegt bei %s)", + "es": "Demasiado grande para compartir (%s; el límite de la tienda es %s)", + "fr": "Trop volumineux pour être partagé (%s ; la limite de la boutique est %s)", + "id": "Terlalu besar untuk dibagikan (%s; batas toko adalah %s)", + "it": "Troppo grande da condividere (%s; il limite dello store è %s)", + "ja": "大きすぎて共有できません(%s、ストアの上限は %s)", + "ko": "너무 커서 공유할 수 없음 (%s, 스토어 제한은 %s)", + "nl": "Te groot om te delen (%s; de limiet van de winkel is %s)", + "pl": "Za duży, aby go udostępnić (%s; limit sklepu to %s)", + "ru": "Слишком большой для публикации (%s; ограничение магазина — %s)", + "tr": "Paylaşmak için çok büyük (%s; mağaza sınırı %s)", + "uk": "Завеликий для публікації (%s; обмеження магазину — %s)", + "zh_Hans": "太大,发不出去(%s;商店上限 %s)", + "zh_Hant": "太大,無法分享(%s;商店上限 %s)" + }, + "Toolbar %d": { + "de": "Werkzeugleiste %d", + "es": "Barra de herramientas %d", + "fr": "Barre d'outils %d", + "id": "Bilah alat %d", + "it": "Barra degli strumenti %d", + "ja": "ツールバー %d", + "ko": "도구 모음 %d", + "nl": "Werkbalk %d", + "pl": "Pasek narzędzi %d", + "ru": "Панель инструментов %d", + "tr": "Araç çubuğu %d", + "uk": "Панель інструментів %d", + "zh_Hans": "工具栏 %d", + "zh_Hant": "工具列 %d" + }, + "Toolbar icons ×%d": { + "de": "Werkzeugleisten-Symbole ×%d", + "es": "Iconos de la barra de herramientas ×%d", + "fr": "Icônes de la barre d'outils ×%d", + "id": "Ikon bilah alat ×%d", + "it": "Icone della barra degli strumenti ×%d", + "ja": "ツールバーアイコン ×%d", + "ko": "도구 모음 아이콘 ×%d", + "nl": "Werkbalkpictogrammen ×%d", + "pl": "Ikony paska narzędzi ×%d", + "ru": "Значки панели инструментов ×%d", + "tr": "Araç çubuğu simgeleri ×%d", + "uk": "Значки панелі інструментів ×%d", + "zh_Hans": "工具栏图标 ×%d", + "zh_Hant": "工具列圖示 ×%d" + }, + "Unknown error": { + "de": "Unbekannter Fehler", + "es": "Error desconocido", + "fr": "Erreur inconnue", + "id": "Kesalahan tidak diketahui", + "it": "Errore sconosciuto", + "ja": "不明なエラー", + "ko": "알 수 없는 오류", + "nl": "Onbekende fout", + "pl": "Nieznany błąd", + "ru": "Неизвестная ошибка", + "tr": "Bilinmeyen hata", + "uk": "Невідома помилка", + "zh_Hans": "未知错误", + "zh_Hant": "未知錯誤" + }, + "Update available %s": { + "de": "Update verfügbar %s", + "es": "Actualización disponible %s", + "fr": "Mise à jour disponible %s", + "id": "Pembaruan tersedia %s", + "it": "Aggiornamento disponibile %s", + "ja": "更新あり %s", + "ko": "업데이트 있음 %s", + "nl": "Update beschikbaar %s", + "pl": "Dostępna aktualizacja %s", + "ru": "Доступно обновление %s", + "tr": "Güncelleme mevcut %s", + "uk": "Доступне оновлення %s", + "zh_Hans": "有新版 %s", + "zh_Hant": "有新版 %s" + }, + "Update source added": { + "de": "Update-Quelle hinzugefügt", + "es": "Fuente de actualizaciones añadida", + "fr": "Source de mise à jour ajoutée", + "id": "Sumber pembaruan ditambahkan", + "it": "Sorgente aggiornamenti aggiunta", + "ja": "更新元を追加しました", + "ko": "업데이트 소스가 추가되었습니다", + "nl": "Updatebron toegevoegd", + "pl": "Dodano źródło aktualizacji", + "ru": "Источник обновлений добавлен", + "tr": "Güncelleme kaynağı eklendi", + "uk": "Джерело оновлень додано", + "zh_Hans": "更新源已添加", + "zh_Hant": "更新來源已新增" + }, + "Upgrading Aurora needs its update source. Once added, upgrades happen in System → Software like any other OpenWrt package.": { + "de": "Für Aurora-Updates wird dessen Update-Quelle benötigt. Einmal hinzugefügt, erfolgen Updates wie bei jedem OpenWrt-Paket unter System → Software.", + "es": "Para actualizar Aurora hace falta su fuente de actualizaciones. Una vez añadida, las actualizaciones se hacen en Sistema → Software, como con cualquier paquete de OpenWrt.", + "fr": "La mise à jour d'Aurora nécessite sa source de mise à jour. Une fois ajoutée, les mises à jour se font dans Système → Logiciels, comme pour tout paquet OpenWrt.", + "id": "Memperbarui Aurora memerlukan sumber pembaruannya. Setelah ditambahkan, pembaruan dilakukan di Sistem → Perangkat Lunak seperti paket OpenWrt lainnya.", + "it": "Per aggiornare Aurora serve la sua sorgente aggiornamenti. Una volta aggiunta, gli aggiornamenti avvengono in Sistema → Software come per ogni pacchetto OpenWrt.", + "ja": "Aurora の更新には更新元が必要です。追加すれば、他の OpenWrt パッケージと同様に システム → ソフトウェア から更新できます。", + "ko": "Aurora를 업그레이드하려면 업데이트 소스가 필요합니다. 추가하고 나면 다른 OpenWrt 패키지처럼 시스템 → 소프트웨어에서 업그레이드합니다.", + "nl": "Voor het bijwerken van Aurora is de updatebron nodig. Eenmaal toegevoegd verlopen updates via Systeem → Software, net als bij elk OpenWrt-pakket.", + "pl": "Aktualizacja Aurory wymaga jej źródła aktualizacji. Po dodaniu aktualizacje odbywają się w System → Oprogramowanie, jak w przypadku każdego pakietu OpenWrt.", + "ru": "Для обновления Aurora нужен её источник обновлений. После добавления обновления выполняются в разделе Система → Программы, как для любого пакета OpenWrt.", + "tr": "Aurora'yı güncellemek için güncelleme kaynağı gerekir. Eklendikten sonra güncellemeler, diğer OpenWrt paketleri gibi Sistem → Yazılım üzerinden yapılır.", + "uk": "Для оновлення Aurora потрібне її джерело оновлень. Після додавання оновлення виконуються в розділі Система → Програми, як і для будь-якого пакунка OpenWrt.", + "zh_Hans": "升级 Aurora 需要先添加更新源。添加后,就能在「系统 → 软件包」里升级,和其他 OpenWrt 软件一样。", + "zh_Hant": "升級 Aurora 需要先新增更新來源。新增後,就能在「系統 → 軟體套件」裡升級,和其他 OpenWrt 軟體一樣。" + }, + "Uploaded with the theme, %s": { + "de": "Wird mit dem Theme hochgeladen, %s", + "es": "Se sube junto con el tema, %s", + "fr": "Envoyé avec le thème, %s", + "id": "Diunggah bersama tema, %s", + "it": "Caricato insieme al tema, %s", + "ja": "テーマと一緒にアップロード、%s", + "ko": "테마와 함께 업로드됨, %s", + "nl": "Wordt met het thema geüpload, %s", + "pl": "Przesyłany razem z motywem, %s", + "ru": "Загружается вместе с темой, %s", + "tr": "Temayla birlikte yüklenir, %s", + "uk": "Завантажується разом із темою, %s", + "zh_Hans": "随主题一起上传,%s", + "zh_Hant": "隨佈景主題一起上傳,%s" + }, + "Uploading %s… %d%%": { + "de": "%s wird hochgeladen … %d%%", + "es": "Subiendo %s… %d%%", + "fr": "Envoi de %s… %d%%", + "id": "Mengunggah %s… %d%%", + "it": "Caricamento di %s… %d%%", + "ja": "%sをアップロード中… %d%%", + "ko": "%s 업로드 중… %d%%", + "nl": "%s uploaden… %d%%", + "pl": "Przesyłanie: %s… %d%%", + "ru": "Загрузка: %s… %d%%", + "tr": "%s yükleniyor… %d%%", + "uk": "Завантаження: %s… %d%%", + "zh_Hans": "正在上传%s… %d%%", + "zh_Hant": "正在上傳%s… %d%%" + }, + "Uploading %s… %d%% (%d/%d)": { + "de": "%s wird hochgeladen … %d%% (%d/%d)", + "es": "Subiendo %s… %d%% (%d/%d)", + "fr": "Envoi de %s… %d%% (%d/%d)", + "id": "Mengunggah %s… %d%% (%d/%d)", + "it": "Caricamento di %s… %d%% (%d/%d)", + "ja": "%sをアップロード中… %d%%(%d/%d)", + "ko": "%s 업로드 중… %d%% (%d/%d)", + "nl": "%s uploaden… %d%% (%d/%d)", + "pl": "Przesyłanie: %s… %d%% (%d/%d)", + "ru": "Загрузка: %s… %d%% (%d/%d)", + "tr": "%s yükleniyor… %d%% (%d/%d)", + "uk": "Завантаження: %s… %d%% (%d/%d)", + "zh_Hans": "正在上传%s… %d%%(%d/%d)", + "zh_Hant": "正在上傳%s… %d%%(%d/%d)" + }, + "Uploading the images failed. Check this computer's internet connection and try again.": { + "de": "Das Hochladen der Bilder ist fehlgeschlagen. Prüfe die Internetverbindung dieses Computers und versuch es noch einmal.", + "es": "Ha fallado la subida de las imágenes. Comprueba la conexión a internet de este ordenador e inténtalo otra vez.", + "fr": "L’envoi des images a échoué. Vérifiez la connexion internet de cet ordinateur et réessayez.", + "id": "Pengunggahan gambar gagal. Periksa koneksi internet komputer ini lalu coba lagi.", + "it": "Il caricamento delle immagini non è riuscito. Controlla la connessione a internet di questo computer e riprova.", + "ja": "画像のアップロードに失敗しました。このパソコンのインターネット接続を確認してやり直してください。", + "ko": "이미지 업로드에 실패했습니다. 이 컴퓨터의 인터넷 연결을 확인한 뒤 다시 시도하세요.", + "nl": "Het uploaden van de afbeeldingen is mislukt. Controleer de internetverbinding van deze computer en probeer het opnieuw.", + "pl": "Nie udało się przesłać obrazów. Sprawdź połączenie internetowe tego komputera i spróbuj ponownie.", + "ru": "Не удалось загрузить изображения. Проверьте интернет-соединение этого компьютера и попробуйте снова.", + "tr": "Görseller yüklenemedi. Bu bilgisayarın internet bağlantısını kontrol edip tekrar dene.", + "uk": "Не вдалося завантажити зображення. Перевірте інтернет-з’єднання цього комп’ютера та спробуйте ще раз.", + "zh_Hans": "图片上传失败。检查一下这台电脑能不能上网,然后重试。", + "zh_Hant": "圖片上傳失敗。檢查一下這台電腦能不能上網,然後重試。" + }, + "Want to publish another one? Set it up in the design studio and publish from there.": { + "de": "Noch eine veröffentlichen? Richte sie im Designstudio ein und veröffentliche sie von dort.", + "es": "¿Quieres publicar otra? Prepárala en el Estudio de diseño y publícala desde ahí.", + "fr": "Envie d’en publier une autre ? Composez-la dans le Studio de design, puis publiez depuis cette page.", + "id": "Mau menerbitkan satu lagi? Siapkan di Studio Desain lalu terbitkan dari sana.", + "it": "Vuoi pubblicarne un’altra? Preparala nello Studio di design e pubblicala da lì.", + "ja": "もう一つ公開しますか?デザインスタジオで仕上げて、そこから公開してください。", + "ko": "하나 더 게시할까요? 디자인 스튜디오에서 꾸민 다음 거기서 게시하세요.", + "nl": "Nog een publiceren? Stel hem in de Ontwerpstudio samen en publiceer daarvandaan.", + "pl": "Chcesz opublikować kolejną? Przygotuj ją w Studiu projektowym i opublikuj stamtąd.", + "ru": "Хотите опубликовать ещё одну? Соберите её в Студии дизайна и опубликуйте оттуда.", + "tr": "Bir tane daha yayımlamak ister misin? Tasarım Stüdyosu’nda hazırla ve oradan yayımla.", + "uk": "Хочете опублікувати ще одну? Складіть її в Студії дизайну й опублікуйте звідти.", + "zh_Hans": "想再发一套?回主题工作台调好,从那里发布。", + "zh_Hant": "想再發一套?回主題工作台調好,從那裡發布。" + }, + "What gets shared": { + "de": "Was geteilt wird", + "es": "Qué se comparte", + "fr": "Ce qui est partagé", + "id": "Apa yang dibagikan", + "it": "Cosa viene condiviso", + "ja": "共有される内容", + "ko": "공유되는 항목", + "nl": "Wat er wordt gedeeld", + "pl": "Co jest udostępniane", + "ru": "Что будет опубликовано", + "tr": "Neler paylaşılıyor", + "uk": "Що буде опубліковано", + "zh_Hans": "本次分享包含", + "zh_Hant": "本次分享包含" + }, + "What is this? Where did it come from?": { + "de": "Was ist das? Woher kommt es?", + "es": "¿Qué es esto? ¿De dónde salió?", + "fr": "Qu'est-ce que c'est ? D'où vient-elle ?", + "id": "Ini apa? Dari mana asalnya?", + "it": "Che cos'è? Da dove viene?", + "ja": "これは何?どこから来たの?", + "ko": "이게 뭔가요? 어디서 온 건가요?", + "nl": "Wat is dit? Waar komt het vandaan?", + "pl": "Co to jest? Skąd się wzięło?", + "ru": "Что это? Откуда оно взялось?", + "tr": "Bu nedir? Nereden geldi?", + "uk": "Що це? Звідки воно взялося?", + "zh_Hans": "这是什么?从哪来的?", + "zh_Hant": "這是什麼?從哪來的?" + }, + "Where this configuration goes": { + "de": "Wohin diese Konfiguration geht", + "es": "A dónde va esta configuración", + "fr": "Où va cette configuration", + "id": "Ke mana konfigurasi ini pergi", + "it": "Dove va questa configurazione", + "ja": "この設定の公開先", + "ko": "이 구성이 올라갈 곳", + "nl": "Waar deze configuratie heen gaat", + "pl": "Dokąd trafia ta konfiguracja", + "ru": "Куда отправится эта конфигурация", + "tr": "Bu yapılandırma nereye gidiyor", + "uk": "Куди піде ця конфігурація", + "zh_Hans": "这套配置要放到哪儿", + "zh_Hant": "這套配置要放到哪裡" + }, + "You've published as much as one router may publish today. Try again tomorrow.": { + "de": "Für heute hat dieser Router sein Veröffentlichungskontingent aufgebraucht. Versuch es morgen wieder.", + "es": "Este router ya ha publicado todo lo que puede hoy. Inténtalo mañana.", + "fr": "Ce routeur a publié tout ce qu’il pouvait aujourd’hui. Réessayez demain.", + "id": "Router ini sudah menerbitkan sebanyak yang boleh hari ini. Coba lagi besok.", + "it": "Questo router ha già pubblicato tutto quello che può per oggi. Riprova domani.", + "ja": "このルーターが今日公開できる分は使い切りました。明日またお試しください。", + "ko": "이 공유기가 오늘 게시할 수 있는 양을 다 썼습니다. 내일 다시 시도하세요.", + "nl": "Deze router heeft vandaag alles gepubliceerd wat mag. Probeer het morgen opnieuw.", + "pl": "Ten router opublikował już dziś tyle, ile może. Spróbuj jutro.", + "ru": "Этот роутер уже опубликовал сегодня всё, что можно. Попробуйте завтра.", + "tr": "Bu yönlendirici bugün yayımlayabileceği kadarını yayımladı. Yarın tekrar dene.", + "uk": "Цей роутер уже опублікував сьогодні все, що можна. Спробуйте завтра.", + "zh_Hans": "今天这台路由器能发布的量已经用完了,明天再来。", + "zh_Hant": "今天這台路由器能發布的量已經用完了,明天再來。" + }, + "Your creator identity lives only on this router — back it up so a reflash can't take it.": { + "de": "Deine Ersteller-Identität existiert nur auf diesem Router – sichere sie, damit ein Neuflashen sie nicht mitnimmt.", + "es": "Tu identidad de creador solo existe en este router: haz una copia para que reinstalar el firmware no se la lleve.", + "fr": "Votre identité de créateur n'existe que sur ce routeur — sauvegardez-la pour qu'un reflash ne l'emporte pas.", + "id": "Identitas kreatormu hanya ada di router ini — cadangkan agar flash ulang tidak membawanya pergi.", + "it": "La tua identità di creatore esiste solo su questo router: fanne un backup, così un reflash non se la porta via.", + "ja": "作成者としての身元はこのルーターにしか存在しません。再フラッシュで失わないようバックアップしてください。", + "ko": "제작자 신원은 이 공유기에만 존재합니다. 펌웨어를 다시 올려도 잃지 않도록 백업하세요.", + "nl": "Je makersidentiteit bestaat alleen op deze router — maak een back-up, dan neemt een herinstallatie haar niet mee.", + "pl": "Twoja tożsamość twórcy istnieje tylko na tym routerze — zrób kopię, żeby ponowne wgranie firmware'u jej nie zabrało.", + "ru": "Ваша личность автора существует только на этом роутере — сохраните её, чтобы перепрошивка её не унесла.", + "tr": "Yaratıcı kimliğin yalnızca bu yönlendiricide bulunuyor — yeniden flash'lamanın onu alıp götürmemesi için yedekle.", + "uk": "Ваша особистість автора існує лише на цьому роутері — збережіть її, щоб перепрошивка її не забрала.", + "zh_Hans": "你的创作者身份只存在这台路由器上 —— 备份一下,刷机就带不走它。", + "zh_Hant": "你的創作者身分只存在這台路由器上 —— 備份一下,刷機就帶不走它。" + }, + "Your shortcuts, logo, icons and login background are left exactly as they are.": { + "de": "Verknüpfungen, Logo, Symbole und Login-Hintergrund bleiben genau so, wie sie sind.", + "es": "Tus accesos directos, logotipo, iconos y fondo de inicio de sesión se mantienen exactamente igual.", + "fr": "Vos raccourcis, votre logo, vos icônes et l'arrière-plan de connexion restent exactement tels quels.", + "id": "Pintasan, logo, ikon, dan latar belakang login Anda tetap persis seperti sekarang.", + "it": "Le tue scorciatoie, il logo, le icone e lo sfondo del login restano esattamente come sono.", + "ja": "ショートカット、ロゴ、アイコン、ログイン背景はそのまま残ります。", + "ko": "바로 가기, 로고, 아이콘, 로그인 배경은 그대로 유지됩니다.", + "nl": "Je snelkoppelingen, logo, pictogrammen en inlogachtergrond blijven precies zoals ze zijn.", + "pl": "Twoje skróty, logo, ikony i tło logowania pozostają dokładnie takie same.", + "ru": "Ваши ярлыки, логотип, значки и фон входа остаются ровно такими же.", + "tr": "Kısayollarınız, logonuz, simgeleriniz ve giriş arka planınız aynen kalır.", + "uk": "Ваші ярлики, логотип, значки та фон входу залишаються точно такими самими.", + "zh_Hans": "你的快捷方式、Logo、图标和登录背景保持原样不变。", + "zh_Hant": "你的捷徑、Logo、圖示與登入背景維持原樣不變。" + }, + "unchanged by this preset": { + "de": "von diesem Preset nicht geändert", + "es": "sin cambios en este preset", + "fr": "inchangé par ce préréglage", + "id": "tidak diubah oleh preset ini", + "it": "invariato con questo preset", + "ja": "このプリセットでは変更なし", + "ko": "이 프리셋에서는 변경 없음", + "nl": "onveranderd door deze preset", + "pl": "bez zmian w tym zestawie", + "ru": "не изменяется этим набором", + "tr": "bu ön ayardan etkilenmez", + "uk": "без змін у цьому наборі", + "zh_Hans": "此预设不改动", + "zh_Hant": "此預設不變動" + }, + "JPG · PNG · WebP · SVG · ICO · up to 8 MB each": { + "de": "JPG · PNG · WebP · SVG · ICO · je max. 8 MB", + "es": "JPG · PNG · WebP · SVG · ICO · hasta 8 MB cada uno", + "fr": "JPG · PNG · WebP · SVG · ICO · 8 Mo maximum chacun", + "id": "JPG · PNG · WebP · SVG · ICO · maksimal 8 MB per berkas", + "it": "JPG · PNG · WebP · SVG · ICO · fino a 8 MB ciascuno", + "ja": "JPG · PNG · WebP · SVG · ICO · 1 ファイル 8 MB まで", + "ko": "JPG · PNG · WebP · SVG · ICO · 각 8 MB 이하", + "nl": "JPG · PNG · WebP · SVG · ICO · max. 8 MB per bestand", + "pl": "JPG · PNG · WebP · SVG · ICO · maks. 8 MB każdy", + "ru": "JPG · PNG · WebP · SVG · ICO · до 8 МБ каждый", + "tr": "JPG · PNG · WebP · SVG · ICO · her biri en fazla 8 MB", + "uk": "JPG · PNG · WebP · SVG · ICO · до 8 МБ кожен", + "zh_Hans": "JPG · PNG · WebP · SVG · ICO · 单个不超过 8 MB", + "zh_Hant": "JPG · PNG · WebP · SVG · ICO · 單個不超過 8 MB" + }, + "PNG · JPG · WebP · up to 8 MB": { + "de": "PNG · JPG · WebP · max. 8 MB", + "es": "PNG · JPG · WebP · hasta 8 MB", + "fr": "PNG · JPG · WebP · 8 Mo maximum", + "id": "PNG · JPG · WebP · maksimal 8 MB", + "it": "PNG · JPG · WebP · fino a 8 MB", + "ja": "PNG · JPG · WebP · 8 MB まで", + "ko": "PNG · JPG · WebP · 8 MB 이하", + "nl": "PNG · JPG · WebP · max. 8 MB", + "pl": "PNG · JPG · WebP · maks. 8 MB", + "ru": "PNG · JPG · WebP · до 8 МБ", + "tr": "PNG · JPG · WebP · en fazla 8 MB", + "uk": "PNG · JPG · WebP · до 8 МБ", + "zh_Hans": "PNG · JPG · WebP · 不超过 8 MB", + "zh_Hant": "PNG · JPG · WebP · 不超過 8 MB" + }, + "One of your images is over the 8 MB limit. Replace it in the Brand Asset Library, then share again.": { + "de": "Eines deiner Bilder überschreitet das Limit von 8 MB. Ersetze es in der Brand-Asset-Bibliothek und teile dann erneut.", + "es": "Una de tus imágenes supera el límite de 8 MB. Sustitúyela en la Biblioteca de recursos de marca y vuelve a compartir.", + "fr": "Une de vos images dépasse la limite de 8 Mo. Remplacez-la dans la Bibliothèque de ressources de marque, puis partagez à nouveau.", + "id": "Salah satu gambar Anda melebihi batas 8 MB. Ganti di Pustaka Aset Merek, lalu bagikan lagi.", + "it": "Una delle tue immagini supera il limite di 8 MB. Sostituiscila nella Libreria delle risorse del brand, poi condividi di nuovo.", + "ja": "画像のいずれかが 8 MB の上限を超えています。ブランドアセットライブラリで差し替えてから、もう一度共有してください。", + "ko": "이미지 중 하나가 8 MB 제한을 초과합니다. 브랜드 자산 라이브러리에서 교체한 뒤 다시 공유하세요.", + "nl": "Een van je afbeeldingen is groter dan 8 MB. Vervang die in de Merk-assetbibliotheek en deel opnieuw.", + "pl": "Jeden z obrazów przekracza limit 8 MB. Zastąp go w Bibliotece zasobów marki i udostępnij ponownie.", + "ru": "Одно из изображений превышает лимит в 8 МБ. Замените его в Библиотеке ресурсов бренда и поделитесь снова.", + "tr": "Görsellerinizden biri 8 MB sınırını aşıyor. Marka Varlıkları Kütüphanesi'nde değiştirip yeniden paylaşın.", + "uk": "Одне із зображень перевищує ліміт у 8 МБ. Замініть його в Бібліотеці ресурсів бренду й поділіться знову.", + "zh_Hans": "有一张图片超过了 8 MB 上限。到品牌资源库里换一张,再重新分享。", + "zh_Hant": "有一張圖片超過了 8 MB 上限。到品牌素材庫裡換一張,再重新分享。" + }, + "Assets not approved": { + "de": "Dateien nicht freigegeben", + "es": "Recursos no aprobados", + "fr": "Ressources non approuvées", + "id": "Aset tidak disetujui", + "it": "Risorse non approvate", + "ja": "素材は承認されませんでした", + "ko": "자산이 승인되지 않았습니다", + "nl": "Bestanden niet goedgekeurd", + "pl": "Zasoby nie zostały zatwierdzone", + "ru": "Ресурсы не одобрены", + "tr": "Varlıklar onaylanmadı", + "uk": "Ресурси не схвалено", + "zh_Hans": "素材未通过审核", + "zh_Hant": "素材未通過審核" + }, + "One of your assets is too large to share. Replace it, then share again.": { + "de": "Eine deiner Dateien ist zu groß zum Teilen. Ersetze sie und teile dann erneut.", + "es": "Uno de tus recursos es demasiado grande para compartirlo. Sustitúyelo y vuelve a compartir.", + "fr": "Une de vos ressources est trop volumineuse pour être partagée. Remplacez-la, puis partagez à nouveau.", + "id": "Salah satu aset Anda terlalu besar untuk dibagikan. Ganti, lalu bagikan lagi.", + "it": "Una delle tue risorse è troppo grande per essere condivisa. Sostituiscila, poi condividi di nuovo.", + "ja": "素材のいずれかが共有できる大きさを超えています。差し替えてから、もう一度共有してください。", + "ko": "자산 중 하나가 공유하기에 너무 큽니다. 교체한 뒤 다시 공유하세요.", + "nl": "Een van je bestanden is te groot om te delen. Vervang die en deel opnieuw.", + "pl": "Jeden z zasobów jest za duży, aby go udostępnić. Zastąp go i udostępnij ponownie.", + "ru": "Один из ресурсов слишком большой для публикации. Замените его и поделитесь снова.", + "tr": "Varlıklarınızdan biri paylaşılamayacak kadar büyük. Değiştirip yeniden paylaşın.", + "uk": "Один із ресурсів завеликий для публікації. Замініть його й поділіться знову.", + "zh_Hans": "有一个素材太大,分享不了。换掉它,再重新分享。", + "zh_Hant": "有一個素材太大,分享不了。換掉它,再重新分享。" + }, + "Couldn't read one of the assets from this router. Try reloading the page.": { + "de": "Eine der Dateien konnte nicht vom Router gelesen werden. Lade die Seite neu und versuch es noch einmal.", + "es": "No se ha podido leer uno de los recursos del router. Vuelve a cargar la página e inténtalo otra vez.", + "fr": "Impossible de lire l’une des ressources depuis le routeur. Rechargez la page et réessayez.", + "id": "Salah satu aset tidak terbaca dari router. Muat ulang halaman lalu coba lagi.", + "it": "Non è stato possibile leggere una delle risorse dal router. Ricarica la pagina e riprova.", + "ja": "ルーターから素材を一つ読み取れませんでした。ページを再読み込みしてやり直してください。", + "ko": "공유기에서 자산 하나를 읽지 못했습니다. 페이지를 새로 고친 뒤 다시 시도하세요.", + "nl": "Een van de bestanden kon niet van de router worden gelezen. Herlaad de pagina en probeer het opnieuw.", + "pl": "Nie udało się odczytać jednego z zasobów z routera. Odśwież stronę i spróbuj ponownie.", + "ru": "Не удалось прочитать один из ресурсов с роутера. Обновите страницу и попробуйте снова.", + "tr": "Varlıklardan biri yönlendiriciden okunamadı. Sayfayı yenileyip tekrar dene.", + "uk": "Не вдалося прочитати один із ресурсів з роутера. Перезавантажте сторінку та спробуйте ще раз.", + "zh_Hans": "从路由器上读不到其中一个素材,刷新页面再试。", + "zh_Hant": "從路由器上讀不到其中一個素材,重新整理頁面再試。" + }, + "Uploading the assets failed. Check this computer's internet connection and try again.": { + "de": "Das Hochladen der Dateien ist fehlgeschlagen. Prüfe die Internetverbindung dieses Computers und versuch es noch einmal.", + "es": "Ha fallado la subida de los recursos. Comprueba la conexión a internet de este ordenador e inténtalo otra vez.", + "fr": "L’envoi des ressources a échoué. Vérifiez la connexion internet de cet ordinateur et réessayez.", + "id": "Pengunggahan aset gagal. Periksa koneksi internet komputer ini lalu coba lagi.", + "it": "Il caricamento delle risorse non è riuscito. Controlla la connessione a internet di questo computer e riprova.", + "ja": "素材のアップロードに失敗しました。このパソコンのインターネット接続を確認してやり直してください。", + "ko": "자산 업로드에 실패했습니다. 이 컴퓨터의 인터넷 연결을 확인한 뒤 다시 시도하세요.", + "nl": "Het uploaden van de bestanden is mislukt. Controleer de internetverbinding van deze computer en probeer het opnieuw.", + "pl": "Nie udało się przesłać zasobów. Sprawdź połączenie internetowe tego komputera i spróbuj ponownie.", + "ru": "Не удалось загрузить ресурсы. Проверьте интернет-соединение этого компьютера и попробуйте снова.", + "tr": "Varlıklar yüklenemedi. Bu bilgisayarın internet bağlantısını kontrol edip tekrar dene.", + "uk": "Не вдалося завантажити ресурси. Перевірте інтернет-з’єднання цього комп’ютера та спробуйте ще раз.", + "zh_Hans": "素材上传失败。检查一下这台电脑能不能上网,然后重试。", + "zh_Hant": "素材上傳失敗。檢查一下這台電腦能不能上網,然後重試。" + }, + "In review — appears in the store once its assets are approved.": { + "de": "In Prüfung – erscheint im Store, sobald die Dateien freigegeben sind.", + "es": "En revisión: aparece en la tienda cuando se aprueben sus recursos.", + "fr": "En cours d'examen — apparaît dans la boutique une fois ses ressources approuvées.", + "id": "Sedang ditinjau — muncul di toko setelah asetnya disetujui.", + "it": "In revisione: compare nello store una volta approvate le sue risorse.", + "ja": "審査中 — 素材が承認されるとストアに表示されます。", + "ko": "검토 중 — 자산이 승인되면 스토어에 표시됩니다.", + "nl": "In beoordeling — verschijnt in de store zodra de bestanden zijn goedgekeurd.", + "pl": "W trakcie weryfikacji — pojawi się w sklepie, gdy zasoby zostaną zatwierdzone.", + "ru": "На проверке — появится в магазине, когда ресурсы одобрят.", + "tr": "İncelemede — varlıkları onaylandığında mağazada görünür.", + "uk": "На перевірці — з'явиться в магазині, коли ресурси схвалять.", + "zh_Hans": "审核中 —— 素材通过后才会出现在商店里。", + "zh_Hant": "審核中 —— 素材通過後才會出現在商店裡。" + }, + "Published. Its assets are queued for review — it appears in the store once they are approved.": { + "de": "Veröffentlicht. Die Dateien stehen in der Prüfung – im Store erscheint es, sobald sie freigegeben sind.", + "es": "Publicado. Sus recursos están en cola de revisión: aparecerá en la tienda cuando se aprueben.", + "fr": "Publié. Ses ressources sont en attente d'examen — il apparaîtra dans la boutique une fois approuvées.", + "id": "Diterbitkan. Asetnya sedang antre ditinjau — akan muncul di toko setelah disetujui.", + "it": "Pubblicato. Le sue risorse sono in coda per la revisione: comparirà nello store una volta approvate.", + "ja": "公開しました。素材は審査待ちです — 承認されるとストアに表示されます。", + "ko": "게시했습니다. 자산은 검토 대기 중입니다 — 승인되면 스토어에 표시됩니다.", + "nl": "Gepubliceerd. De bestanden wachten op beoordeling — het verschijnt in de store zodra ze zijn goedgekeurd.", + "pl": "Opublikowano. Zasoby czekają na weryfikację — pojawi się w sklepie, gdy zostaną zatwierdzone.", + "ru": "Опубликовано. Ресурсы ждут проверки — появится в магазине после одобрения.", + "tr": "Yayınlandı. Varlıklar inceleme sırasında — onaylandığında mağazada görünecek.", + "uk": "Опубліковано. Ресурси чекають на перевірку — з'явиться в магазині після схвалення.", + "zh_Hans": "已发布。素材正在排队等审核 —— 通过后才会出现在商店里。", + "zh_Hant": "已發布。素材正在排隊等審核 —— 通過後才會出現在商店裡。" + }, + "Learn more": { + "de": "Mehr erfahren", + "es": "Más información", + "fr": "En savoir plus", + "id": "Pelajari lebih lanjut", + "it": "Scopri di più", + "ja": "詳しく見る", + "ko": "자세히 보기", + "nl": "Meer informatie", + "pl": "Dowiedz się więcej", + "ru": "Подробнее", + "tr": "Daha fazla bilgi", + "uk": "Докладніше", + "zh_Hans": "了解详情", + "zh_Hant": "瞭解詳情" + }, + "Needs update": { + "de": "Aktualisierung nötig", + "es": "Necesita actualizarse", + "fr": "Mise à jour requise", + "id": "Perlu diperbarui", + "it": "Da aggiornare", + "ja": "要更新", + "ko": "업데이트 필요", + "nl": "Bijwerken nodig", + "pl": "Wymaga aktualizacji", + "ru": "Нужно обновить", + "tr": "Güncelleme gerekli", + "uk": "Потрібне оновлення", + "zh_Hans": "需要更新", + "zh_Hant": "需要更新" + }, + "%d need an update": { + "de": "%d brauchen ein Update", + "es": "%d necesitan actualizarse", + "fr": "%d à mettre à jour", + "id": "%d perlu diperbarui", + "it": "%d da aggiornare", + "ja": "%d 件が要更新", + "ko": "%d개 업데이트 필요", + "nl": "%d moeten worden bijgewerkt", + "pl": "%d do aktualizacji", + "ru": "%d нужно обновить", + "tr": "%d tanesi güncellenmeli", + "uk": "%d потрібно оновити", + "zh_Hans": "%d 个需要更新", + "zh_Hant": "%d 個需要更新" + }, + "%d need an update before %s": { + "de": "%d brauchen ein Update vor dem %s", + "es": "%d necesitan actualizarse antes del %s", + "fr": "%d à mettre à jour avant le %s", + "id": "%d perlu diperbarui sebelum %s", + "it": "%d da aggiornare entro il %s", + "ja": "%d 件が %s までに要更新", + "ko": "%d개를 %s 전에 업데이트해야 함", + "nl": "%d moeten vóór %s worden bijgewerkt", + "pl": "%d do aktualizacji przed %s", + "ru": "%d нужно обновить до %s", + "tr": "%d tanesi %s tarihinden önce güncellenmeli", + "uk": "%d потрібно оновити до %s", + "zh_Hans": "%d 个需要在 %s 之前更新", + "zh_Hant": "%d 個需要在 %s 之前更新" + }, + "%d not listed for updated routers": { + "de": "%d auf aktualisierten Routern nicht gelistet", + "es": "%d no aparecen en los routers actualizados", + "fr": "%d non proposés aux routeurs mis à jour", + "id": "%d tidak ditampilkan untuk router yang sudah diperbarui", + "it": "%d non in elenco per i router aggiornati", + "ja": "%d 件が更新済みルーターに非表示", + "ko": "%d개가 업데이트된 라우터에 표시되지 않음", + "nl": "%d niet getoond op bijgewerkte routers", + "pl": "%d niewidocznych na zaktualizowanych routerach", + "ru": "%d не показываются обновлённым роутерам", + "tr": "%d tanesi güncellenmiş yönlendiricilerde listelenmiyor", + "uk": "%d не показуються оновленим роутерам", + "zh_Hans": "%d 个不再向升级后的路由器展示", + "zh_Hant": "%d 個不再向升級後的路由器顯示" + }, + "%d of your shared themes are not listed for updated routers": { + "de": "%d deiner geteilten Themes werden auf aktualisierten Routern nicht gelistet", + "es": "%d de tus temas compartidos no aparecen en los routers actualizados", + "fr": "%d de vos thèmes partagés ne sont pas proposés aux routeurs mis à jour", + "id": "%d tema yang Anda bagikan tidak ditampilkan untuk router yang sudah diperbarui", + "it": "%d dei tuoi temi condivisi non sono in elenco per i router aggiornati", + "ja": "共有テーマのうち %d 件が更新済みルーターに表示されていません", + "ko": "공유한 테마 중 %d개가 업데이트된 라우터에 표시되지 않습니다", + "nl": "%d van je gedeelde thema's worden niet getoond op bijgewerkte routers", + "pl": "%d z Twoich udostępnionych motywów nie widać na zaktualizowanych routerach", + "ru": "%d из ваших опубликованных тем не показываются обновлённым роутерам", + "tr": "Paylaştığınız temalardan %d tanesi güncellenmiş yönlendiricilerde listelenmiyor", + "uk": "%d з ваших опублікованих тем не показуються оновленим роутерам", + "zh_Hans": "你分享的主题中有 %d 个不再向升级后的路由器展示", + "zh_Hant": "你分享的佈景主題中有 %d 個不再向升級後的路由器顯示" + }, + "%d of your shared themes need an update": { + "de": "%d deiner geteilten Themes brauchen ein Update", + "es": "%d de tus temas compartidos necesitan actualizarse", + "fr": "%d de vos thèmes partagés doivent être mis à jour", + "id": "%d tema yang Anda bagikan perlu diperbarui", + "it": "%d dei tuoi temi condivisi vanno aggiornati", + "ja": "共有テーマのうち %d 件は更新が必要です", + "ko": "공유한 테마 중 %d개는 업데이트가 필요합니다", + "nl": "%d van je gedeelde thema's moeten worden bijgewerkt", + "pl": "%d z Twoich udostępnionych motywów wymaga aktualizacji", + "ru": "%d из ваших опубликованных тем нужно обновить", + "tr": "Paylaştığınız temalardan %d tanesinin güncellenmesi gerekiyor", + "uk": "%d з ваших опублікованих тем потрібно оновити", + "zh_Hans": "你分享的主题中有 %d 个需要更新", + "zh_Hant": "你分享的佈景主題中有 %d 個需要更新" + }, + "1 needs an update": { + "de": "1 braucht ein Update", + "es": "1 necesita actualizarse", + "fr": "1 à mettre à jour", + "id": "1 perlu diperbarui", + "it": "1 da aggiornare", + "ja": "1 件が要更新", + "ko": "1개 업데이트 필요", + "nl": "1 moet worden bijgewerkt", + "pl": "1 do aktualizacji", + "ru": "1 нужно обновить", + "tr": "1 tanesi güncellenmeli", + "uk": "1 потрібно оновити", + "zh_Hans": "1 个需要更新", + "zh_Hant": "1 個需要更新" + }, + "1 needs an update before %s": { + "de": "1 braucht ein Update vor dem %s", + "es": "1 necesita actualizarse antes del %s", + "fr": "1 à mettre à jour avant le %s", + "id": "1 perlu diperbarui sebelum %s", + "it": "1 da aggiornare entro il %s", + "ja": "1 件が %s までに要更新", + "ko": "1개를 %s 전에 업데이트해야 함", + "nl": "1 moet vóór %s worden bijgewerkt", + "pl": "1 do aktualizacji przed %s", + "ru": "1 нужно обновить до %s", + "tr": "1 tanesi %s tarihinden önce güncellenmeli", + "uk": "1 потрібно оновити до %s", + "zh_Hans": "1 个需要在 %s 之前更新", + "zh_Hant": "1 個需要在 %s 之前更新" + }, + "1 not listed for updated routers": { + "de": "1 auf aktualisierten Routern nicht gelistet", + "es": "1 no aparece en los routers actualizados", + "fr": "1 non proposé aux routeurs mis à jour", + "id": "1 tidak ditampilkan untuk router yang sudah diperbarui", + "it": "1 non in elenco per i router aggiornati", + "ja": "1 件が更新済みルーターに非表示", + "ko": "1개가 업데이트된 라우터에 표시되지 않음", + "nl": "1 niet getoond op bijgewerkte routers", + "pl": "1 niewidoczny na zaktualizowanych routerach", + "ru": "1 не показывается обновлённым роутерам", + "tr": "1 tanesi güncellenmiş yönlendiricilerde listelenmiyor", + "uk": "1 не показується оновленим роутерам", + "zh_Hans": "1 个不再向升级后的路由器展示", + "zh_Hant": "1 個不再向升級後的路由器顯示" + }, + "1 of your shared themes is not listed for updated routers": { + "de": "1 deiner geteilten Themes wird auf aktualisierten Routern nicht gelistet", + "es": "1 de tus temas compartidos no aparece en los routers actualizados", + "fr": "1 de vos thèmes partagés n'est pas proposé aux routeurs mis à jour", + "id": "1 tema yang Anda bagikan tidak ditampilkan untuk router yang sudah diperbarui", + "it": "1 dei tuoi temi condivisi non è in elenco per i router aggiornati", + "ja": "共有テーマのうち 1 件が更新済みルーターに表示されていません", + "ko": "공유한 테마 중 1개가 업데이트된 라우터에 표시되지 않습니다", + "nl": "1 van je gedeelde thema's wordt niet getoond op bijgewerkte routers", + "pl": "1 z Twoich udostępnionych motywów nie widać na zaktualizowanych routerach", + "ru": "1 из ваших опубликованных тем не показывается обновлённым роутерам", + "tr": "Paylaştığınız temalardan 1 tanesi güncellenmiş yönlendiricilerde listelenmiyor", + "uk": "1 з ваших опублікованих тем не показується оновленим роутерам", + "zh_Hans": "你分享的主题中有 1 个不再向升级后的路由器展示", + "zh_Hant": "你分享的佈景主題中有 1 個不再向升級後的路由器顯示" + }, + "1 of your shared themes needs an update": { + "de": "1 deiner geteilten Themes braucht ein Update", + "es": "1 de tus temas compartidos necesita actualizarse", + "fr": "1 de vos thèmes partagés doit être mis à jour", + "id": "1 tema yang Anda bagikan perlu diperbarui", + "it": "1 dei tuoi temi condivisi va aggiornato", + "ja": "共有テーマのうち 1 件は更新が必要です", + "ko": "공유한 테마 중 1개는 업데이트가 필요합니다", + "nl": "1 van je gedeelde thema's moet worden bijgewerkt", + "pl": "1 z Twoich udostępnionych motywów wymaga aktualizacji", + "ru": "1 из ваших опубликованных тем нужно обновить", + "tr": "Paylaştığınız temalardan 1 tanesinin güncellenmesi gerekiyor", + "uk": "1 з ваших опублікованих тем потрібно оновити", + "zh_Hans": "你分享的主题中有 1 个需要更新", + "zh_Hant": "你分享的佈景主題中有 1 個需要更新" + }, + "Assets not approved: %s": { + "de": "Assets nicht freigegeben: %s", + "es": "Recursos no aprobados: %s", + "fr": "Ressources non approuvées : %s", + "id": "Aset tidak disetujui: %s", + "it": "Risorse non approvate: %s", + "ja": "素材が承認されませんでした:%s", + "ko": "에셋이 승인되지 않음: %s", + "nl": "Assets niet goedgekeurd: %s", + "pl": "Zasoby niezatwierdzone: %s", + "ru": "Ресурсы не одобрены: %s", + "tr": "Varlıklar onaylanmadı: %s", + "uk": "Ресурси не схвалено: %s", + "zh_Hans": "素材未通过审核:%s", + "zh_Hant": "素材未通過審核:%s" + }, + "Done": { + "de": "Erledigt", + "es": "Hecho", + "fr": "Terminé", + "id": "Selesai", + "it": "Fatto", + "ja": "完了", + "ko": "완료", + "nl": "Klaar", + "pl": "Gotowe", + "ru": "Готово", + "tr": "Bitti", + "uk": "Готово", + "zh_Hans": "完成", + "zh_Hant": "完成" + }, + "Inbox": { + "de": "Posteingang", + "es": "Bandeja de entrada", + "fr": "Boîte de réception", + "id": "Kotak masuk", + "it": "Posta in arrivo", + "ja": "受信トレイ", + "ko": "받은 알림", + "nl": "Inbox", + "pl": "Skrzynka", + "ru": "Входящие", + "tr": "Gelen kutusu", + "uk": "Вхідні", + "zh_Hans": "收件箱", + "zh_Hant": "收件匣" + }, + "Listed until %s. Publish it again from this router to keep it listed.": { + "de": "Gelistet bis %s. Veröffentliche es erneut von diesem Router, damit es gelistet bleibt.", + "es": "En la lista hasta el %s. Vuelve a publicarlo desde este router para que siga apareciendo.", + "fr": "Proposé jusqu'au %s. Republiez-le depuis ce routeur pour qu'il reste proposé.", + "id": "Ditampilkan hingga %s. Terbitkan lagi dari router ini agar tetap ditampilkan.", + "it": "In elenco fino al %s. Ripubblicalo da questo router perché resti in elenco.", + "ja": "%s まで掲載されます。掲載を続けるには、このルーターからもう一度公開してください。", + "ko": "%s까지 표시됩니다. 계속 표시하려면 이 라우터에서 다시 게시하세요.", + "nl": "Getoond tot %s. Publiceer het opnieuw vanaf deze router om het getoond te houden.", + "pl": "Widoczny do %s. Opublikuj go ponownie z tego routera, aby pozostał na liście.", + "ru": "В списке до %s. Опубликуйте её заново с этого роутера, чтобы она осталась в списке.", + "tr": "%s tarihine kadar listelenir. Listede kalması için bu yönlendiriciden yeniden yayımlayın.", + "uk": "У списку до %s. Опублікуйте її знову з цього роутера, щоб вона залишилася в списку.", + "zh_Hans": "展示到 %s 为止。从这台路由器重新发布一次,就能继续展示。", + "zh_Hant": "顯示到 %s 為止。從這台路由器重新發布一次,就能繼續顯示。" + }, + "Mark all read": { + "de": "Alle als gelesen markieren", + "es": "Marcar todo como leído", + "fr": "Tout marquer comme lu", + "id": "Tandai semua sudah dibaca", + "it": "Segna tutto come letto", + "ja": "すべて既読にする", + "ko": "모두 읽음으로 표시", + "nl": "Alles als gelezen markeren", + "pl": "Oznacz wszystko jako przeczytane", + "ru": "Отметить всё прочитанным", + "tr": "Tümünü okundu işaretle", + "uk": "Позначити все прочитаним", + "zh_Hans": "全部标为已读", + "zh_Hant": "全部標為已讀" + }, + "Mark unread": { + "de": "Als ungelesen markieren", + "es": "Marcar como no leído", + "fr": "Marquer comme non lu", + "id": "Tandai belum dibaca", + "it": "Segna come non letto", + "ja": "未読にする", + "ko": "읽지 않음으로 표시", + "nl": "Als ongelezen markeren", + "pl": "Oznacz jako nieprzeczytane", + "ru": "Отметить непрочитанным", + "tr": "Okunmadı işaretle", + "uk": "Позначити непрочитаним", + "zh_Hans": "标为未读", + "zh_Hant": "標為未讀" + }, + "Marketplace": { + "de": "Marktplatz", + "es": "Mercado", + "fr": "Marketplace", + "id": "Marketplace", + "it": "Marketplace", + "ja": "マーケットプレイス", + "ko": "마켓플레이스", + "nl": "Marktplaats", + "pl": "Marketplace", + "ru": "Маркетплейс", + "tr": "Pazaryeri", + "uk": "Маркетплейс", + "zh_Hans": "主题市场", + "zh_Hant": "主題市場" + }, + "Needs action": { + "de": "Zu erledigen", + "es": "Requiere acción", + "fr": "À traiter", + "id": "Perlu tindakan", + "it": "Da gestire", + "ja": "要対応", + "ko": "조치 필요", + "nl": "Actie nodig", + "pl": "Wymaga działania", + "ru": "Требует действий", + "tr": "İşlem bekleyenler", + "uk": "Потребує дій", + "zh_Hans": "待处理", + "zh_Hant": "待處理" + }, + "Not listed for updated routers": { + "de": "Auf aktualisierten Routern nicht gelistet", + "es": "No aparece en routers actualizados", + "fr": "Non proposé aux routeurs mis à jour", + "id": "Tidak ditampilkan untuk router yang diperbarui", + "it": "Non in elenco per i router aggiornati", + "ja": "更新済みルーターに非表示", + "ko": "업데이트된 라우터에 표시 안 됨", + "nl": "Niet getoond op bijgewerkte routers", + "pl": "Niewidoczny na zaktualizowanych routerach", + "ru": "Не показывается обновлённым роутерам", + "tr": "Güncellenmiş yönlendiricilerde listelenmiyor", + "uk": "Не показується оновленим роутерам", + "zh_Hans": "不再向升级后的路由器展示", + "zh_Hant": "不再向升級後的路由器顯示" + }, + "Open %s": { + "de": "%s öffnen", + "es": "Abrir %s", + "fr": "Ouvrir %s", + "id": "Buka %s", + "it": "Apri %s", + "ja": "%s を開く", + "ko": "%s 열기", + "nl": "%s openen", + "pl": "Otwórz %s", + "ru": "Открыть %s", + "tr": "%s öğesini aç", + "uk": "Відкрити %s", + "zh_Hans": "打开 %s", + "zh_Hant": "開啟 %s" + }, + "Publish it again from this router to keep it listed.": { + "de": "Veröffentliche es erneut von diesem Router, damit es gelistet bleibt.", + "es": "Vuelve a publicarlo desde este router para que siga apareciendo.", + "fr": "Republiez-le depuis ce routeur pour qu'il reste proposé.", + "id": "Terbitkan lagi dari router ini agar tetap ditampilkan.", + "it": "Ripubblicalo da questo router perché resti in elenco.", + "ja": "掲載を続けるには、このルーターからもう一度公開してください。", + "ko": "계속 표시하려면 이 라우터에서 다시 게시하세요.", + "nl": "Publiceer het opnieuw vanaf deze router om het getoond te houden.", + "pl": "Opublikuj go ponownie z tego routera, aby pozostał na liście.", + "ru": "Опубликуйте её заново с этого роутера, чтобы она осталась в списке.", + "tr": "Listede kalması için bu yönlendiriciden yeniden yayımlayın.", + "uk": "Опублікуйте її знову з цього роутера, щоб вона залишилася в списку.", + "zh_Hans": "从这台路由器重新发布一次,就能继续展示。", + "zh_Hant": "從這台路由器重新發布一次,就能繼續顯示。" + }, + "Published with an older configuration format: %s.": { + "de": "Mit einem älteren Konfigurationsformat veröffentlicht: %s.", + "es": "Publicados con un formato de configuración anterior: %s.", + "fr": "Publiés avec un ancien format de configuration : %s.", + "id": "Diterbitkan dengan format konfigurasi lama: %s.", + "it": "Pubblicati con un formato di configurazione precedente: %s.", + "ja": "旧形式の設定で公開されています:%s。", + "ko": "이전 구성 형식으로 게시됨: %s.", + "nl": "Gepubliceerd met een ouder configuratieformaat: %s.", + "pl": "Opublikowane w starszym formacie konfiguracji: %s.", + "ru": "Опубликованы в прежнем формате конфигурации: %s.", + "tr": "Eski bir yapılandırma biçimiyle yayımlandı: %s.", + "uk": "Опубліковані в попередньому форматі конфігурації: %s.", + "zh_Hans": "这些是用旧的配置格式发布的:%s。", + "zh_Hant": "這些是用舊的設定格式發布的:%s。" + }, + "Review my shares": { + "de": "Meine Freigaben ansehen", + "es": "Revisar lo que he compartido", + "fr": "Voir mes partages", + "id": "Tinjau yang saya bagikan", + "it": "Rivedi le mie condivisioni", + "ja": "自分の共有を確認", + "ko": "내 공유 확인", + "nl": "Mijn deelacties bekijken", + "pl": "Przejrzyj moje udostępnienia", + "ru": "Посмотреть мои публикации", + "tr": "Paylaşımlarımı gözden geçir", + "uk": "Переглянути мої публікації", + "zh_Hans": "查看我的分享", + "zh_Hant": "查看我的分享" + }, + "Reviewer’s note: %s": { + "de": "Anmerkung der Prüfung: %s", + "es": "Nota de la revisión: %s", + "fr": "Note de la modération : %s", + "id": "Catatan peninjau: %s", + "it": "Nota della revisione: %s", + "ja": "審査担当者のコメント:%s", + "ko": "검토자 메모: %s", + "nl": "Opmerking van de beoordelaar: %s", + "pl": "Uwaga recenzenta: %s", + "ru": "Комментарий проверяющего: %s", + "tr": "İnceleyenin notu: %s", + "uk": "Коментар перевіряльника: %s", + "zh_Hans": "审核员的说明:%s", + "zh_Hant": "審核員的說明:%s" + }, + "Select a notification": { + "de": "Benachrichtigung auswählen", + "es": "Selecciona una notificación", + "fr": "Sélectionnez une notification", + "id": "Pilih notifikasi", + "it": "Seleziona una notifica", + "ja": "通知を選択してください", + "ko": "알림을 선택하세요", + "nl": "Selecteer een melding", + "pl": "Wybierz powiadomienie", + "ru": "Выберите уведомление", + "tr": "Bir bildirim seçin", + "uk": "Виберіть сповіщення", + "zh_Hans": "选择一条通知", + "zh_Hant": "選擇一則通知" + }, + "Taken down: %s": { + "de": "Entfernt: %s", + "es": "Retirado: %s", + "fr": "Retiré : %s", + "id": "Diturunkan: %s", + "it": "Rimosso: %s", + "ja": "公開停止:%s", + "ko": "게시 중단됨: %s", + "nl": "Verwijderd: %s", + "pl": "Usunięto: %s", + "ru": "Снято с публикации: %s", + "tr": "Kaldırıldı: %s", + "uk": "Знято з публікації: %s", + "zh_Hans": "已被下架:%s", + "zh_Hant": "已被下架:%s" + }, + "The colours and layout of this theme are live. Only the assets are held back.": { + "de": "Farben und Layout dieses Themes sind online. Nur die Assets werden zurückgehalten.", + "es": "Los colores y el diseño de este tema están publicados. Solo se retienen los recursos.", + "fr": "Les couleurs et la mise en page de ce thème sont en ligne. Seules les ressources sont retenues.", + "id": "Warna dan tata letak tema ini sudah tayang. Hanya asetnya yang ditahan.", + "it": "Colori e layout di questo tema sono online. Solo le risorse sono trattenute.", + "ja": "このテーマの配色とレイアウトは公開されています。保留されているのは素材だけです。", + "ko": "이 테마의 색상과 레이아웃은 공개되어 있습니다. 보류된 것은 에셋뿐입니다.", + "nl": "De kleuren en lay-out van dit thema staan online. Alleen de assets worden achtergehouden.", + "pl": "Kolory i układ tego motywu są opublikowane. Wstrzymane są tylko zasoby.", + "ru": "Цвета и макет этой темы опубликованы. Задержаны только ресурсы.", + "tr": "Bu temanın renkleri ve düzeni yayında. Yalnızca varlıklar bekletiliyor.", + "uk": "Кольори й макет цієї теми опубліковано. Затримано лише ресурси.", + "zh_Hans": "这个主题的配色和布局已经上线,只有素材被扣下了。", + "zh_Hant": "這個佈景主題的配色和版面已經上線,只有素材被扣下了。" + }, + "They stay listed for routers that have not upgraded. From %s, updated routers stop seeing them until you publish them again from this router — the link, the downloads and the name are kept.": { + "de": "Für Router ohne Upgrade bleiben sie gelistet. Ab dem %s sehen aktualisierte Router sie nicht mehr, bis du sie von diesem Router erneut veröffentlichst — Link, Downloads und Name bleiben erhalten.", + "es": "Siguen apareciendo en los routers que no se han actualizado. A partir del %s, los routers actualizados dejarán de verlos hasta que los publiques de nuevo desde este router: el enlace, las descargas y el nombre se conservan.", + "fr": "Ils restent proposés aux routeurs qui n'ont pas été mis à niveau. À partir du %s, les routeurs mis à jour ne les verront plus tant que vous ne les aurez pas republiés depuis ce routeur — le lien, les téléchargements et le nom sont conservés.", + "id": "Tema tersebut tetap ditampilkan untuk router yang belum ditingkatkan. Mulai %s, router yang sudah diperbarui tidak lagi melihatnya sampai Anda menerbitkannya lagi dari router ini — tautan, jumlah unduhan, dan namanya tetap.", + "it": "Restano in elenco per i router non aggiornati. Dal %s i router aggiornati non li vedranno più finché non li ripubblichi da questo router — link, download e nome restano.", + "ja": "アップグレードしていないルーターには引き続き表示されます。%s 以降、更新済みのルーターには、このルーターからもう一度公開するまで表示されません — リンク、ダウンロード数、名前はそのまま残ります。", + "ko": "업그레이드하지 않은 라우터에는 계속 표시됩니다. %s부터 업데이트된 라우터에서는 이 라우터에서 다시 게시할 때까지 보이지 않습니다 — 링크, 다운로드 수, 이름은 그대로 유지됩니다.", + "nl": "Ze blijven getoond op routers die niet zijn geüpgraded. Vanaf %s zien bijgewerkte routers ze niet meer totdat je ze opnieuw publiceert vanaf deze router — de link, de downloads en de naam blijven behouden.", + "pl": "Pozostają widoczne na routerach, które nie zostały zaktualizowane. Od %s zaktualizowane routery przestaną je widzieć, dopóki nie opublikujesz ich ponownie z tego routera — link, liczba pobrań i nazwa zostają zachowane.", + "ru": "Они остаются в списке для роутеров, которые не обновились. С %s обновлённые роутеры перестанут их видеть, пока вы не опубликуете их заново с этого роутера — ссылка, число загрузок и название сохранятся.", + "tr": "Yükseltilmemiş yönlendiricilerde listelenmeye devam ederler. %s tarihinden itibaren güncellenmiş yönlendiriciler, siz bu yönlendiriciden yeniden yayımlayana kadar onları görmez — bağlantı, indirme sayısı ve ad korunur.", + "uk": "Вони залишаються в списку для роутерів, які не оновилися. Від %s оновлені роутери перестануть їх бачити, доки ви не опублікуєте їх знову з цього роутера — посилання, кількість завантажень і назва збережуться.", + "zh_Hans": "没有升级的路由器仍然能看到它们。从 %s 起,升级后的路由器将看不到它们,直到你从这台路由器重新发布 —— 链接、下载数和名字都会保留。", + "zh_Hant": "沒有升級的路由器仍然能看到它們。從 %s 起,升級後的路由器將看不到它們,直到你從這台路由器重新發布 —— 連結、下載數和名稱都會保留。" + }, + "They stay listed for routers that have not upgraded. Later, updated routers stop seeing them until you publish them again from this router — the link, the downloads and the name are kept.": { + "de": "Für Router ohne Upgrade bleiben sie gelistet. Später sehen aktualisierte Router sie nicht mehr, bis du sie von diesem Router erneut veröffentlichst — Link, Downloads und Name bleiben erhalten.", + "es": "Siguen apareciendo en los routers que no se han actualizado. Más adelante, los routers actualizados dejarán de verlos hasta que los publiques de nuevo desde este router: el enlace, las descargas y el nombre se conservan.", + "fr": "Ils restent proposés aux routeurs qui n'ont pas été mis à niveau. Par la suite, les routeurs mis à jour ne les verront plus tant que vous ne les aurez pas republiés depuis ce routeur — le lien, les téléchargements et le nom sont conservés.", + "id": "Tema tersebut tetap ditampilkan untuk router yang belum ditingkatkan. Nantinya, router yang sudah diperbarui tidak lagi melihatnya sampai Anda menerbitkannya lagi dari router ini — tautan, jumlah unduhan, dan namanya tetap.", + "it": "Restano in elenco per i router non aggiornati. In seguito i router aggiornati non li vedranno più finché non li ripubblichi da questo router — link, download e nome restano.", + "ja": "アップグレードしていないルーターには引き続き表示されます。その後、更新済みのルーターには、このルーターからもう一度公開するまで表示されなくなります — リンク、ダウンロード数、名前はそのまま残ります。", + "ko": "업그레이드하지 않은 라우터에는 계속 표시됩니다. 이후 업데이트된 라우터에서는 이 라우터에서 다시 게시할 때까지 보이지 않게 됩니다 — 링크, 다운로드 수, 이름은 그대로 유지됩니다.", + "nl": "Ze blijven getoond op routers die niet zijn geüpgraded. Later zien bijgewerkte routers ze niet meer totdat je ze opnieuw publiceert vanaf deze router — de link, de downloads en de naam blijven behouden.", + "pl": "Pozostają widoczne na routerach, które nie zostały zaktualizowane. Później zaktualizowane routery przestaną je widzieć, dopóki nie opublikujesz ich ponownie z tego routera — link, liczba pobrań i nazwa zostają zachowane.", + "ru": "Они остаются в списке для роутеров, которые не обновились. Позже обновлённые роутеры перестанут их видеть, пока вы не опубликуете их заново с этого роутера — ссылка, число загрузок и название сохранятся.", + "tr": "Yükseltilmemiş yönlendiricilerde listelenmeye devam ederler. Daha sonra güncellenmiş yönlendiriciler, siz bu yönlendiriciden yeniden yayımlayana kadar onları görmez — bağlantı, indirme sayısı ve ad korunur.", + "uk": "Вони залишаються в списку для роутерів, які не оновилися. Згодом оновлені роутери перестануть їх бачити, доки ви не опублікуєте їх знову з цього роутера — посилання, кількість завантажень і назва збережуться.", + "zh_Hans": "没有升级的路由器仍然能看到它们。之后,升级后的路由器将看不到它们,直到你从这台路由器重新发布 —— 链接、下载数和名字都会保留。", + "zh_Hant": "沒有升級的路由器仍然能看到它們。之後,升級後的路由器將看不到它們,直到你從這台路由器重新發布 —— 連結、下載數和名稱都會保留。" + }, + "Unread": { + "de": "Ungelesen", + "es": "No leídos", + "fr": "Non lus", + "id": "Belum dibaca", + "it": "Non letti", + "ja": "未読", + "ko": "읽지 않음", + "nl": "Ongelezen", + "pl": "Nieprzeczytane", + "ru": "Непрочитанные", + "tr": "Okunmamış", + "uk": "Непрочитані", + "zh_Hans": "未读", + "zh_Hant": "未讀" + }, + "Updated routers no longer see them. Update each one from this router to relist it — the link, the downloads and the name are kept.": { + "de": "Aktualisierte Router sehen sie nicht mehr. Aktualisiere jedes von diesem Router, um es wieder zu listen — Link, Downloads und Name bleiben erhalten.", + "es": "Los routers actualizados ya no los ven. Actualiza cada uno desde este router para que vuelva a aparecer: el enlace, las descargas y el nombre se conservan.", + "fr": "Les routeurs mis à jour ne les voient plus. Mettez chacun à jour depuis ce routeur pour le proposer à nouveau — le lien, les téléchargements et le nom sont conservés.", + "id": "Router yang sudah diperbarui tidak lagi melihatnya. Perbarui masing-masing dari router ini agar tampil kembali — tautan, jumlah unduhan, dan namanya tetap.", + "it": "I router aggiornati non li vedono più. Aggiorna ciascuno da questo router per rimetterlo in elenco — link, download e nome restano.", + "ja": "更新済みのルーターには表示されなくなりました。このルーターからそれぞれ更新すると再掲載されます — リンク、ダウンロード数、名前はそのまま残ります。", + "ko": "업데이트된 라우터에서는 더 이상 보이지 않습니다. 이 라우터에서 각각 업데이트하면 다시 표시됩니다 — 링크, 다운로드 수, 이름은 그대로 유지됩니다.", + "nl": "Bijgewerkte routers zien ze niet meer. Werk elk thema bij vanaf deze router om het weer te tonen — de link, de downloads en de naam blijven behouden.", + "pl": "Zaktualizowane routery już ich nie widzą. Zaktualizuj każdy z tego routera, aby wrócił na listę — link, liczba pobrań i nazwa zostają zachowane.", + "ru": "Обновлённые роутеры их больше не видят. Обновите каждую с этого роутера, чтобы вернуть её в список — ссылка, число загрузок и название сохранятся.", + "tr": "Güncellenmiş yönlendiriciler artık onları görmüyor. Yeniden listelenmesi için her birini bu yönlendiriciden güncelleyin — bağlantı, indirme sayısı ve ad korunur.", + "uk": "Оновлені роутери їх більше не бачать. Оновіть кожну з цього роутера, щоб повернути її до списку — посилання, кількість завантажень і назва збережуться.", + "zh_Hans": "升级后的路由器已经看不到它们了。从这台路由器逐个更新即可重新上架 —— 链接、下载数和名字都会保留。", + "zh_Hant": "升級後的路由器已經看不到它們了。從這台路由器逐個更新即可重新上架 —— 連結、下載數和名稱都會保留。" + }, + "Updated routers no longer see this theme. Update it to relist — link and downloads are kept.": { + "de": "Aktualisierte Router sehen dieses Theme nicht mehr. Aktualisiere es, um es wieder zu listen — Link und Downloads bleiben erhalten.", + "es": "Los routers actualizados ya no ven este tema. Actualízalo para que vuelva a aparecer: el enlace y las descargas se conservan.", + "fr": "Les routeurs mis à jour ne voient plus ce thème. Mettez-le à jour pour le proposer à nouveau — le lien et les téléchargements sont conservés.", + "id": "Router yang sudah diperbarui tidak lagi melihat tema ini. Perbarui agar tampil kembali — tautan dan jumlah unduhan tetap.", + "it": "I router aggiornati non vedono più questo tema. Aggiornalo per rimetterlo in elenco — link e download restano.", + "ja": "更新済みのルーターにはこのテーマが表示されなくなりました。更新すると再掲載されます — リンクとダウンロード数はそのまま残ります。", + "ko": "업데이트된 라우터에서는 이 테마가 더 이상 보이지 않습니다. 업데이트하면 다시 표시됩니다 — 링크와 다운로드 수는 그대로 유지됩니다.", + "nl": "Bijgewerkte routers zien dit thema niet meer. Werk het bij om het weer te tonen — de link en de downloads blijven behouden.", + "pl": "Zaktualizowane routery nie widzą już tego motywu. Zaktualizuj go, aby wrócił na listę — link i liczba pobrań zostają zachowane.", + "ru": "Обновлённые роутеры больше не видят эту тему. Обновите её, чтобы вернуть в список — ссылка и число загрузок сохранятся.", + "tr": "Güncellenmiş yönlendiriciler artık bu temayı görmüyor. Yeniden listelenmesi için güncelleyin — bağlantı ve indirme sayısı korunur.", + "uk": "Оновлені роутери більше не бачать цю тему. Оновіть її, щоб повернути до списку — посилання й кількість завантажень збережуться.", + "zh_Hans": "升级后的路由器已经看不到这个主题了。更新后即可重新上架 —— 链接和下载数都会保留。", + "zh_Hant": "升級後的路由器已經看不到這個佈景主題了。更新後即可重新上架 —— 連結和下載數都會保留。" + }, + "Updates": { + "de": "Neuigkeiten", + "es": "Novedades", + "fr": "Nouveautés", + "id": "Kabar terbaru", + "it": "Novità", + "ja": "新着情報", + "ko": "새 소식", + "nl": "Nieuws", + "pl": "Nowości", + "ru": "Новости", + "tr": "Haberler", + "uk": "Новини", + "zh_Hans": "动态", + "zh_Hant": "動態" + }, + "Your shares": { + "de": "Deine Freigaben", + "es": "Lo que has compartido", + "fr": "Vos partages", + "id": "Yang Anda bagikan", + "it": "Le tue condivisioni", + "ja": "自分の共有", + "ko": "내 공유", + "nl": "Je deelacties", + "pl": "Twoje udostępnienia", + "ru": "Ваши публикации", + "tr": "Paylaşımlarınız", + "uk": "Ваші публікації", + "zh_Hans": "你的分享", + "zh_Hant": "你的分享" + }, + "You’re all caught up.": { + "de": "Du bist auf dem neuesten Stand.", + "es": "Estás al día.", + "fr": "Vous êtes à jour.", + "id": "Semua sudah dibaca.", + "it": "Sei in pari con tutto.", + "ja": "すべて確認済みです。", + "ko": "모두 확인했습니다.", + "nl": "Je bent helemaal bij.", + "pl": "Wszystko przeczytane.", + "ru": "Всё просмотрено.", + "tr": "Hepsini gördünüz.", + "uk": "Усе переглянуто.", + "zh_Hans": "都处理完了。", + "zh_Hant": "都處理完了。" + }, + "This router": { + "de": "Dieser Router", + "es": "Este router", + "fr": "Ce routeur", + "id": "Router ini", + "it": "Questo router", + "ja": "このルーター", + "ko": "이 라우터", + "nl": "Deze router", + "pl": "Ten router", + "ru": "Этот роутер", + "tr": "Bu yönlendirici", + "uk": "Цей роутер", + "zh_Hans": "本机", + "zh_Hant": "本機" + }, + "Installed now: theme %s, configuration app %s.": { + "de": "Derzeit installiert: Theme %s, Konfigurations-App %s.", + "es": "Instalado ahora: tema %s, aplicación de configuración %s.", + "fr": "Installé actuellement : thème %s, application de configuration %s.", + "id": "Terpasang saat ini: tema %s, aplikasi konfigurasi %s.", + "it": "Installati ora: tema %s, app di configurazione %s.", + "ja": "現在のインストール:テーマ %s、設定アプリ %s。", + "ko": "현재 설치됨: 테마 %s, 구성 앱 %s.", + "nl": "Nu geïnstalleerd: thema %s, configuratie-app %s.", + "pl": "Obecnie zainstalowane: motyw %s, aplikacja konfiguracyjna %s.", + "ru": "Сейчас установлено: тема %s, приложение настройки %s.", + "tr": "Şu an yüklü: tema %s, yapılandırma uygulaması %s.", + "uk": "Зараз встановлено: тема %s, застосунок налаштування %s.", + "zh_Hans": "当前已安装:主题 %s,配置应用 %s。", + "zh_Hant": "目前已安裝:佈景主題 %s,設定應用 %s。" + }, + "Theme": { + "de": "Theme", + "es": "Tema", + "fr": "Thème", + "id": "Tema", + "it": "Tema", + "ja": "テーマ", + "ko": "테마", + "nl": "Thema", + "pl": "Motyw", + "ru": "Тема", + "tr": "Tema", + "uk": "Тема", + "zh_Hans": "主题", + "zh_Hant": "佈景主題" + }, + "Config": { + "de": "Konfiguration", + "es": "Configuración", + "fr": "Configuration", + "id": "Konfigurasi", + "it": "Configurazione", + "ja": "設定", + "ko": "구성", + "nl": "Configuratie", + "pl": "Konfiguracja", + "ru": "Конфигурация", + "tr": "Yapılandırma", + "uk": "Конфігурація", + "zh_Hans": "配置", + "zh_Hant": "設定" + }, + "More": { + "de": "Mehr", + "es": "Más", + "fr": "Plus", + "id": "Lainnya", + "it": "Altro", + "ja": "その他", + "ko": "더 보기", + "nl": "Meer", + "pl": "Więcej", + "ru": "Ещё", + "tr": "Daha fazla", + "uk": "Ще", + "zh_Hans": "更多", + "zh_Hant": "更多" + }, + "%s available": { + "de": "%s verfügbar", + "es": "%s disponible", + "fr": "%s disponible", + "id": "%s tersedia", + "it": "%s disponibile", + "ja": "%s が利用可能", + "ko": "%s 사용 가능", + "nl": "%s beschikbaar", + "pl": "Dostępna %s", + "ru": "Доступна %s", + "tr": "%s mevcut", + "uk": "Доступна %s", + "zh_Hans": "%s 可更新", + "zh_Hant": "%s 可更新" + }, + "Export configuration": { + "de": "Konfiguration exportieren", + "es": "Exportar configuración", + "fr": "Exporter la configuration", + "id": "Ekspor konfigurasi", + "it": "Esporta configurazione", + "ja": "設定をエクスポート", + "ko": "구성 내보내기", + "nl": "Configuratie exporteren", + "pl": "Eksportuj konfigurację", + "ru": "Экспортировать конфигурацию", + "tr": "Yapılandırmayı dışa aktar", + "uk": "Експортувати конфігурацію", + "zh_Hans": "导出配置", + "zh_Hant": "匯出設定" + }, + "Import configuration": { + "de": "Konfiguration importieren", + "es": "Importar configuración", + "fr": "Importer la configuration", + "id": "Impor konfigurasi", + "it": "Importa configurazione", + "ja": "設定をインポート", + "ko": "구성 가져오기", + "nl": "Configuratie importeren", + "pl": "Importuj konfigurację", + "ru": "Импортировать конфигурацию", + "tr": "Yapılandırmayı içe aktar", + "uk": "Імпортувати конфігурацію", + "zh_Hans": "导入配置", + "zh_Hant": "匯入設定" + }, + "Reset everything to defaults": { + "de": "Alles auf Standard zurücksetzen", + "es": "Restablecer todo a los valores predeterminados", + "fr": "Tout réinitialiser par défaut", + "id": "Setel ulang semua ke bawaan", + "it": "Ripristina tutto ai valori predefiniti", + "ja": "すべて初期設定に戻す", + "ko": "모두 기본값으로 재설정", + "nl": "Alles terugzetten naar standaard", + "pl": "Przywróć wszystko do domyślnych", + "ru": "Сбросить всё к значениям по умолчанию", + "tr": "Tümünü varsayılana sıfırla", + "uk": "Скинути все до типових", + "zh_Hans": "重置为默认", + "zh_Hant": "重設為預設" + } +} diff --git a/luci-app-aurora-config/.dev/src/resource/aurora/presets.json b/luci-app-aurora-config/.dev/src/resource/aurora/presets.json new file mode 100644 index 00000000..d7d851bb --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/aurora/presets.json @@ -0,0 +1,259 @@ +{ + "presets": { + "default": { + "colors": { + "light_bg": "#f7fafc", + "light_surface": "#fff", + "light_text": "#121a22", + "light_brand": "#0086bf", + "dark_bg": "#05070e", + "dark_surface": "#141822", + "dark_text": "#f9fafb", + "dark_brand": "#00978f" + }, + "layout": { + "nav_type": "mega-menu", + "struct_spacing": "0.25rem", + "struct_radius_base": "0.5rem", + "struct_content_width_centered": "80rem", + "toolbar_enabled": "1" + }, + "typography": { + "struct_font_sans": "\"Lato\", ui-sans-serif, system-ui, sans-serif", + "struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace", + "font_sans": "default", + "font_mono": "default" + }, + "toolbar": [ + { + "title": "Overview", + "url": "/cgi-bin/luci/admin/status/overview", + "icon": "overview.svg", + "enabled": "1" + }, + { + "title": "System", + "url": "/cgi-bin/luci/admin/system/system", + "icon": "system.svg", + "enabled": "1" + }, + { + "title": "Software", + "url": "/cgi-bin/luci/admin/system/package-manager", + "icon": "software.svg", + "enabled": "0" + }, + { + "title": "Network", + "url": "/cgi-bin/luci/admin/network/network", + "icon": "network.svg", + "enabled": "1" + } + ] + }, + "sage-green": { + "colors": { + "light_bg": "#f8f7f4", + "light_surface": "#fff", + "light_text": "#1a1f2e", + "light_brand": "#7c9082", + "dark_bg": "#0a0a0a", + "dark_surface": "#121212", + "dark_text": "#f5f5f5", + "dark_brand": "#7c9082" + }, + "layout": { + "nav_type": "mega-menu", + "struct_spacing": "0.3rem", + "struct_radius_base": "0.875rem", + "struct_content_width_centered": "92rem", + "toolbar_enabled": "1" + }, + "typography": { + "struct_font_sans": "\"Nunito\", \"Lato\", ui-sans-serif, system-ui, sans-serif", + "struct_font_mono": "\"Maple Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace", + "font_sans": "nunito", + "font_mono": "maple-mono" + }, + "toolbar": [ + { + "title": "Overview", + "url": "/cgi-bin/luci/admin/status/overview", + "icon": "overview.svg", + "enabled": "1" + }, + { + "title": "System", + "url": "/cgi-bin/luci/admin/system/system", + "icon": "system.svg", + "enabled": "1" + }, + { + "title": "Software", + "url": "/cgi-bin/luci/admin/system/package-manager", + "icon": "software.svg", + "enabled": "0" + }, + { + "title": "Network", + "url": "/cgi-bin/luci/admin/network/network", + "icon": "network.svg", + "enabled": "1" + } + ] + }, + "amber-sand": { + "colors": { + "light_bg": "#faf9f5", + "light_surface": "#faf9f5", + "light_text": "#3d3929", + "light_brand": "#c96442", + "dark_bg": "#262624", + "dark_surface": "#30302e", + "dark_text": "#c3c0b6", + "dark_brand": "#d97757" + }, + "layout": { + "nav_type": "sidebar", + "struct_spacing": "0.25rem", + "struct_radius_base": "0.375rem", + "struct_content_width_centered": "96rem", + "toolbar_enabled": "0" + }, + "typography": { + "struct_font_sans": "\"Space Grotesk\", \"Lato\", ui-sans-serif, system-ui, sans-serif", + "struct_font_mono": "\"Fira Code\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace", + "font_sans": "space-grotesk", + "font_mono": "fira-code" + }, + "toolbar": [ + { + "title": "Overview", + "url": "/cgi-bin/luci/admin/status/overview", + "icon": "overview.svg", + "enabled": "1" + }, + { + "title": "System", + "url": "/cgi-bin/luci/admin/system/system", + "icon": "system.svg", + "enabled": "1" + }, + { + "title": "Software", + "url": "/cgi-bin/luci/admin/system/package-manager", + "icon": "software.svg", + "enabled": "0" + }, + { + "title": "Network", + "url": "/cgi-bin/luci/admin/network/network", + "icon": "network.svg", + "enabled": "1" + } + ] + }, + "monochrome": { + "colors": { + "light_bg": "#fcfcfc", + "light_surface": "#fff", + "light_text": "#000", + "light_brand": "#000", + "dark_bg": "#000", + "dark_surface": "#090909", + "dark_text": "#fff", + "dark_brand": "#fff" + }, + "layout": { + "nav_type": "dropdown", + "struct_spacing": "0.2rem", + "struct_radius_base": "0rem", + "struct_content_width_centered": "88rem", + "toolbar_enabled": "0" + }, + "typography": { + "struct_font_sans": "\"Geist Sans\", \"Lato\", ui-sans-serif, system-ui, sans-serif", + "struct_font_mono": "\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace", + "font_sans": "geist-sans", + "font_mono": "jetbrains-mono" + }, + "toolbar": [ + { + "title": "Overview", + "url": "/cgi-bin/luci/admin/status/overview", + "icon": "overview.svg", + "enabled": "1" + }, + { + "title": "System", + "url": "/cgi-bin/luci/admin/system/system", + "icon": "system.svg", + "enabled": "1" + }, + { + "title": "Software", + "url": "/cgi-bin/luci/admin/system/package-manager", + "icon": "software.svg", + "enabled": "0" + }, + { + "title": "Network", + "url": "/cgi-bin/luci/admin/network/network", + "icon": "network.svg", + "enabled": "1" + } + ] + }, + "sky-blue": { + "colors": { + "light_bg": "#fff", + "light_surface": "#f7f8f8", + "light_text": "#0f1419", + "light_brand": "#1e9df1", + "dark_bg": "#000", + "dark_surface": "#17181c", + "dark_text": "#e7e9ea", + "dark_brand": "#1c9cf0" + }, + "layout": { + "nav_type": "sidebar", + "struct_spacing": "0.25rem", + "struct_radius_base": "0.75rem", + "struct_content_width_centered": "80rem", + "toolbar_enabled": "1" + }, + "typography": { + "struct_font_sans": "system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif", + "struct_font_mono": "ui-monospace, \"SF Mono\", Menlo, Monaco, Consolas, monospace", + "font_sans": "system", + "font_mono": "default" + }, + "toolbar": [ + { + "title": "Overview", + "url": "/cgi-bin/luci/admin/status/overview", + "icon": "overview.svg", + "enabled": "1" + }, + { + "title": "System", + "url": "/cgi-bin/luci/admin/system/system", + "icon": "system.svg", + "enabled": "1" + }, + { + "title": "Software", + "url": "/cgi-bin/luci/admin/system/package-manager", + "icon": "software.svg", + "enabled": "0" + }, + { + "title": "Network", + "url": "/cgi-bin/luci/admin/network/network", + "icon": "network.svg", + "enabled": "1" + } + ] + } + } +} diff --git a/luci-app-aurora-config/.dev/src/resource/preload/aurora-notices.js b/luci-app-aurora-config/.dev/src/resource/preload/aurora-notices.js new file mode 100644 index 00000000..1d755f1f --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/preload/aurora-notices.js @@ -0,0 +1,85 @@ +"use strict"; +"require baseclass"; + +const INDICATOR_ID = "aurora-inbox"; +const STYLE_ID = "aurora-inbox-indicator"; +const INBOX_PATH = "admin/system/aurora/marketplace"; +const INBOX_HASH = "#inbox"; + +const ICON_CLASS = "aurora-inbox-icon"; +const INDICATOR = '#indicators span[data-indicator="' + INDICATOR_ID + '"].' + ICON_CLASS; + +// Only for themes that hide indicator text and paint ::before by indicator +// name (Aurora, shadcn): there an unknown name is a solid square. Themes that +// show the text keep LuCI's plain "Inbox 3". +const INDICATOR_CSS = + INDICATOR + + "{position:relative;order:1;--aurora-inbox-icon:url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M22 12h-6l-2 3h-4l-2-3H2'/%3e%3cpath d='M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z'/%3e%3c/svg%3e\")}" + + INDICATOR + + "::before{-webkit-mask:var(--aurora-inbox-icon) center/cover no-repeat;mask:var(--aurora-inbox-icon) center/cover no-repeat}" + + INDICATOR + + "[data-count]::after{content:attr(data-count);position:absolute;top:-2px;right:-2px;z-index:10;" + + "display:flex;align-items:center;justify-content:center;min-width:12px;min-height:12px;" + + "padding:0 2px;border-radius:99px;background:var(--danger);color:var(--on-brand);" + + "font-size:8px;font-weight:700;line-height:1;box-shadow:0 1px 2px rgba(0,0,0,.1)}"; + +const openInbox = () => { + window.location.href = L.url(INBOX_PATH) + INBOX_HASH; +}; + +const update = () => + Promise.all([L.require("utils.notices"), L.require("utils.hub-api"), L.require("ui")]).then( + ([notices, hubApi, ui]) => { + const count = notices.badgeCount( + hubApi.noticesCache.getStale(), + hubApi.meCache.getStale(), + hubApi.inboxState(), + ); + if (!count) return ui.hideIndicator(INDICATOR_ID); + + if (!document.getElementById(STYLE_ID)) { + const style = E("style", { id: STYLE_ID }); + style.textContent = INDICATOR_CSS; + document.head.appendChild(style); + } + ui.showIndicator(INDICATOR_ID, _("Inbox") + " " + count, openInbox, "active"); + const node = document.querySelector('span[data-indicator="' + INDICATOR_ID + '"]'); + if (node) { + node.setAttribute("data-count", count); + node.title = node.textContent; + if (window.getComputedStyle(node).fontSize === "0px") node.classList.add(ICON_CLASS); + // Last in the DOM and the highest order: after the existing indicators in + // either row direction, and still there when a later one appears. + node.parentNode.appendChild(node); + } + }, + ); + +const run = () => + Promise.all([L.require("utils.notices"), L.require("utils.hub-api")]) + .then(([notices, hubApi]) => + notices.isDue(hubApi.noticesCheckedAt(), Date.now()) + ? hubApi.refreshNotices().catch(() => null) + : null, + ) + .then(update); + +return baseclass.extend({ + INBOX_HASH: INBOX_HASH, + + __init__() { + if (!L.env.sessionid) return; + document.addEventListener( + "luci-loaded", + () => + (window.requestIdleCallback || window.setTimeout)(() => + Promise.resolve().then(run).catch(() => {}), + ), + { once: true }, + ); + }, + + update() { + return Promise.resolve().then(update).catch(() => {}); + }, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/asset-upload.js b/luci-app-aurora-config/.dev/src/resource/utils/asset-upload.js new file mode 100644 index 00000000..b9941599 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/asset-upload.js @@ -0,0 +1,399 @@ +"use strict"; +"require baseclass"; +"require rpc"; +"require ui"; + +// Shared upload plumbing for the config UI. Two surfaces consume it (custom +// fonts, brand asset library); anything font- or image-specific stays with +// the caller -- this module only knows about files, bytes, and the +// cgi-upload channel. + +const MAX_UPLOAD = 8 * 1024 * 1024; + +const formatSize = (bytes) => + bytes >= 1048576 + ? (bytes / 1048576).toFixed(1) + " MB" + : Math.max(1, Math.round(bytes / 1024)) + " KB"; + +const extOf = (name) => (name.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase(); + +return baseclass.extend({ + MAX_UPLOAD, + + formatSize, + + extOf, + + // Pure pre-check for instant feedback; the rpcd receive_upload gate stays + // authoritative. exts is a lowercase list without dots, e.g. ["woff2"]. + checkFile(file, opts) { + const exts = opts?.exts || []; + const ext = extOf(file.name); + if (exts.length && !exts.includes(ext)) + return { + ok: false, + err: _("Unsupported file type. Allowed: %s").format(exts.join(", ")), + }; + if (file.size > MAX_UPLOAD) + return { + ok: false, + err: _("File is %s, exceeding the 8MB limit.").format( + formatSize(file.size), + ), + }; + return { ok: true, err: "" }; + }, + + // XHR to /cgi-bin/cgi-upload. Resolves on HTTP 200; the caller still has + // to run its own RPC confirm (upload_font / upload_icon) afterwards. + uploadToRouter(opts) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + if (opts.onProgress) + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) + opts.onProgress(Math.round((e.loaded / e.total) * 100)); + }); + + xhr.addEventListener("load", () => + xhr.status === 200 + ? resolve() + : reject(new Error(_("Upload failed (HTTP %s)").format(xhr.status))), + ); + xhr.addEventListener("error", () => + reject(new Error(_("Upload failed"))), + ); + + const formData = new FormData(); + formData.append("sessionid", rpc.getSessionID()); + formData.append("filename", opts.tmpPath); + formData.append("filemode", "0600"); + formData.append("filedata", opts.file, opts.file.name); + + xhr.open("POST", "/cgi-bin/cgi-upload"); + xhr.withCredentials = true; + xhr.send(formData); + }); + }, + + // Composite "asset manager": a native LuCI `.table` (preview | name | + // badge | size | delete) sitting on a dock that shows either the slim + // upload bar, the in-place confirm form, or a progress row -- never more + // than one at a time. Both custom-fonts and the brand asset library + // render through this; the only per-kind knowledge lives in the caller's + // row/bar/form/checkFile/upload callbacks. Table markup deliberately + // mirrors the theme's other cbi tables (`table` / `tr table-titles` / + // `th` / `tr` / `td`) so it inherits the Aurora theme's colors, dark + // mode, and row hover instead of fighting it with bespoke inline chrome. + // + // cfg = { + // badgeHeader, // _("Slot") or _("Type") + // emptyText, + // rows: [{ preview, name, badge, size, onDelete() }], // badge: plain text or falsy + // bar: { hint, sub, accept }, + // checkFile(file) -> { ok, err }, + // form: { + // fields(file) -> { + // rows: [{label, control}], // rendered as cbi-value label/field rows + // value(), valid(), setDisabled(bool), // bool reaches every control + // }, + // }, + // upload(file, meta, onProgress) -> Promise, // caller composes + // // uploadToRouter + RPC + // } + createAssetManager(cfg) { + const rows = cfg.rows || []; + + const shell = E("div", {}); + + const dock = E("div", {}); + shell.appendChild(dock); + + if (rows.length) { + shell.appendChild( + E("table", { class: "table" }, [ + E("tr", { class: "tr table-titles" }, [ + E("th", { class: "th", style: "width:56px;" }, ""), + E("th", { class: "th" }, _("Name")), + E("th", { class: "th" }, cfg.badgeHeader), + E("th", { class: "th" }, _("Size")), + E("th", { class: "th center" }, ""), + ]), + ...rows.map((row) => + E("tr", { class: "tr" }, [ + E("td", { class: "td" }, row.preview), + E( + "td", + { class: "td", style: "word-break:break-all;" }, + row.name, + ), + E( + "td", + { + class: "td", + style: "color:var(--text-muted);font-size:0.9em;", + }, + row.badge || "", + ), + E( + "td", + { + class: "td", + style: + "color:var(--text-muted);font-variant-numeric:tabular-nums;" + + "white-space:nowrap;", + }, + formatSize(row.size || 0), + ), + E( + "td", + { class: "td center" }, + E( + "button", + { + type: "button", + class: "cbi-button cbi-button-remove", + click: row.onDelete, + }, + _("Delete"), + ), + ), + ]), + ), + ]), + ); + } else { + shell.appendChild( + E("div", { style: "padding:0.5em 0;" }, [E("em", {}, cfg.emptyText)]), + ); + } + + const clearDock = () => { + while (dock.firstChild) dock.firstChild.remove(); + }; + + const renderBar = () => { + clearDock(); + const input = E("input", { + type: "file", + style: "display:none;", + accept: (cfg.bar && cfg.bar.accept) || "", + }); + input.addEventListener("change", () => { + const file = input.files && input.files[0]; + input.value = ""; + if (file) onFile(file); + }); + + const bar = E( + "button", + { + type: "button", + style: + "display:block;width:100%;" + + "border:2px dashed var(--hairline);border-radius:0.5em;" + + "padding:1.25em 1em;text-align:center;cursor:pointer;" + + "margin-bottom:0.75em;" + + "transition:border-color 0.15s,background 0.15s;", + click: () => input.click(), + dragover: (e) => { + e.preventDefault(); + bar.style.borderColor = "var(--brand)"; + bar.style.background = "var(--brand-subtle)"; + }, + dragleave: () => { + bar.style.borderColor = ""; + bar.style.background = ""; + }, + drop: (e) => { + e.preventDefault(); + bar.style.borderColor = ""; + bar.style.background = ""; + const file = e.dataTransfer && e.dataTransfer.files[0]; + if (file) onFile(file); + }, + }, + [ + E( + "div", + { style: "font-size:1.5em;margin-bottom:0.25em;pointer-events:none;" }, + "⬆", + ), + E("strong", { style: "pointer-events:none;" }, cfg.bar.hint), + E( + "div", + { + style: + "font-size:0.8em;opacity:0.6;margin-top:0.25em;pointer-events:none;", + }, + cfg.bar.sub || "", + ), + ], + ); + + dock.appendChild(bar); + dock.appendChild(input); + }; + + const buildForm = (file, check) => { + const fields = cfg.form.fields(file); + + const progressBar = E("div", { + style: + "height:100%;width:0%;transition:width 0.12s linear;" + + "background:var(--brand);border-radius:999px;", + }); + const progressWrap = E( + "div", + { + style: + "display:none;height:5px;border-radius:999px;" + + "background:var(--hairline);overflow:hidden;margin-top:0.6em;", + }, + [progressBar], + ); + const setProgress = (p) => { + progressBar.style.width = p + "%"; + }; + const showProgress = () => { + progressWrap.style.display = "block"; + }; + const hideProgress = () => { + progressWrap.style.display = "none"; + setProgress(0); + }; + + let busy = false; + + const cancelBtn = E( + "button", + { type: "button", class: "cbi-button", click: renderBar }, + _("Cancel"), + ); + + const fileSummary = E("div", { style: "margin-bottom:0.6em;" }, [ + E("strong", { style: "word-break:break-all;" }, file.name), + " · " + formatSize(file.size), + ]); + + const errEl = check.ok + ? null + : E( + "p", + { + style: + "color:var(--danger);font-weight:600;font-size:0.9em;" + + "margin:0 0 0.6em;", + }, + check.err, + ); + + const rowsEl = E( + "div", + { style: "margin-bottom:0.6em;" }, + fields.rows.map((row) => + E("div", { class: "cbi-value" }, [ + E("label", { class: "cbi-value-title" }, row.label), + E("div", { class: "cbi-value-field" }, row.control), + ]), + ), + ); + + const goBtn = E( + "button", + { type: "button", class: "cbi-button cbi-button-positive" }, + _("Confirm Upload"), + ); + const updateGoState = () => { + if (busy) return; + goBtn.disabled = !check.ok || !fields.valid(); + }; + updateGoState(); + rowsEl.addEventListener("input", updateGoState); + rowsEl.addEventListener("change", updateGoState); + + const actionsRow = E("div", { class: "right" }, [ + cancelBtn, + " ", + goBtn, + ]); + + goBtn.addEventListener("click", () => { + busy = true; + goBtn.disabled = true; + cancelBtn.disabled = true; + fields.setDisabled(true); + showProgress(); + setProgress(0); + const meta = fields.value(); + cfg + .upload(file, meta, setProgress) + .catch((err) => { + busy = false; + cancelBtn.disabled = false; + fields.setDisabled(false); + hideProgress(); + updateGoState(); + ui.addNotification( + null, + E("p", _("Upload failed: %s").format(err.message)), + "error", + ); + }); + }); + + return E( + "div", + { + style: + "margin-bottom:0.5em;padding-bottom:0.6em;" + + "border-bottom:1px solid var(--hairline);", + }, + [fileSummary, errEl, rowsEl, actionsRow, progressWrap].filter(Boolean), + ); + }; + + const onFile = (file) => { + const check = cfg.checkFile(file); + clearDock(); + dock.appendChild(buildForm(file, check)); + }; + + renderBar(); + return shell; + }, + + confirmDelete(opts) { + return new Promise((resolve) => { + ui.showModal(opts.title, [ + E("p", {}, opts.message), + E("div", { class: "right" }, [ + E( + "button", + { + class: "btn", + click: () => { + ui.hideModal(); + resolve(false); + }, + }, + _("Cancel"), + ), + " ", + E( + "button", + { + class: "btn cbi-button-negative", + click: () => { + ui.hideModal(); + resolve(true); + }, + }, + _("Delete"), + ), + ]), + ]); + }); + }, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/color.global.js b/luci-app-aurora-config/.dev/src/resource/utils/color.global.js new file mode 100644 index 00000000..f2eb38b1 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/color.global.js @@ -0,0 +1,7905 @@ +var Color = (function () { + 'use strict'; + + /** @import { Matrix3x3, Vector3 } from "./types.js" */ + + /** + * A is m x n. B is n x p. product is m x p. + * + * Array arguments are treated like vectors: + * - A becomes 1 x n + * - B becomes n x 1 + * + * Returns Matrix m x p or equivalent array or number + * + * @overload + * @param {number[]} A Vector 1 x n + * @param {number[]} B Vector n x 1 + * @returns {number} Scalar number + * + * @overload + * @param {number[][]} A Matrix m x n + * @param {number[]} B Vector n x 1 + * @returns {number[]} Array with length m + * + * @overload + * @param {number[]} A Vector 1 x n + * @param {number[][]} B Matrix n x p + * @returns {number[]} Array with length p + * + * @overload + * @param {number[][]} A Matrix m x n + * @param {number[][]} B Matrix n x p + * @returns {number[][]} Matrix m x p + * + * @param {number[] | number[][]} A Matrix m x n or a vector + * @param {number[] | number[][]} B Matrix n x p or a vector + * @returns {number | number[] | number[][]} Matrix m x p or equivalent array or number + */ + function multiplyMatrices (A, B) { + let m = A.length; + /** @type {number[][]} */ + let AM; + /** @type {number[][]} */ + let BM; + let aVec = false; + let bVec = false; + + if (!Array.isArray(A[0])) { + // A is vector, convert to [[a, b, c, ...]] + AM = [/** @type {number[]} */ (A)]; + m = AM.length; + aVec = true; + } + else { + AM = /** @type {number[][]} */ (A); + } + + if (!Array.isArray(B[0])) { + // B is vector, convert to [[a], [b], [c], ...]] + BM = B.length > 0 ? B.map(x => [x]) : [[]]; // Avoid mapping empty array + bVec = true; + } + else { + BM = /** @type {number[][]} */ (B); + } + + let p = BM[0].length; + let BM_cols = BM[0].map((_, i) => BM.map(x => x[i])); // transpose B + /** @type {number[] | number[][]} */ + let product = AM.map(row => + BM_cols.map(col => { + let ret = 0; + + if (!Array.isArray(row)) { + for (let c of col) { + ret += row * c; + } + + return ret; + } + + for (let i = 0; i < row.length; i++) { + ret += row[i] * (col[i] || 0); + } + + return ret; + })); + + if (m === 1 && aVec) { + product = product[0]; // Avoid [[a, b, c, ...]] + } + if (p === 1 && bVec) { + if (m === 1 && aVec) { + return product[0]; // Avoid [[a]], return a number + } + else { + return product.map(x => x[0]); // Avoid [[a], [b], [c], ...]] + } + } + + return product; + } + + // dot3 and transform functions adapted from https://github.com/texel-org/color/blob/9793c7d4d02b51f068e0f3fd37131129a4270396/src/core.js + // + // The MIT License (MIT) + // Copyright (c) 2024 Matt DesLauriers + + // Permission 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: + + // The above copyright notice and this permission notice shall be included in all + // copies or substantial portions of the Software. + + // THE 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. + + /** + * Returns the dot product of two vectors each with a length of 3. + * + * @param {Vector3} a + * @param {Vector3} b + * @returns {number} + */ + function dot3 (a, b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } + + /** + * Transforms a vector of length 3 by a 3x3 matrix. Specify the same input and output + * vector to transform in place. + * + * @param {Vector3} input + * @param {Matrix3x3} matrix + * @param {Vector3} [out] + * @returns {Vector3} + */ + function multiply_v3_m3x3 (input, matrix, out = [0, 0, 0]) { + const x = dot3(input, matrix[0]); + const y = dot3(input, matrix[1]); + const z = dot3(input, matrix[2]); + out[0] = x; + out[1] = y; + out[2] = z; + return out; + } + + /** + * Various utility functions + */ + + + /** + * Check if a value is a string (including a String object) + * @param {any} str - Value to check + * @returns {str is string} + */ + function isString (str) { + return type(str) === "string"; + } + + /** + * Determine the internal JavaScript [[Class]] of an object. + * @param {any} o - Value to check + * @returns {string} + */ + function type (o) { + let str = Object.prototype.toString.call(o); + + return (str.match(/^\[object\s+(.*?)\]$/)[1] || "").toLowerCase(); + } + + /** + * @param {number} n + * @param {{ precision?: number | undefined, unit?: string | undefined }} options + * @returns {string} + */ + function serializeNumber (n, { precision = 16, unit }) { + if (isNone(n)) { + return "none"; + } + + n = +toPrecision(n, precision); + + return n + (unit ?? ""); + } + + /** + * Check if a value corresponds to a none argument + * @param {any} n - Value to check + * @returns {n is null} + */ + function isNone (n) { + return n === null; + } + + /** + * Replace none values with 0 + * @param {number | null} n + * @returns {number} + */ + function skipNone (n) { + return isNone(n) ? 0 : n; + } + + /** + * Round a number to a certain number of significant digits + * @param {number} n - The number to round + * @param {number} precision - Number of significant digits + */ + function toPrecision (n, precision) { + if (n === 0) { + return 0; + } + let integer = ~~n; + let digits = 0; + if (integer && precision) { + digits = ~~Math.log10(Math.abs(integer)) + 1; + } + const multiplier = 10.0 ** (precision - digits); + return Math.floor(n * multiplier + 0.5) / multiplier; + } + + /** + * @param {number} start + * @param {number} end + * @param {number} p + */ + function interpolate (start, end, p) { + if (isNaN(start)) { + return end; + } + + if (isNaN(end)) { + return start; + } + + return start + (end - start) * p; + } + + /** + * @param {number} start + * @param {number} end + * @param {number} value + */ + function interpolateInv (start, end, value) { + return (value - start) / (end - start); + } + + /** + * @param {[number, number]} from + * @param {[number, number]} to + * @param {number} value + */ + function mapRange (from, to, value) { + if ( + !from || + !to || + from === to || + (from[0] === to[0] && from[1] === to[1]) || + isNaN(value) || + value === null + ) { + // Ranges missing or the same + return value; + } + + return interpolate(to[0], to[1], interpolateInv(from[0], from[1], value)); + } + + /** + * Clamp value between the minimum and maximum + * @param {number} min minimum value to return + * @param {number} val the value to return if it is between min and max + * @param {number} max maximum value to return + */ + function clamp (min, val, max) { + return Math.max(Math.min(max, val), min); + } + + /** + * Copy sign of one value to another. + * @param {number} to - Number to copy sign to + * @param {number} from - Number to copy sign from + */ + function copySign (to, from) { + return Math.sign(to) === Math.sign(from) ? to : -to; + } + + /** + * Perform pow on a signed number and copy sign to result + * @param {number} base The base number + * @param {number} exp The exponent + */ + function spow (base, exp) { + return copySign(Math.abs(base) ** exp, base); + } + + /** + * Perform a divide, but return zero if the denominator is zero + * @param {number} n The numerator + * @param {number} d The denominator + */ + function zdiv (n, d) { + return d === 0 ? 0 : n / d; + } + + /** + * Perform a bisect on a sorted list and locate the insertion point for + * a value in arr to maintain sorted order. + * @param {number[]} arr - array of sorted numbers + * @param {number} value - value to find insertion point for + * @param {number} lo - used to specify a the low end of a subset of the list + * @param {number} hi - used to specify a the high end of a subset of the list + */ + function bisectLeft (arr, value, lo = 0, hi = arr.length) { + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (arr[mid] < value) { + lo = mid + 1; + } + else { + hi = mid; + } + } + return lo; + } + + /** + * Determines whether an argument is an instance of a constructor, including subclasses. + * This is done by first just checking `instanceof`, + * and then comparing the string names of the constructors if that fails. + * @param {any} arg + * @param {C} constructor + * @template {new (...args: any) => any} C + * @returns {arg is InstanceType} + */ + function isInstance (arg, constructor) { + if (arg instanceof constructor) { + return true; + } + + const targetName = constructor.name; + + while (arg) { + const proto = Object.getPrototypeOf(arg); + const constructorName = proto?.constructor?.name; + if (constructorName === targetName) { + return true; + } + if (!constructorName || constructorName === "Object") { + return false; + } + arg = proto; + } + + return false; + } + + var util = /*#__PURE__*/Object.freeze({ + __proto__: null, + bisectLeft: bisectLeft, + clamp: clamp, + copySign: copySign, + interpolate: interpolate, + interpolateInv: interpolateInv, + isInstance: isInstance, + isNone: isNone, + isString: isString, + mapRange: mapRange, + multiplyMatrices: multiplyMatrices, + multiply_v3_m3x3: multiply_v3_m3x3, + serializeNumber: serializeNumber, + skipNone: skipNone, + spow: spow, + toPrecision: toPrecision, + type: type, + zdiv: zdiv + }); + + /** + * A class for adding deep extensibility to any piece of JS code + */ + class Hooks { + add (name, callback, first) { + if (typeof arguments[0] != "string") { + // Multiple hooks + for (var name in arguments[0]) { + this.add(name, arguments[0][name], arguments[1]); + } + + return; + } + + (Array.isArray(name) ? name : [name]).forEach(function (name) { + this[name] = this[name] || []; + + if (callback) { + this[name][first ? "unshift" : "push"](callback); + } + }, this); + } + + run (name, env) { + this[name] = this[name] || []; + this[name].forEach(function (callback) { + callback.call(env && env.context ? env.context : env, env); + }); + } + } + + /** + * The instance of {@link Hooks} used throughout Color.js + */ + const hooks = new Hooks(); + + // Global defaults one may want to configure + var defaults = { + gamut_mapping: "css", + precision: 5, + deltaE: "76", // Default deltaE method + verbose: globalThis?.process?.env?.NODE_ENV?.toLowerCase() !== "test", + warn: function warn (msg) { + if (this.verbose) { + globalThis?.console?.warn?.(msg); + } + }, + }; + + class Type { + // Class properties - declared here so that type inference works + type; + coordMeta; + coordRange; + /** @type {[number, number]} */ + range; + + /** + * @param {any} type + * @param {import("./types.js").CoordMeta} coordMeta + */ + constructor (type, coordMeta) { + if (typeof type === "object") { + this.coordMeta = type; + } + + if (coordMeta) { + this.coordMeta = coordMeta; + this.coordRange = coordMeta.range ?? coordMeta.refRange; + } + + if (typeof type === "string") { + let params = type + .trim() + .match(/^(?<[a-z]+>)(\[(?-?[.\d]+),\s*(?-?[.\d]+)\])?$/); + + if (!params) { + throw new TypeError(`Cannot parse ${type} as a type definition.`); + } + + this.type = params.groups.type; + let { min, max } = params.groups; + + if (min || max) { + this.range = [+min, +max]; + } + } + } + + /** @returns {[number, number]} */ + get computedRange () { + if (this.range) { + return this.range; + } + if (this.type === "") { + return this.percentageRange(); + } + else if (this.type === "") { + return [0, 360]; + } + return null; + } + + get unit () { + if (this.type === "") { + return "%"; + } + else if (this.type === "") { + return "deg"; + } + + return ""; + } + + /** + * Map a number to the internal representation + * @param {number} number + */ + resolve (number) { + if (this.type === "") { + return number; + } + + let fromRange = this.computedRange; + let toRange = this.coordRange; + + if (this.type === "") { + toRange ??= this.percentageRange(); + } + + return mapRange(fromRange, toRange, number); + } + + /** + * Serialize a number from the internal representation to a string + * @param {number} number + * @param {number} [precision] + */ + serialize (number, precision) { + let toRange = this.type === "" ? this.percentageRange(100) : this.computedRange; + + let unit = this.unit; + + number = mapRange(this.coordRange, toRange, number); + return serializeNumber(number, { unit, precision }); + } + + toString () { + let ret = this.type; + + if (this.range) { + let [min = "", max = ""] = this.range; + ret += `[${min},${max}]`; + } + + return ret; + } + + /** + * Returns a percentage range for values of this type + * @param {number} scale + * @returns {[number, number]} + */ + percentageRange (scale = 1) { + let range; + if ( + (this.coordMeta && this.coordMeta.range) || + (this.coordRange && this.coordRange[0] >= 0) + ) { + range = [0, 1]; + } + else { + range = [-1, 1]; + } + return [range[0] * scale, range[1] * scale]; + } + + static get (type, coordMeta) { + if (isInstance(type, this)) { + return type; + } + + return new this(type, coordMeta); + } + } + + /** @import { ColorSpace, Coords } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").Format} FormatInterface */ + + /** + * @internal + * Used to index {@link FormatInterface Format} objects and store an instance. + * Not meant for external use + */ + const instance = Symbol("instance"); + + /** + * Remove the first element of an array type + * @template {any[]} T + * @typedef {T extends [any, ...infer R] ? R : T[number][]} RemoveFirstElement + */ + + /** + * @class Format + * @implements {Omit} + * Class to hold a color serialization format + */ + class Format { + // Class properties - declared here so that type inference works + type; + name; + spaceCoords; + /** @type {Type[][]} */ + coords; + /** @type {string | undefined} */ + id; + /** @type {boolean | undefined} */ + alpha; + + /** + * @param {FormatInterface} format + * @param {ColorSpace} space + */ + constructor (format, space = format.space) { + format[instance] = this; + this.type = "function"; + this.name = "color"; + + Object.assign(this, format); + + this.space = space; + + if (this.type === "custom") { + // Nothing else to do here + return; + } + + this.spaceCoords = Object.values(space.coords); + + if (!this.coords) { + // @ts-expect-error Strings are converted to the correct type later + this.coords = this.spaceCoords.map(coordMeta => { + let ret = ["", ""]; + + if (coordMeta.type === "angle") { + ret.push(""); + } + + return ret; + }); + } + + this.coords = this.coords.map( + /** @param {string | string[] | Type[]} types */ (types, i) => { + let coordMeta = this.spaceCoords[i]; + + if (typeof types === "string") { + types = types.trim().split(/\s*\|\s*/); + } + + return types.map(type => Type.get(type, coordMeta)); + }, + ); + } + + /** + * @param {Coords} coords + * @param {number} precision + * @param {Type[]} types + */ + serializeCoords (coords, precision, types) { + types = coords.map((_, i) => + Type.get(types?.[i] ?? this.coords[i][0], this.spaceCoords[i])); + return coords.map((c, i) => types[i].serialize(c, precision)); + } + + /** + * Validates the coordinates of a color against a format's coord grammar and + * maps the coordinates to the range or refRange of the coordinates. + * @param {Coords} coords + * @param {[string, string, string]} types + */ + coerceCoords (coords, types) { + return Object.entries(this.space.coords).map(([id, coordMeta], i) => { + let arg = coords[i]; + + if (isNone(arg) || isNaN(arg)) { + // Nothing to do here + return arg; + } + + // Find grammar alternative that matches the provided type + // Non-strict equals is intentional because we are comparing w/ string objects + let providedType = types[i]; + let type = this.coords[i].find(c => c.type == providedType); + + // Check that each coord conforms to its grammar + if (!type) { + // Type does not exist in the grammar, throw + let coordName = coordMeta.name || id; + throw new TypeError( + `${providedType ?? /** @type {any} */ (arg)?.raw ?? arg} not allowed for ${coordName} in ${this.name}()`, + ); + } + + arg = type.resolve(arg); + + if (type.range) { + // Adjust type to include range + types[i] = type.toString(); + } + + return arg; + }); + } + + /** + * @returns {boolean | Required["serialize"]} + */ + canSerialize () { + return this.type === "function" || /** @type {any} */ (this).serialize; + } + + /** + * @param {string} str + * @returns {(import("./types.js").ColorConstructor) | undefined | null} + */ + parse (str) { + return null; + } + + /** + * @param {Format | FormatInterface} format + * @param {RemoveFirstElement>} args + * @returns {Format} + */ + static get (format, ...args) { + if (!format || isInstance(format, this)) { + return /** @type {Format} */ (format); + } + + if (format[instance]) { + return format[instance]; + } + + return new Format(format, ...args); + } + } + + // Type re-exports + /** @typedef {import("./types.js").White} White */ + + /** @type {Record} */ + // prettier-ignore + const WHITES = { + // for compatibility, the four-digit chromaticity-derived ones everyone else uses + D50: [0.3457 / 0.3585, 1.00000, (1.0 - 0.3457 - 0.3585) / 0.3585], + D65: [0.3127 / 0.3290, 1.00000, (1.0 - 0.3127 - 0.3290) / 0.3290], + }; + + /** + * + * @param {string | White} name + * @returns {White} + */ + function getWhite (name) { + if (Array.isArray(name)) { + return name; + } + + return WHITES[name]; + } + + /** + * Adapt XYZ from white point W1 to W2 + * @param {White | string} W1 + * @param {White | string} W2 + * @param {[number, number, number]} XYZ + * @param {{ method?: string | undefined }} options + * @returns {[number, number, number]} + */ + function adapt$2 (W1, W2, XYZ, options = {}) { + W1 = getWhite(W1); + W2 = getWhite(W2); + + if (!W1 || !W2) { + throw new TypeError( + `Missing white point to convert ${!W1 ? "from" : ""}${!W1 && !W2 ? "/" : ""}${!W2 ? "to" : ""}`, + ); + } + + if (W1 === W2) { + // Same whitepoints, no conversion needed + return XYZ; + } + + let env = { W1, W2, XYZ, options }; + + hooks.run("chromatic-adaptation-start", env); + + if (!env.M) { + if (env.W1 === WHITES.D65 && env.W2 === WHITES.D50) { + // prettier-ignore + env.M = [ + [ 1.0479297925449969, 0.022946870601609652, -0.05019226628920524 ], + [ 0.02962780877005599, 0.9904344267538799, -0.017073799063418826 ], + [ -0.009243040646204504, 0.015055191490298152, 0.7518742814281371 ], + ]; + } + else if (env.W1 === WHITES.D50 && env.W2 === WHITES.D65) { + // prettier-ignore + env.M = [ + [ 0.955473421488075, -0.02309845494876471, 0.06325924320057072 ], + [ -0.0283697093338637, 1.0099953980813041, 0.021041441191917323 ], + [ 0.012314014864481998, -0.020507649298898964, 1.330365926242124 ], + ]; + } + } + + hooks.run("chromatic-adaptation-end", env); + + if (env.M) { + return multiply_v3_m3x3(env.XYZ, env.M); + } + else { + throw new TypeError("Only Bradford CAT with white points D50 and D65 supported for now."); + } + } + + /** @import { ColorConstructor } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").ArgumentMeta} ArgumentMeta */ + /** @typedef {import("./types.js").ParseFunctionReturn} ParseFunctionReturn */ + /** @typedef {import("./types.js").ParseOptions} ParseOptions */ + + /** + * Convert a CSS Color string to a color object + * @param {string} str + * @param {ParseOptions} [options] + * @returns {ColorConstructor} + */ + function parse (str, options) { + let env = { + str: String(str)?.trim(), + options, + }; + + hooks.run("parse-start", env); + + if (env.color) { + return env.color; + } + + env.parsed = parseFunction(env.str); + let ret; + let meta = env.options ? (env.options.parseMeta ?? env.options.meta) : null; + + if (env.parsed) { + // Is a functional syntax + let name = env.parsed.name; + let format; + let space; + let coords = env.parsed.args; + let types = coords.map((c, i) => env.parsed.argMeta[i]?.type); + + if (name === "color") { + // color() function + let id = coords.shift(); + types.shift(); + // Check against both and versions + let alternateId = id.startsWith("--") ? id.substring(2) : `--${id}`; + let ids = [id, alternateId]; + format = ColorSpace.findFormat({ name, id: ids, type: "function" }); + + if (!format) { + // Not found + let didYouMean; + + let registryId = id in ColorSpace.registry ? id : alternateId; + if (registryId in ColorSpace.registry) { + // Used color space id instead of color() id, these are often different + let cssId = ColorSpace.registry[registryId].formats?.color?.id; + + if (cssId) { + let altColor = str.replace("color(" + id, "color(" + cssId); + didYouMean = `Did you mean ${altColor}?`; + } + } + + throw new TypeError( + `Cannot parse ${env.str}. ` + (didYouMean ?? "Missing a plugin?"), + ); + } + + space = format.space; + + if (format.id.startsWith("--") && !id.startsWith("--")) { + defaults.warn( + `${space.name} is a non-standard space and not currently supported in the CSS spec. ` + + `Use prefixed color(${format.id}) instead of color(${id}).`, + ); + } + if (id.startsWith("--") && !format.id.startsWith("--")) { + defaults.warn( + `${space.name} is a standard space and supported in the CSS spec. ` + + `Use color(${format.id}) instead of prefixed color(${id}).`, + ); + } + } + else { + format = ColorSpace.findFormat({ name, type: "function" }); + space = format.space; + } + + if (meta) { + Object.assign(meta, { + format, + formatId: format.name, + types, + commas: env.parsed.commas, + }); + } + + let alpha = 1; + + if (env.parsed.lastAlpha) { + alpha = env.parsed.args.pop(); + + if (meta) { + meta.alphaType = types.pop(); + } + } + + let coordCount = format.coords.length; + + if (coords.length !== coordCount) { + throw new TypeError( + `Expected ${coordCount} coordinates for ${space.id} in ${env.str}), got ${coords.length}`, + ); + } + + coords = format.coerceCoords(coords, types); + + ret = { spaceId: space.id, coords, alpha }; + } + else { + // Custom, colorspace-specific format + spaceloop: for (let space of ColorSpace.all) { + for (let formatId in space.formats) { + let format = space.formats[formatId]; + + if (format.type !== "custom") { + continue; + } + + if (format.test && !format.test(env.str)) { + continue; + } + + // Convert to Format object + let formatObject = space.getFormat(format); + + let color = formatObject.parse(env.str); + + if (color) { + if (meta) { + Object.assign(meta, { format: formatObject, formatId }); + } + + ret = color; + break spaceloop; + } + } + } + } + + if (!ret) { + // If we're here, we couldn't parse + throw new TypeError(`Could not parse ${str} as a color. Missing a plugin?`); + } + + // Clamp alpha to [0, 1] + ret.alpha = isNone(ret.alpha) + ? ret.alpha + : ret.alpha === undefined + ? 1 + : clamp(0, ret.alpha, 1); + + return ret; + } + + /** + * Units and multiplication factors for the internally stored numbers + */ + const units = { + "%": 0.01, + deg: 1, + grad: 0.9, + rad: 180 / Math.PI, + turn: 360, + }; + + const regex = { + // Need to list calc(NaN) explicitly as otherwise its ending paren would terminate the function call + function: /^([a-z]+)\(((?:calc\(NaN\)|.)+?)\)$/i, + number: /^([-+]?(?:[0-9]*\.)?[0-9]+(e[-+]?[0-9]+)?)$/i, + unitValue: RegExp(`(${Object.keys(units).join("|")})$`), + + // NOTE The -+ are not just for prefix, but also for idents, and e+N notation! + singleArgument: /\/?\s*(none|NaN|calc\(NaN\)|[-+\w.]+(?:%|deg|g?rad|turn)?)/g, + }; + + /** + * Parse a single function argument + * @param {string} rawArg + * @returns {{value: number, meta: ArgumentMeta}} + */ + function parseArgument (rawArg) { + /** @type {Partial} */ + let meta = {}; + let unit = rawArg.match(regex.unitValue)?.[0]; + /** @type {string | number} */ + let value = (meta.raw = rawArg); + + if (unit) { + // It’s a dimension token + meta.type = unit === "%" ? "" : ""; + meta.unit = unit; + meta.unitless = Number(value.slice(0, -unit.length)); // unitless number + + value = meta.unitless * units[unit]; + } + else if (regex.number.test(value)) { + // It's a number + // Convert numerical args to numbers + value = Number(value); + meta.type = ""; + } + else if (value === "none") { + value = null; + } + else if (value === "NaN" || value === "calc(NaN)") { + value = NaN; + meta.type = ""; + } + else { + meta.type = ""; + } + + return { value: /** @type {number} */ (value), meta: /** @type {ArgumentMeta} */ (meta) }; + } + + /** + * Parse a CSS function, regardless of its name and arguments + * @param {string} str String to parse + * @return {ParseFunctionReturn | void} + */ + function parseFunction (str) { + if (!str) { + return; + } + + str = str.trim(); + + let parts = str.match(regex.function); + + if (parts) { + // It is a function, parse args + let args = []; + let argMeta = []; + let lastAlpha = false; + let name = parts[1].toLowerCase(); + + let separators = parts[2].replace(regex.singleArgument, ($0, rawArg) => { + let { value, meta } = parseArgument(rawArg); + + if ( + // If there's a slash here, it's modern syntax + $0.startsWith("/") || + // If there's still elements to process after there's already 3 in `args` (and the we're not dealing with "color()"), it's likely to be a legacy color like "hsl(0, 0%, 0%, 0.5)" + (name !== "color" && args.length === 3) + ) { + // It's alpha + lastAlpha = true; + } + + args.push(value); + argMeta.push(meta); + return ""; + }); + + return { + name, + args, + argMeta, + lastAlpha, + commas: separators.includes(","), + rawName: parts[1], + rawArgs: parts[2], + }; + } + } + + /** @import { ColorTypes, ParseOptions as GetColorOptions, PlainColorObject } from "./types.js" */ + + /** + * Resolves a color reference (object or string) to a plain color object + * @overload + * @param {ColorTypes} color + * @param {GetColorOptions} [options] + * @returns {PlainColorObject} + */ + /** + * @overload + * @param {ColorTypes[]} color + * @param {GetColorOptions} [options] + * @returns {PlainColorObject[]} + */ + function getColor (color, options) { + if (Array.isArray(color)) { + return color.map(c => getColor(c, options)); + } + + if (!color) { + throw new TypeError("Empty color reference"); + } + + if (isString(color)) { + color = parse(color, options); + } + + // Object fixup + let space = color.space || color.spaceId; + + if (typeof space === "string") { + // Convert string id to color space object + color.space = ColorSpace.get(space); + } + + if (color.alpha === undefined) { + color.alpha = 1; + } + + return color; + } + + /** + * @packageDocumentation + * Defines the class and other types related to creating color spaces. + * For the builtin color spaces, see the `spaces` module. + */ + + const ε$7 = 0.000075; + + /** + * Class to represent a color space + */ + class ColorSpace { + constructor (options) { + this.id = options.id; + this.name = options.name; + this.base = options.base ? ColorSpace.get(options.base) : null; + this.aliases = options.aliases; + + if (this.base) { + this.fromBase = options.fromBase; + this.toBase = options.toBase; + } + + // Coordinate metadata + + let coords = options.coords ?? this.base.coords; + + for (let name in coords) { + if (!("name" in coords[name])) { + coords[name].name = name; + } + } + this.coords = coords; + + // White point + + let white = options.white ?? this.base.white ?? "D65"; + this.white = getWhite(white); + + // Sort out formats + + this.formats = options.formats ?? {}; + + for (let name in this.formats) { + let format = this.formats[name]; + format.type ||= "function"; + format.name ||= name; + } + + if (!this.formats.color?.id) { + this.formats.color = { + ...(this.formats.color ?? {}), + id: options.cssId || this.id, + }; + } + + // Gamut space + + if (options.gamutSpace) { + // Gamut space explicitly specified + this.gamutSpace = + options.gamutSpace === "self" ? this : ColorSpace.get(options.gamutSpace); + } + else { + // No gamut space specified, calculate a sensible default + if (this.isPolar) { + // Do not check gamut through polar coordinates + this.gamutSpace = this.base; + } + else { + this.gamutSpace = this; + } + } + + // Optimize inGamut for unbounded spaces + if (this.gamutSpace.isUnbounded) { + this.inGamut = (coords, options) => { + return true; + }; + } + + // Other stuff + this.referred = options.referred; + + // Compute ancestors and store them, since they will never change + Object.defineProperty(this, "path", { + value: getPath(this).reverse(), + writable: false, + enumerable: true, + configurable: true, + }); + + hooks.run("colorspace-init-end", this); + } + + inGamut (coords, { epsilon = ε$7 } = {}) { + if (!this.equals(this.gamutSpace)) { + coords = this.to(this.gamutSpace, coords); + return this.gamutSpace.inGamut(coords, { epsilon }); + } + + let coordMeta = Object.values(this.coords); + + return coords.every((c, i) => { + let meta = coordMeta[i]; + + if (meta.type !== "angle" && meta.range) { + if (isNone(c)) { + // NaN is always in gamut + return true; + } + + let [min, max] = meta.range; + return ( + (min === undefined || c >= min - epsilon) && + (max === undefined || c <= max + epsilon) + ); + } + + return true; + }); + } + + get isUnbounded () { + return Object.values(this.coords).every(coord => !("range" in coord)); + } + + get cssId () { + return this.formats?.color?.id || this.id; + } + + get isPolar () { + for (let id in this.coords) { + if (this.coords[id].type === "angle") { + return true; + } + } + + return false; + } + + /** + * Lookup a format in this color space + * @param {string | object | Format} format - Format id if string. If object, it's converted to a `Format` object and returned. + * @returns {Format} + */ + getFormat (format) { + if (!format) { + return null; + } + + if (format === "default") { + format = Object.values(this.formats)[0]; + } + else if (typeof format === "string") { + format = this.formats[format]; + } + + let ret = Format.get(format, this); + + if (ret !== format && format.name in this.formats) { + // Update the format we have on file so we can find it more quickly next time + this.formats[format.name] = ret; + } + + return ret; + } + + /** + * Check if this color space is the same as another color space reference. + * Allows proxying color space objects and comparing color spaces with ids. + * @param {string | ColorSpace} space ColorSpace object or id to compare to + * @returns {boolean} + */ + equals (space) { + if (!space) { + return false; + } + + return this === space || this.id === space || this.id === space.id; + } + + to (space, coords) { + if (arguments.length === 1) { + const color = getColor(space); + [space, coords] = [color.space, color.coords]; + } + + space = ColorSpace.get(space); + + if (this.equals(space)) { + // Same space, no change needed + return coords; + } + + // Convert NaN to 0, which seems to be valid in every coordinate of every color space + coords = coords.map(c => (isNone(c) ? 0 : c)); + + // Find connection space = lowest common ancestor in the base tree + let myPath = this.path; + let otherPath = space.path; + + let connectionSpace, connectionSpaceIndex; + + for (let i = 0; i < myPath.length; i++) { + if (myPath[i].equals(otherPath[i])) { + connectionSpace = myPath[i]; + connectionSpaceIndex = i; + } + else { + break; + } + } + + if (!connectionSpace) { + // This should never happen + throw new Error( + `Cannot convert between color spaces ${this} and ${space}: no connection space was found`, + ); + } + + // Go up from current space to connection space + for (let i = myPath.length - 1; i > connectionSpaceIndex; i--) { + coords = myPath[i].toBase(coords); + } + + // Go down from connection space to target space + for (let i = connectionSpaceIndex + 1; i < otherPath.length; i++) { + coords = otherPath[i].fromBase(coords); + } + + return coords; + } + + from (space, coords) { + if (arguments.length === 1) { + const color = getColor(space); + [space, coords] = [color.space, color.coords]; + } + + space = ColorSpace.get(space); + + return space.to(this, coords); + } + + toString () { + return `${this.name} (${this.id})`; + } + + getMinCoords () { + let ret = []; + + for (let id in this.coords) { + let meta = this.coords[id]; + let range = meta.range || meta.refRange; + ret.push(range?.min ?? 0); + } + + return ret; + } + + static registry = {}; + + // Returns array of unique color spaces + static get all () { + return [...new Set(Object.values(ColorSpace.registry))]; + } + + static register (id, space) { + if (arguments.length === 1) { + space = arguments[0]; + id = space.id; + } + + space = this.get(space); + + if (this.registry[id] && this.registry[id] !== space) { + throw new Error(`Duplicate color space registration: '${id}'`); + } + this.registry[id] = space; + + // Register aliases when called without an explicit ID. + if (arguments.length === 1 && space.aliases) { + for (let alias of space.aliases) { + this.register(alias, space); + } + } + + return space; + } + + /** + * Lookup ColorSpace object by name + * @param {ColorSpace | string} name + */ + static get (space, ...alternatives) { + if (!space || isInstance(space, this)) { + return space; + } + + let argType = type(space); + + if (argType === "string") { + // It's a color space id + let ret = ColorSpace.registry[space.toLowerCase()]; + + if (!ret) { + throw new TypeError(`No color space found with id = "${space}"`); + } + + return ret; + } + + if (alternatives.length) { + return ColorSpace.get(...alternatives); + } + + throw new TypeError(`${space} is not a valid color space`); + } + + /** + * Look up all color spaces for a format that matches certain criteria + * @param {object | string} filters + * @param {Array} [spaces=ColorSpace.all] + * @returns {Format | null} + */ + static findFormat (filters, spaces = ColorSpace.all) { + if (!filters) { + return null; + } + + if (typeof filters === "string") { + filters = { name: filters }; + } + + for (let space of spaces) { + for (let [name, format] of Object.entries(space.formats)) { + format.name ??= name; + format.type ??= "function"; + + let matches = + (!filters.name || format.name === filters.name) && + (!filters.type || format.type === filters.type); + + if (filters.id) { + let ids = format.ids || [format.id]; + let filterIds = Array.isArray(filters.id) ? filters.id : [filters.id]; + matches &&= filterIds.some(id => ids.includes(id)); + } + + if (matches) { + let ret = Format.get(format, space); + + if (ret !== format) { + space.formats[format.name] = ret; + } + + return ret; + } + } + } + + return null; + } + + /** + * Get metadata about a coordinate of a color space + * + * @static + * @param {Array | string} ref + * @param {ColorSpace | string} [workingSpace] + * @return {Object} + */ + static resolveCoord (ref, workingSpace) { + let coordType = type(ref); + let space, coord; + + if (coordType === "string") { + if (ref.includes(".")) { + // Absolute coordinate + [space, coord] = ref.split("."); + } + else { + // Relative coordinate + [space, coord] = [, ref]; + } + } + else if (Array.isArray(ref)) { + [space, coord] = ref; + } + else { + // Object + space = ref.space; + coord = ref.coordId; + } + + space = ColorSpace.get(space); + + if (!space) { + space = workingSpace; + } + + if (!space) { + throw new TypeError( + `Cannot resolve coordinate reference ${ref}: No color space specified and relative references are not allowed here`, + ); + } + + coordType = type(coord); + + if (coordType === "number" || (coordType === "string" && coord >= 0)) { + // Resolve numerical coord + let meta = Object.entries(space.coords)[coord]; + + if (meta) { + return { space, id: meta[0], index: coord, ...meta[1] }; + } + } + + space = ColorSpace.get(space); + + let normalizedCoord = coord.toLowerCase(); + + let i = 0; + for (let id in space.coords) { + let meta = space.coords[id]; + + if ( + id.toLowerCase() === normalizedCoord || + meta.name?.toLowerCase() === normalizedCoord + ) { + return { space, id, index: i, ...meta }; + } + + i++; + } + + throw new TypeError( + `No "${coord}" coordinate found in ${space.name}. Its coordinates are: ${Object.keys(space.coords).join(", ")}`, + ); + } + + static DEFAULT_FORMAT = { + type: "functions", + name: "color", + }; + } + + function getPath (space) { + let ret = [space]; + + for (let s = space; (s = s.base); ) { + ret.push(s); + } + + return ret; + } + + var xyz_d65 = new ColorSpace({ + id: "xyz-d65", + name: "XYZ D65", + coords: { + x: { + refRange: [0, 1], + name: "X", + }, + y: { + refRange: [0, 1], + name: "Y", + }, + z: { + refRange: [0, 1], + name: "Z", + }, + }, + white: "D65", + formats: { + color: { + ids: ["xyz-d65", "xyz"], + }, + }, + aliases: ["xyz"], + }); + + // Type re-exports + /** @typedef {import("./types.js").RGBOptions} RGBOptions */ + + /** Convenience class for RGB color spaces */ + class RGBColorSpace extends ColorSpace { + /** + * Creates a new RGB ColorSpace. + * If coords are not specified, they will use the default RGB coords. + * Instead of `fromBase()` and `toBase()` functions, + * you can specify to/from XYZ matrices and have `toBase()` and `fromBase()` automatically generated. + * @param {RGBOptions} options + */ + constructor (options) { + if (!options.coords) { + options.coords = { + r: { + range: [0, 1], + name: "Red", + }, + g: { + range: [0, 1], + name: "Green", + }, + b: { + range: [0, 1], + name: "Blue", + }, + }; + } + + if (!options.base) { + options.base = xyz_d65; + } + + if (options.toXYZ_M && options.fromXYZ_M) { + options.toBase ??= rgb => { + let xyz = multiply_v3_m3x3(rgb, options.toXYZ_M); + + if (this.white !== this.base.white) { + // Perform chromatic adaptation + xyz = adapt$2(this.white, this.base.white, xyz); + } + + return xyz; + }; + + options.fromBase ??= xyz => { + xyz = adapt$2(this.base.white, this.white, xyz); + return multiply_v3_m3x3(xyz, options.fromXYZ_M); + }; + } + + options.referred ??= "display"; + + super(options); + } + } + + /** @import { ColorTypes, PlainColorObject } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").TryColorOptions} TryColorOptions */ + + /** + * Resolves a color reference (object or string) to a plain color object, or `null` if resolution fails. + * Can resolve more complex CSS colors (e.g. relative colors, `calc()`, CSS variables, `color-mix()`, etc.) through the DOM. + * + * @overload + * @param {ColorTypes} color + * @param {TryColorOptions} [options] + * @returns {PlainColorObject | null} + */ + /** + * @overload + * @param {ColorTypes[]} color + * @param {TryColorOptions} [options] + * @returns {(PlainColorObject | null)[]} + */ + function tryColor (color, options = {}) { + if (Array.isArray(color)) { + return color.map(c => tryColor(c, options)); + } + + let { cssProperty = "background-color", element, ...getColorOptions } = options; + let error = null; + try { + return getColor(color, getColorOptions); + } + catch (e) { + error = e; + } + + let { CSS, getComputedStyle } = globalThis; + if (isString(color) && element && CSS && getComputedStyle) { + // Try resolving the color using the DOM, if supported in CSS + if (CSS.supports(cssProperty, color)) { + let previousValue = element.style[cssProperty]; + + if (color !== previousValue) { + element.style[cssProperty] = color; + } + + let computedColor = getComputedStyle(element).getPropertyValue(cssProperty); + + if (color !== previousValue) { + element.style[cssProperty] = previousValue; + } + + if (computedColor !== color) { + // getComputedStyle() changed the color, try again + try { + return getColor(computedColor, getColorOptions); + } + catch (e) { + error = e; + } + } + else { + // Still not resolved + error = { + message: "Color value is a valid CSS color, but it could not be resolved :(", + }; + } + } + } + + // If we're here, we failed to resolve the color + if (options.errorMeta) { + options.errorMeta.error = error; + } + + return null; + } + + /** @import { ColorTypes, Coords } from "./types.js" */ + + /** + * Options for {@link getAll} + * @typedef GetAllOptions + * @property {string | ColorSpace | undefined} [space] + * The color space to convert to. Defaults to the color's current space + * @property {number | undefined} [precision] + * The number of significant digits to round the coordinates to + */ + + /** + * Get the coordinates of a color in any color space + * @overload + * @param {ColorTypes} color + * @param {string | ColorSpace} [options=color.space] The color space to convert to. Defaults to the color's current space + * @returns {Coords} The color coordinates in the given color space + */ + /** + * @overload + * @param {ColorTypes} color + * @param {GetAllOptions} [options] + * @returns {Coords} The color coordinates in the given color space + */ + function getAll (color, options) { + color = getColor(color); + + let space = ColorSpace.get(options, options?.space); + let precision = options?.precision; + + let coords; + if (!space || color.space.equals(space)) { + // No conversion needed + coords = color.coords.slice(); + } + else { + coords = space.from(color); + } + + return precision === undefined ? coords : coords.map(coord => toPrecision(coord, precision)); + } + + /** @import { ColorTypes, Ref } from "./types.js" */ + + /** + * @param {ColorTypes} color + * @param {Ref} prop + * @returns {number} + */ + function get (color, prop) { + color = getColor(color); + + if (prop === "alpha") { + return color.alpha ?? 1; + } + + let { space, index } = ColorSpace.resolveCoord(prop, color.space); + let coords = getAll(color, space); + return coords[index]; + } + + /** @import { ColorTypes, Coords, PlainColorObject } from "./types.js" */ + + /** + * Set all coordinates of a color at once, in its own color space or another. + * Modifies the color in place. + * @overload + * @param {ColorTypes} color + * @param {Coords} coords Array of coordinates + * @param {number} [alpha] + * @returns {PlainColorObject} + */ + /** + * @overload + * @param {ColorTypes} color + * @param {string | ColorSpace} space The color space of the provided coordinates. + * @param {Coords} coords Array of coordinates + * @param {number} [alpha] + * @returns {PlainColorObject} + */ + function setAll (color, space, coords, alpha) { + color = getColor(color); + + if (Array.isArray(space)) { + // Space is omitted + [space, coords, alpha] = [color.space, space, coords]; + } + + space = ColorSpace.get(space); // Make sure we have a ColorSpace object + color.coords = space === color.space ? coords.slice() : space.to(color.space, coords); + + if (alpha !== undefined) { + color.alpha = alpha; + } + + return color; + } + + /** @type {"color"} */ + setAll.returns = "color"; + + /** @import { ColorTypes, PlainColorObject, Ref } from "./types.js" */ + + /** + * Set properties and return current instance + * @overload + * @param {ColorTypes} color + * @param {Ref} prop + * @param {number | ((coord: number) => number)} value + * @returns {PlainColorObject} + */ + /** + * @overload + * @param {ColorTypes} color + * @param {Record number)>} props + * @returns {PlainColorObject} + */ + function set (color, prop, value) { + color = getColor(color); + + if (arguments.length === 2 && type(arguments[1]) === "object") { + // Argument is an object literal + let object = arguments[1]; + for (let p in object) { + set(color, p, object[p]); + } + } + else { + if (typeof value === "function") { + value = value(get(color, prop)); + } + + if (prop === "alpha") { + color.alpha = value; + } + else { + let { space, index } = ColorSpace.resolveCoord(prop, color.space); + let coords = getAll(color, space); + coords[index] = value; + setAll(color, space, coords); + } + } + + return color; + } + + /** @type {"color"} */ + set.returns = "color"; + + var XYZ_D50 = new ColorSpace({ + id: "xyz-d50", + name: "XYZ D50", + white: "D50", + base: xyz_d65, + fromBase: coords => adapt$2(xyz_d65.white, "D50", coords), + toBase: coords => adapt$2("D50", xyz_d65.white, coords), + }); + + // κ * ε = 2^3 = 8 + const ε$6 = 216 / 24389; // 6^3/29^3 == (24/116)^3 + const ε3$1 = 24 / 116; + const κ$4 = 24389 / 27; // 29^3/3^3 + + let white$4 = WHITES.D50; + + var lab = new ColorSpace({ + id: "lab", + name: "Lab", + coords: { + l: { + refRange: [0, 100], + name: "Lightness", + }, + a: { + refRange: [-125, 125], + }, + b: { + refRange: [-125, 125], + }, + }, + + // Assuming XYZ is relative to D50, convert to CIE Lab + // from CIE standard, which now defines these as a rational fraction + white: white$4, + + base: XYZ_D50, + // Convert D50-adapted XYX to Lab + // CIE 15.3:2004 section 8.2.1.1 + fromBase (XYZ) { + // XYZ scaled relative to reference white + let xyz = XYZ.map((value, i) => value / white$4[i]); + let f = xyz.map(value => (value > ε$6 ? Math.cbrt(value) : (κ$4 * value + 16) / 116)); + + let L = 116 * f[1] - 16; + let a = 500 * (f[0] - f[1]); + let b = 200 * (f[1] - f[2]); + + return [L, a, b]; + }, + // Convert Lab to D50-adapted XYZ + // Same result as CIE 15.3:2004 Appendix D although the derivation is different + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + toBase (Lab) { + // compute f, starting with the luminance-related term + let [L, a, b] = Lab; + let f = []; + f[1] = (L + 16) / 116; + f[0] = a / 500 + f[1]; + f[2] = f[1] - b / 200; + + // compute xyz + // prettier-ignore + let xyz = [ + f[0] > ε3$1 ? Math.pow(f[0], 3) : (116 * f[0] - 16) / κ$4, + Lab[0] > 8 ? Math.pow((Lab[0] + 16) / 116, 3) : Lab[0] / κ$4, + f[2] > ε3$1 ? Math.pow(f[2], 3) : (116 * f[2] - 16) / κ$4, + ]; + + // Compute XYZ by scaling xyz by reference white + return xyz.map((value, i) => value * white$4[i]); + }, + + formats: { + lab: { + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + /** + * Constrain an angle to 360 degrees + * @param {number} angle + * @returns {number} + */ + function constrain (angle) { + if (typeof angle !== "number") { + return angle; + } + + return ((angle % 360) + 360) % 360; + } + + /** + * @param {"raw" | "increasing" | "decreasing" | "longer" | "shorter"} arc + * @param {[number, number]} angles + * @returns {[number, number]} + */ + function adjust (arc, angles) { + let [a1, a2] = angles; + + let none1 = isNone(a1); + let none2 = isNone(a2); + + if (none1 && none2) { + return [a1, a2]; + } + else if (none1) { + a1 = a2; + } + else if (none2) { + a2 = a1; + } + + if (arc === "raw") { + return angles; + } + + a1 = constrain(a1); + a2 = constrain(a2); + + let angleDiff = a2 - a1; + + if (arc === "increasing") { + if (angleDiff < 0) { + a2 += 360; + } + } + else if (arc === "decreasing") { + if (angleDiff > 0) { + a1 += 360; + } + } + else if (arc === "longer") { + if (-180 < angleDiff && angleDiff < 180) { + if (angleDiff > 0) { + a1 += 360; + } + else { + a2 += 360; + } + } + } + else if (arc === "shorter") { + if (angleDiff > 180) { + a1 += 360; + } + else if (angleDiff < -180) { + a2 += 360; + } + } + + return [a1, a2]; + } + + var lch = new ColorSpace({ + id: "lch", + name: "LCH", + coords: { + l: { + refRange: [0, 100], + name: "Lightness", + }, + c: { + refRange: [0, 150], + name: "Chroma", + }, + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + + base: lab, + fromBase (Lab) { + // These methods are used for other polar forms as well, so we can't hardcode the ε + if (this.ε === undefined) { + // @ts-expect-error Property 'coords' does not exist on type 'string | ColorSpace' + let range = Object.values(this.base.coords)[1].refRange; + let extent = range[1] - range[0]; + this.ε = extent / 100000; + } + + // Convert to polar form + let [L, a, b] = Lab; + let isAchromatic = Math.abs(a) < this.ε && Math.abs(b) < this.ε; + let h = isAchromatic ? null : constrain((Math.atan2(b, a) * 180) / Math.PI); + let C = isAchromatic ? 0 : Math.sqrt(a ** 2 + b ** 2); + + return [L, C, h]; + }, + toBase (lch) { + // Convert from polar form + let [L, C, h] = lch; + let a = null, + b = null; + + if (!isNone(h)) { + C = C < 0 ? 0 : C; // Clamp negative Chroma + a = C * Math.cos((h * Math.PI) / 180); + b = C * Math.sin((h * Math.PI) / 180); + } + + return [L, a, b]; + }, + + formats: { + lch: { + coords: [" | ", " | ", " | "], + }, + }, + }); + + // deltaE2000 is a statistically significant improvement + // and is recommended by the CIE and Idealliance + // especially for color differences less than 10 deltaE76 + // but is wicked complicated + // and many implementations have small errors! + // DeltaE2000 is also discontinuous; in case this + // matters to you, use deltaECMC instead. + + const Gfactor = 25 ** 7; + const π$1 = Math.PI; + const r2d = 180 / π$1; + const d2r$1 = π$1 / 180; + + function pow7 (x) { + // Faster than x ** 7 or Math.pow(x, 7) + + const x2 = x * x; + const x7 = x2 * x2 * x2 * x; + + return x7; + } + + /** + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @param {{ kL?: number | undefined; kC?: number | undefined; kH?: number | undefined }} options + * @returns {number} + */ + function deltaE2000 (color, sample, { kL = 1, kC = 1, kH = 1 } = {}) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and the function parameter as the sample, + // calculate deltaE 2000. + + // This implementation assumes the parametric + // weighting factors kL, kC and kH + // for the influence of viewing conditions + // are all 1, as sadly seems typical. + // kL should be increased for lightness texture or noise + // and kC increased for chroma noise + + let [L1, a1, b1] = lab.from(color); + let C1 = lch.from(lab, [L1, a1, b1])[1]; + let [L2, a2, b2] = lab.from(sample); + let C2 = lch.from(lab, [L2, a2, b2])[1]; + + // Check for negative Chroma, + // which might happen through + // direct user input of LCH values + + if (C1 < 0) { + C1 = 0; + } + if (C2 < 0) { + C2 = 0; + } + + let Cbar = (C1 + C2) / 2; // mean Chroma + + // calculate a-axis asymmetry factor from mean Chroma + // this turns JND ellipses for near-neutral colors back into circles + let C7 = pow7(Cbar); + + let G = 0.5 * (1 - Math.sqrt(C7 / (C7 + Gfactor))); + + // scale a axes by asymmetry factor + // this by the way is why there is no Lab2000 colorspace + let adash1 = (1 + G) * a1; + let adash2 = (1 + G) * a2; + + // calculate new Chroma from scaled a and original b axes + let Cdash1 = Math.sqrt(adash1 ** 2 + b1 ** 2); + let Cdash2 = Math.sqrt(adash2 ** 2 + b2 ** 2); + + // calculate new hues, with zero hue for true neutrals + // and in degrees, not radians + + let h1 = adash1 === 0 && b1 === 0 ? 0 : Math.atan2(b1, adash1); + let h2 = adash2 === 0 && b2 === 0 ? 0 : Math.atan2(b2, adash2); + + if (h1 < 0) { + h1 += 2 * π$1; + } + if (h2 < 0) { + h2 += 2 * π$1; + } + + h1 *= r2d; + h2 *= r2d; + + // Lightness and Chroma differences; sign matters + let ΔL = L2 - L1; + let ΔC = Cdash2 - Cdash1; + + // Hue difference, getting the sign correct + let hdiff = h2 - h1; + let hsum = h1 + h2; + let habs = Math.abs(hdiff); + let Δh; + + if (Cdash1 * Cdash2 === 0) { + Δh = 0; + } + else if (habs <= 180) { + Δh = hdiff; + } + else if (hdiff > 180) { + Δh = hdiff - 360; + } + else if (hdiff < -180) { + Δh = hdiff + 360; + } + else { + defaults.warn("the unthinkable has happened"); + } + + // weighted Hue difference, more for larger Chroma + let ΔH = 2 * Math.sqrt(Cdash2 * Cdash1) * Math.sin((Δh * d2r$1) / 2); + + // calculate mean Lightness and Chroma + let Ldash = (L1 + L2) / 2; + let Cdash = (Cdash1 + Cdash2) / 2; + let Cdash7 = pow7(Cdash); + + // Compensate for non-linearity in the blue region of Lab. + // Four possibilities for hue weighting factor, + // depending on the angles, to get the correct sign + let hdash; + if (Cdash1 * Cdash2 === 0) { + hdash = hsum; // which should be zero + } + else if (habs <= 180) { + hdash = hsum / 2; + } + else if (hsum < 360) { + hdash = (hsum + 360) / 2; + } + else { + hdash = (hsum - 360) / 2; + } + + // positional corrections to the lack of uniformity of CIELAB + // These are all trying to make JND ellipsoids more like spheres + + // SL Lightness crispening factor + // a background with L=50 is assumed + let lsq = (Ldash - 50) ** 2; + let SL = 1 + (0.015 * lsq) / Math.sqrt(20 + lsq); + + // SC Chroma factor, similar to those in CMC and deltaE 94 formulae + let SC = 1 + 0.045 * Cdash; + + // Cross term T for blue non-linearity + let T = 1; + T -= 0.17 * Math.cos((hdash - 30) * d2r$1); + T += 0.24 * Math.cos(2 * hdash * d2r$1); + T += 0.32 * Math.cos((3 * hdash + 6) * d2r$1); + T -= 0.2 * Math.cos((4 * hdash - 63) * d2r$1); + + // SH Hue factor depends on Chroma, + // as well as adjusted hue angle like deltaE94. + let SH = 1 + 0.015 * Cdash * T; + + // RT Hue rotation term compensates for rotation of JND ellipses + // and Munsell constant hue lines + // in the medium-high Chroma blue region + // (Hue 225 to 315) + let Δθ = 30 * Math.exp(-1 * ((hdash - 275) / 25) ** 2); + let RC = 2 * Math.sqrt(Cdash7 / (Cdash7 + Gfactor)); + let RT = -1 * Math.sin(2 * Δθ * d2r$1) * RC; + + // Finally calculate the deltaE, term by term as root sume of squares + let dE = (ΔL / (kL * SL)) ** 2; + dE += (ΔC / (kC * SC)) ** 2; + dE += (ΔH / (kH * SH)) ** 2; + dE += RT * (ΔC / (kC * SC)) * (ΔH / (kH * SH)); + return Math.sqrt(dE); + // Yay!!! + } + + /** @import { Matrix3x3 } from "../types.js" */ + + // Recalculated for consistent reference white + // see https://github.com/w3c/csswg-drafts/issues/6642#issuecomment-943521484 + /** @type {Matrix3x3} */ + // prettier-ignore + const XYZtoLMS_M$1 = [ + [ 0.8190224379967030, 0.3619062600528904, -0.1288737815209879 ], + [ 0.0329836539323885, 0.9292868615863434, 0.0361446663506424 ], + [ 0.0481771893596242, 0.2642395317527308, 0.6335478284694309 ], + ]; + // inverse of XYZtoLMS_M + /** @type {Matrix3x3} */ + // prettier-ignore + const LMStoXYZ_M$1 = [ + [ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647 ], + [ -0.0405757452148008, 1.1122868032803170, -0.0717110580655164 ], + [ -0.0763729366746601, -0.4214933324022432, 1.5869240198367816 ], + ]; + /** @type {Matrix3x3} */ + // prettier-ignore + const LMStoLab_M = [ + [ 0.2104542683093140, 0.7936177747023054, -0.0040720430116193 ], + [ 1.9779985324311684, -2.4285922420485799, 0.4505937096174110 ], + [ 0.0259040424655478, 0.7827717124575296, -0.8086757549230774 ], + ]; + // LMStoIab_M inverted + /** @type {Matrix3x3} */ + // prettier-ignore + const LabtoLMS_M = [ + [ 1.0000000000000000, 0.3963377773761749, 0.2158037573099136 ], + [ 1.0000000000000000, -0.1055613458156586, -0.0638541728258133 ], + [ 1.0000000000000000, -0.0894841775298119, -1.2914855480194092 ], + ]; + + var Oklab = new ColorSpace({ + id: "oklab", + name: "Oklab", + coords: { + l: { + refRange: [0, 1], + name: "Lightness", + }, + a: { + refRange: [-0.4, 0.4], + }, + b: { + refRange: [-0.4, 0.4], + }, + }, + + // Note that XYZ is relative to D65 + white: "D65", + base: xyz_d65, + fromBase (XYZ) { + // move to LMS cone domain + let LMS = multiply_v3_m3x3(XYZ, XYZtoLMS_M$1); + + // non-linearity + LMS[0] = Math.cbrt(LMS[0]); + LMS[1] = Math.cbrt(LMS[1]); + LMS[2] = Math.cbrt(LMS[2]); + + return multiply_v3_m3x3(LMS, LMStoLab_M, LMS); + }, + toBase (OKLab) { + // move to LMS cone domain + let LMSg = multiply_v3_m3x3(OKLab, LabtoLMS_M); + + // restore linearity + LMSg[0] = LMSg[0] ** 3; + LMSg[1] = LMSg[1] ** 3; + LMSg[2] = LMSg[2] ** 3; + + return multiply_v3_m3x3(LMSg, LMStoXYZ_M$1, LMSg); + }, + + formats: { + oklab: { + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + /** + * More accurate color-difference formulae + * than the simple 1976 Euclidean distance in CIE Lab + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaEOK (color, sample) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and a sample, + // calculate deltaEOK, term by term as root sum of squares + let [L1, a1, b1] = Oklab.from(color); + let [L2, a2, b2] = Oklab.from(sample); + let ΔL = L1 - L2; + let Δa = a1 - a2; + let Δb = b1 - b2; + return Math.sqrt(ΔL ** 2 + Δa ** 2 + Δb ** 2); + } + + /** @import { ColorTypes } from "./types.js" */ + + const ε$5 = 0.000075; + + /** + * Check if a color is in gamut of either its own or another color space + * @param {ColorTypes} color + * @param {string | ColorSpace} [space] + * @param {{ epsilon?: number | undefined }} [param2] + * @returns {boolean} + */ + function inGamut (color, space, { epsilon = ε$5 } = {}) { + color = getColor(color); + + if (!space) { + space = color.space; + } + + space = ColorSpace.get(space); + let coords = color.coords; + + if (space !== color.space) { + coords = space.from(color); + } + + return space.inGamut(coords, { epsilon }); + } + + /** @import { Coords, PlainColorObject } from "./types.js" */ + + /** + * @param {PlainColorObject} color + * @returns {PlainColorObject} + */ + function clone (color) { + return { + space: color.space, + coords: /** @type {Coords} */ (color.coords.slice()), + alpha: color.alpha, + }; + } + + /** @import { ColorTypes } from "./types.js" */ + + /** + * Euclidean distance of colors in an arbitrary color space + * @param {ColorTypes} color1 + * @param {ColorTypes} color2 + * @param {string | ColorSpace} space + * @returns {number} + */ + function distance (color1, color2, space = "lab") { + space = ColorSpace.get(space); + + // Assume getColor() is called on color in space.from() + let coords1 = space.from(color1); + let coords2 = space.from(color2); + + return Math.sqrt( + coords1.reduce((acc, c1, i) => { + let c2 = coords2[i]; + if (isNone(c1) || isNone(c2)) { + return acc; + } + + return acc + (c2 - c1) ** 2; + }, 0), + ); + } + + /** + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaE76 (color, sample) { + // Assume getColor() is called in the distance function + return distance(color, sample, "lab"); + } + + // More accurate color-difference formulae + // than the simple 1976 Euclidean distance in Lab + + // CMC by the Color Measurement Committee of the + // Bradford Society of Dyeists and Colorsts, 1994. + // Uses LCH rather than Lab, + // with different weights for L, C and H differences + // A nice increase in accuracy for modest increase in complexity + const π = Math.PI; + const d2r = π / 180; + + /** + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @param {{ l?: number | undefined; c?: number | undefined }} options + * @returns {number} + */ + function deltaECMC (color, sample, { l = 2, c = 1 } = {}) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and a sample, + // calculate deltaE CMC. + + // This implementation assumes the parametric + // weighting factors l:c are 2:1 + // which is typical for non-textile uses. + + let [L1, a1, b1] = lab.from(color); + let [, C1, H1] = lch.from(lab, [L1, a1, b1]); + let [L2, a2, b2] = lab.from(sample); + let C2 = lch.from(lab, [L2, a2, b2])[1]; + + // let [L1, a1, b1] = color.getAll(lab); + // let C1 = color.get("lch.c"); + // let H1 = color.get("lch.h"); + // let [L2, a2, b2] = sample.getAll(lab); + // let C2 = sample.get("lch.c"); + + // Check for negative Chroma, + // which might happen through + // direct user input of LCH values + + if (C1 < 0) { + C1 = 0; + } + if (C2 < 0) { + C2 = 0; + } + + // we don't need H2 as ΔH is calculated from Δa, Δb and ΔC + + // Lightness and Chroma differences + // These are (color - sample), unlike deltaE2000 + let ΔL = L1 - L2; + let ΔC = C1 - C2; + + let Δa = a1 - a2; + let Δb = b1 - b2; + + // weighted Hue difference, less for larger Chroma difference + + let H2 = Δa ** 2 + Δb ** 2 - ΔC ** 2; + // due to roundoff error it is possible that, for zero a and b, + // ΔC > Δa + Δb is 0, resulting in attempting + // to take the square root of a negative number + + // trying instead the equation from Industrial Color Physics + // By Georg A. Klein + + // let ΔH = ((a1 * b2) - (a2 * b1)) / Math.sqrt(0.5 * ((C2 * C1) + (a2 * a1) + (b2 * b1))); + // console.log({ΔH}); + // This gives the same result to 12 decimal places + // except it sometimes NaNs when trying to root a negative number + + // let ΔH = Math.sqrt(H2); we never actually use the root, it gets squared again!! + + // positional corrections to the lack of uniformity of CIELAB + // These are all trying to make JND ellipsoids more like spheres + + // SL Lightness crispening factor, depends entirely on L1 not L2 + let SL = 0.511; // linear portion of the Y to L transfer function + if (L1 >= 16) { + // cubic portion + SL = (0.040975 * L1) / (1 + 0.01765 * L1); + } + + // SC Chroma factor + let SC = (0.0638 * C1) / (1 + 0.0131 * C1) + 0.638; + + // Cross term T for blue non-linearity + let T; + if (isNone(H1)) { + H1 = 0; + } + + if (H1 >= 164 && H1 <= 345) { + T = 0.56 + Math.abs(0.2 * Math.cos((H1 + 168) * d2r)); + } + else { + T = 0.36 + Math.abs(0.4 * Math.cos((H1 + 35) * d2r)); + } + // console.log({T}); + + // SH Hue factor also depends on C1, + let C4 = Math.pow(C1, 4); + let F = Math.sqrt(C4 / (C4 + 1900)); + let SH = SC * (F * T + 1 - F); + + // Finally calculate the deltaE, term by term as root sume of squares + let dE = (ΔL / (l * SL)) ** 2; + dE += (ΔC / (c * SC)) ** 2; + dE += H2 / SH ** 2; + // dE += (ΔH / SH) ** 2; + return Math.sqrt(dE); + // Yay!!! + } + + const Yw$1 = 203; // absolute luminance of media white + + var XYZ_Abs_D65 = new ColorSpace({ + // Absolute CIE XYZ, with a D65 whitepoint, + // as used in most HDR colorspaces as a starting point. + // SDR spaces are converted per BT.2048 + // so that diffuse, media white is 203 cd/m² + id: "xyz-abs-d65", + cssId: "--xyz-abs-d65", + name: "Absolute XYZ D65", + coords: { + x: { + refRange: [0, 9504.7], + name: "Xa", + }, + y: { + refRange: [0, 10000], + name: "Ya", + }, + z: { + refRange: [0, 10888.3], + name: "Za", + }, + }, + + base: xyz_d65, + fromBase (XYZ) { + // Make XYZ absolute, not relative to media white + // Maximum luminance in PQ is 10,000 cd/m² + // Relative XYZ has Y=1 for media white + return XYZ.map(v => v * Yw$1); + }, + toBase (AbsXYZ) { + // Convert to media-white relative XYZ + return AbsXYZ.map(v => v / Yw$1); + }, + }); + + /** @import { Matrix3x3, Vector3 } from "../types.js" */ + + const b$1 = 1.15; + const g = 0.66; + const n$1 = 2610 / 2 ** 14; + const ninv$1 = 2 ** 14 / 2610; + const c1$2 = 3424 / 2 ** 12; + const c2$2 = 2413 / 2 ** 7; + const c3$2 = 2392 / 2 ** 7; + const p = (1.7 * 2523) / 2 ** 5; + const pinv = 2 ** 5 / (1.7 * 2523); + const d = -0.56; + const d0 = 1.6295499532821566e-11; + + /** @type {Matrix3x3} */ + // prettier-ignore + const XYZtoCone_M = [ + [ 0.41478972, 0.579999, 0.0146480 ], + [ -0.2015100, 1.120649, 0.0531008 ], + [ -0.0166008, 0.264800, 0.6684799 ], + ]; + // XYZtoCone_M inverted + /** @type {Matrix3x3} */ + // prettier-ignore + const ConetoXYZ_M = [ + [ 1.9242264357876067, -1.0047923125953657, 0.037651404030618 ], + [ 0.35031676209499907, 0.7264811939316552, -0.06538442294808501 ], + [ -0.09098281098284752, -0.3127282905230739, 1.5227665613052603 ], + ]; + /** @type {Matrix3x3} */ + // prettier-ignore + const ConetoIab_M = [ + [ 0.5, 0.5, 0 ], + [ 3.524000, -4.066708, 0.542708 ], + [ 0.199076, 1.096799, -1.295875 ], + ]; + // ConetoIab_M inverted + /** @type {Matrix3x3} */ + // prettier-ignore + const IabtoCone_M = [ + [ 1, 0.13860504327153927, 0.05804731615611883 ], + [ 1, -0.1386050432715393, -0.058047316156118904 ], + [ 1, -0.09601924202631895, -0.81189189605603900 ], + ]; + + var Jzazbz = new ColorSpace({ + id: "jzazbz", + name: "Jzazbz", + coords: { + jz: { + refRange: [0, 1], + name: "Jz", + }, + az: { + refRange: [-0.21, 0.21], + }, + bz: { + refRange: [-0.21, 0.21], + }, + }, + + base: XYZ_Abs_D65, + fromBase (XYZ) { + // First make XYZ absolute, not relative to media white + // Maximum luminance in PQ is 10,000 cd/m² + // Relative XYZ has Y=1 for media white + // BT.2048 says media white Y=203 at PQ 58 + + let [Xa, Ya, Za] = XYZ; + + // modify X and Y to minimize blue curvature + let Xm = b$1 * Xa - (b$1 - 1) * Za; + let Ym = g * Ya - (g - 1) * Xa; + + // move to LMS cone domain + let LMS = multiply_v3_m3x3([Xm, Ym, Za], XYZtoCone_M); + + // PQ-encode LMS + let PQLMS = /** @type {Vector3} } */ ( + LMS.map(function (val) { + let num = c1$2 + c2$2 * spow(val / 10000, n$1); + let denom = 1 + c3$2 * spow(val / 10000, n$1); + + return spow(num / denom, p); + }) + ); + + // almost there, calculate Iz az bz + let [Iz, az, bz] = multiply_v3_m3x3(PQLMS, ConetoIab_M); + // console.log({Iz, az, bz}); + + let Jz = ((1 + d) * Iz) / (1 + d * Iz) - d0; + return [Jz, az, bz]; + }, + toBase (Jzazbz) { + let [Jz, az, bz] = Jzazbz; + let Iz = (Jz + d0) / (1 + d - d * (Jz + d0)); + + // bring into LMS cone domain + let PQLMS = multiply_v3_m3x3([Iz, az, bz], IabtoCone_M); + + // convert from PQ-coded to linear-light + let LMS = /** @type {Vector3} } */ ( + PQLMS.map(function (val) { + let num = c1$2 - spow(val, pinv); + let denom = c3$2 * spow(val, pinv) - c2$2; + let x = 10000 * spow(num / denom, ninv$1); + + return x; // luminance relative to diffuse white, [0, 70 or so]. + }) + ); + + // modified abs XYZ + let [Xm, Ym, Za] = multiply_v3_m3x3(LMS, ConetoXYZ_M); + + // un-modify X and Y to get D65 XYZ, relative to media white + let Xa = (Xm + (b$1 - 1) * Za) / b$1; + let Ya = (Ym + (g - 1) * Xa) / g; + return [Xa, Ya, Za]; + }, + + formats: { + // https://drafts.csswg.org/css-color-hdr/#Jzazbz + jzazbz: { + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + var jzczhz = new ColorSpace({ + id: "jzczhz", + name: "JzCzHz", + coords: { + jz: { + refRange: [0, 1], + name: "Jz", + }, + cz: { + refRange: [0, 0.26], + name: "Chroma", + }, + hz: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + + base: Jzazbz, + fromBase: lch.fromBase, + toBase: lch.toBase, + + formats: { + // https://drafts.csswg.org/css-color-hdr/#JzCzhz + jzczhz: { + coords: [" | ", " | ", " | "], + }, + }, + }); + + /** + * More accurate color-difference formulae + * than the simple 1976 Euclidean distance in Lab + * + * Uses JzCzHz, which has improved perceptual uniformity + * and thus a simple Euclidean root-sum of ΔL² ΔC² ΔH² + * gives good results. + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaEJz (color, sample) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and a sample, + // calculate deltaE in JzCzHz. + let [Jz1, Cz1, Hz1] = jzczhz.from(color); + let [Jz2, Cz2, Hz2] = jzczhz.from(sample); + + // Lightness and Chroma differences + // sign does not matter as they are squared. + let ΔJ = Jz1 - Jz2; + let ΔC = Cz1 - Cz2; + + // length of chord for ΔH + if (isNone(Hz1) && isNone(Hz2)) { + // both undefined hues + Hz1 = 0; + Hz2 = 0; + } + else if (isNone(Hz1)) { + // one undefined, set to the defined hue + Hz1 = Hz2; + } + else if (isNone(Hz2)) { + Hz2 = Hz1; + } + + let Δh = Hz1 - Hz2; + let ΔH = 2 * Math.sqrt(Cz1 * Cz2) * Math.sin((Δh / 2) * (Math.PI / 180)); + + return Math.sqrt(ΔJ ** 2 + ΔC ** 2 + ΔH ** 2); + } + + /** @import { Matrix3x3, Vector3 } from "../types.js" */ + + const c1$1 = 3424 / 4096; + const c2$1 = 2413 / 128; + const c3$1 = 2392 / 128; + const m1$1 = 2610 / 16384; + const m2 = 2523 / 32; + const im1 = 16384 / 2610; + const im2 = 32 / 2523; + + // The matrix below includes the 4% crosstalk components + // and is from the Dolby "What is ICtCp" paper" + /** @type {Matrix3x3} */ + // prettier-ignore + const XYZtoLMS_M = [ + [ 0.3592832590121217, 0.6976051147779502, -0.0358915932320290 ], + [ -0.1920808463704993, 1.1004767970374321, 0.0753748658519118 ], + [ 0.0070797844607479, 0.0748396662186362, 0.8433265453898765 ], + ]; + // linear-light Rec.2020 to LMS, again with crosstalk + // rational terms from Jan Fröhlich, + // Encoding High Dynamic Range andWide Color Gamut Imagery, p.97 + // and ITU-R BT.2124-0 p.2 + /* + const Rec2020toLMS_M = [ + [ 1688 / 4096, 2146 / 4096, 262 / 4096 ], + [ 683 / 4096, 2951 / 4096, 462 / 4096 ], + [ 99 / 4096, 309 / 4096, 3688 / 4096 ] + ]; + */ + // this includes the Ebner LMS coefficients, + // the rotation, and the scaling to [-0.5,0.5] range + // rational terms from Fröhlich p.97 + // and ITU-R BT.2124-0 pp.2-3 + /** @type {Matrix3x3} */ + // prettier-ignore + const LMStoIPT_M = [ + [ 2048 / 4096, 2048 / 4096, 0 ], + [ 6610 / 4096, -13613 / 4096, 7003 / 4096 ], + [ 17933 / 4096, -17390 / 4096, -543 / 4096 ], + ]; + + // inverted matrices, calculated from the above + /** @type {Matrix3x3} */ + // prettier-ignore + const IPTtoLMS_M = [ + [ 0.9999999999999998, 0.0086090370379328, 0.1110296250030260 ], + [ 0.9999999999999998, -0.0086090370379328, -0.1110296250030259 ], + [ 0.9999999999999998, 0.5600313357106791, -0.3206271749873188 ], + ]; + /* + // prettier-ignore + const LMStoRec2020_M = [ + [ 3.4375568932814012112, -2.5072112125095058195, 0.069654319228104608382], + [-0.79142868665644156125, 1.9838372198740089874, -0.19240853321756742626 ], + [-0.025646662911506476363, -0.099240248643945566751, 1.1248869115554520431 ] + ]; + */ + /** @type {Matrix3x3} */ + // prettier-ignore + const LMStoXYZ_M = [ + [ 2.0701522183894223, -1.3263473389671563, 0.2066510476294053 ], + [ 0.3647385209748072, 0.6805660249472273, -0.0453045459220347 ], + [ -0.0497472075358123, -0.0492609666966131, 1.1880659249923042 ], + ]; + + // Only the PQ form of ICtCp is implemented here. There is also an HLG form. + // from Dolby, "WHAT IS ICTCP?" + // https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf + // and + // Dolby, "Perceptual Color Volume + // Measuring the Distinguishable Colors of HDR and WCG Displays" + // https://professional.dolby.com/siteassets/pdfs/dolby-vision-measuring-perceptual-color-volume-v7.1.pdf + var ictcp = new ColorSpace({ + id: "ictcp", + name: "ICTCP", + // From BT.2100-2 page 7: + // During production, signal values are expected to exceed the + // range E′ = [0.0 : 1.0]. This provides processing headroom and avoids + // signal degradation during cascaded processing. Such values of E′, + // below 0.0 or exceeding 1.0, should not be clipped during production + // and exchange. + // Values below 0.0 should not be clipped in reference displays (even + // though they represent “negative” light) to allow the black level of + // the signal (LB) to be properly set using test signals known as “PLUGE” + coords: { + i: { + refRange: [0, 1], // Constant luminance, + name: "I", + }, + ct: { + refRange: [-0.5, 0.5], // Full BT.2020 gamut in range [-0.5, 0.5] + name: "CT", + }, + cp: { + refRange: [-0.5, 0.5], + name: "CP", + }, + }, + + base: XYZ_Abs_D65, + fromBase (XYZ) { + // move to LMS cone domain + let LMS = multiply_v3_m3x3(XYZ, XYZtoLMS_M); + + return LMStoICtCp(LMS); + }, + toBase (ICtCp) { + let LMS = ICtCptoLMS(ICtCp); + + return multiply_v3_m3x3(LMS, LMStoXYZ_M); + }, + + formats: { + ictcp: { + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + /** + * + * @param {Vector3} LMS + * @returns {Vector3} + */ + function LMStoICtCp (LMS) { + // apply the PQ EOTF + // we can't ever be dividing by zero because of the "1 +" in the denominator + let PQLMS = /** @type {Vector3} */ ( + LMS.map(function (val) { + let num = c1$1 + c2$1 * (val / 10000) ** m1$1; + let denom = 1 + c3$1 * (val / 10000) ** m1$1; + + return (num / denom) ** m2; + }) + ); + + // LMS to IPT, with rotation for Y'C'bC'r compatibility + return multiply_v3_m3x3(PQLMS, LMStoIPT_M); + } + + /** + * + * @param {Vector3} ICtCp + * @returns {Vector3} + */ + function ICtCptoLMS (ICtCp) { + let PQLMS = multiply_v3_m3x3(ICtCp, IPTtoLMS_M); + + // From BT.2124-0 Annex 2 Conversion 3 + let LMS = /** @type {Vector3} */ ( + PQLMS.map(function (val) { + let num = Math.max(val ** im2 - c1$1, 0); + let denom = c2$1 - c3$1 * val ** im2; + return 10000 * (num / denom) ** im1; + }) + ); + + return LMS; + } + + /** + * Delta E in ICtCp space, + * which the ITU calls Delta E ITP, which is shorter. + * Formulae from ITU Rec. ITU-R BT.2124-0 + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaEITP (color, sample) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and a sample, + // calculate deltaE in ICtCp + // which is simply the Euclidean distance + + let [I1, T1, P1] = ictcp.from(color); + let [I2, T2, P2] = ictcp.from(sample); + + // the 0.25 factor is to undo the encoding scaling in Ct + // the 720 is so that 1 deltaE = 1 JND + // per ITU-R BT.2124-0 p.3 + + return 720 * Math.sqrt((I1 - I2) ** 2 + 0.25 * (T1 - T2) ** 2 + (P1 - P2) ** 2); + } + + /** + * More accurate color-difference formulae + * than the simple 1976 Euclidean distance in CIE Lab + * The Oklab a and b axes are scaled relative to the L axis, for better uniformity + * Björn Ottosson said: + * "I've recently done some tests with color distance datasets as implemented + * in Colorio and on both the Combvd dataset and the OSA-UCS dataset a + * scale factor of slightly more than 2 for a and b would give the best results + * (2.016 works best for Combvd and 2.045 for the OSA-UCS dataset)." + * @see {@link } + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaEOK2 (color, sample) { + [color, sample] = getColor([color, sample]); + + // Given this color as the reference + // and a sample, + // calculate deltaEOK2, term by term as root sum of squares + let abscale = 2; + let [L1, a1, b1] = Oklab.from(color); + let [L2, a2, b2] = Oklab.from(sample); + let ΔL = L1 - L2; + let Δa = abscale * (a1 - a2); + let Δb = abscale * (b1 - b2); + return Math.sqrt(ΔL ** 2 + Δa ** 2 + Δb ** 2); + } + + /** @import { Coords, Matrix3x3, Vector3 } from "../types.js" */ + + // Type re-exports + /** @typedef {import("../types.js").Cam16Object} Cam16Object */ + /** @typedef {import("../types.js").Cam16Input} Cam16Input */ + /** @typedef {import("../types.js").Cam16Environment} Cam16Environment */ + + const white$3 = WHITES.D65; + const adaptedCoef = 0.42; + const adaptedCoefInv = 1 / adaptedCoef; + const tau$1 = 2 * Math.PI; + + /** @type {Matrix3x3} */ + // prettier-ignore + const cat16 = [ + [ 0.401288, 0.650173, -0.051461 ], + [ -0.250268, 1.204414, 0.045854 ], + [ -0.002079, 0.048952, 0.953127 ], + ]; + + /** @type {Matrix3x3} */ + const cat16Inv = [ + [1.8620678550872327, -1.0112546305316843, 0.14918677544445175], + [0.38752654323613717, 0.6214474419314753, -0.008973985167612518], + [-0.015841498849333856, -0.03412293802851557, 1.0499644368778496], + ]; + + /** @type {Matrix3x3} */ + const m1 = [ + [460.0, 451.0, 288.0], + [460.0, -891.0, -261.0], + [460.0, -220.0, -6300.0], + ]; + + const surroundMap = { + dark: [0.8, 0.525, 0.8], + dim: [0.9, 0.59, 0.9], + average: [1, 0.69, 1], + }; + + const hueQuadMap = { + // Red, Yellow, Green, Blue, Red + h: [20.14, 90.0, 164.25, 237.53, 380.14], + e: [0.8, 0.7, 1.0, 1.2, 0.8], + H: [0.0, 100.0, 200.0, 300.0, 400.0], + }; + + const rad2deg = 180 / Math.PI; + const deg2rad$1 = Math.PI / 180; + + /** + * @param {Coords} coords + * @param {number} fl + * @returns {[number, number, number]} + */ + function adapt$1 (coords, fl) { + const temp = /** @type {[number, number, number]} */ ( + coords.map(c => { + const x = spow(fl * Math.abs(c) * 0.01, adaptedCoef); + return (400 * copySign(x, c)) / (x + 27.13); + }) + ); + return temp; + } + + /** + * @param {Coords} adapted + * @param {number} fl + * @returns {[number, number, number]} + */ + function unadapt (adapted, fl) { + const constant = (100 / fl) * 27.13 ** adaptedCoefInv; + return /** @type {[number, number, number]} */ ( + adapted.map(c => { + const cabs = Math.abs(c); + return copySign(constant * spow(cabs / (400 - cabs), adaptedCoefInv), c); + }) + ); + } + + /** + * @param {number} h + */ + function hueQuadrature (h) { + let hp = constrain(h); + if (hp <= hueQuadMap.h[0]) { + hp += 360; + } + + const i = bisectLeft(hueQuadMap.h, hp) - 1; + const [hi, hii] = hueQuadMap.h.slice(i, i + 2); + const [ei, eii] = hueQuadMap.e.slice(i, i + 2); + const Hi = hueQuadMap.H[i]; + + const t = (hp - hi) / ei; + return Hi + (100 * t) / (t + (hii - hp) / eii); + } + + /** + * @param {number} H + */ + function invHueQuadrature (H) { + let Hp = ((H % 400) + 400) % 400; + const i = Math.floor(0.01 * Hp); + Hp = Hp % 100; + const [hi, hii] = hueQuadMap.h.slice(i, i + 2); + const [ei, eii] = hueQuadMap.e.slice(i, i + 2); + + return constrain((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii)); + } + + /** + * @param {[number, number, number]} refWhite + * @param {number} adaptingLuminance + * @param {number} backgroundLuminance + * @param {keyof typeof surroundMap} surround + * @param {boolean} discounting + * @returns {Cam16Environment} + */ + function environment ( + refWhite, + adaptingLuminance, + backgroundLuminance, + surround, + discounting, + ) { + const env = {}; + + env.discounting = discounting; + env.refWhite = refWhite; + env.surround = surround; + const xyzW = /** @type {Vector3} */ ( + refWhite.map(c => { + return c * 100; + }) + ); + + // The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits) + env.la = adaptingLuminance; + // The relative luminance of the nearby background + env.yb = backgroundLuminance; + // Absolute luminance of the reference white. + const yw = xyzW[1]; + + // Cone response for reference white + const rgbW = multiply_v3_m3x3(xyzW, cat16); + + // Surround: dark, dim, and average + let values = surroundMap[env.surround]; + const f = values[0]; + env.c = values[1]; + env.nc = values[2]; + + const k = 1 / (5 * env.la + 1); + const k4 = k ** 4; + + // Factor of luminance level adaptation + env.fl = k4 * env.la + 0.1 * (1 - k4) * (1 - k4) * Math.cbrt(5 * env.la); + env.flRoot = env.fl ** 0.25; + + env.n = env.yb / yw; + env.z = 1.48 + Math.sqrt(env.n); + env.nbb = 0.725 * env.n ** -0.2; + env.ncb = env.nbb; + + // Degree of adaptation calculating if not discounting + // illuminant (assumed eye is fully adapted) + const d = Math.max(Math.min(f * (1 - (1 / 3.6) * Math.exp((-env.la - 42) / 92)), 1), 0); + env.dRgb = /** @type {[number, number, number]} */ ( + rgbW.map(c => { + return interpolate(1, yw / c, d); + }) + ); + env.dRgbInv = /** @type {[number, number, number]} */ ( + env.dRgb.map(c => { + return 1 / c; + }) + ); + + // Achromatic response + const rgbCW = /** @type {[number, number, number]} */ ( + rgbW.map((c, i) => { + return c * env.dRgb[i]; + }) + ); + const rgbAW = adapt$1(rgbCW, env.fl); + env.aW = env.nbb * (2 * rgbAW[0] + rgbAW[1] + 0.05 * rgbAW[2]); + + // console.log(env); + + return env; + } + + // Pre-calculate everything we can with the viewing conditions + const viewingConditions$1 = environment(white$3, (64 / Math.PI) * 0.2, 20, "average", false); + + /** + * @param {Cam16Input} cam16 + * @param {Cam16Environment} env + * @returns {[number, number, number]} + */ + function fromCam16 (cam16, env) { + // These check ensure one, and only one attribute for a + // given category is provided. + // @ts-expect-error The '^` operator is not allowed for boolean types + if (!((cam16.J !== undefined) ^ (cam16.Q !== undefined))) { + throw new Error("Conversion requires one and only one: 'J' or 'Q'"); + } + + // @ts-expect-error - The '^` operator is not allowed for boolean types + if (!((cam16.C !== undefined) ^ (cam16.M !== undefined) ^ (cam16.s !== undefined))) { + throw new Error("Conversion requires one and only one: 'C', 'M' or 's'"); + } + + // Hue is absolutely required + // @ts-expect-error - The '^` operator is not allowed for boolean types + if (!((cam16.h !== undefined) ^ (cam16.H !== undefined))) { + throw new Error("Conversion requires one and only one: 'h' or 'H'"); + } + + // Black + if (cam16.J === 0.0 || cam16.Q === 0.0) { + return [0.0, 0.0, 0.0]; + } + + // Break hue into Cartesian components + let hRad = 0.0; + if (cam16.h !== undefined) { + hRad = constrain(cam16.h) * deg2rad$1; + } + else { + hRad = invHueQuadrature(cam16.H) * deg2rad$1; + } + + const cosh = Math.cos(hRad); + const sinh = Math.sin(hRad); + + // Calculate `Jroot` from one of the lightness derived coordinates. + let Jroot = 0.0; + if (cam16.J !== undefined) { + Jroot = spow(cam16.J, 1 / 2) * 0.1; + } + else if (cam16.Q !== undefined) { + Jroot = (0.25 * env.c * cam16.Q) / ((env.aW + 4) * env.flRoot); + } + + // Calculate the `t` value from one of the chroma derived coordinates + let alpha = 0.0; + if (cam16.C !== undefined) { + alpha = cam16.C / Jroot; + } + else if (cam16.M !== undefined) { + alpha = cam16.M / env.flRoot / Jroot; + } + else if (cam16.s !== undefined) { + alpha = (0.0004 * cam16.s ** 2 * (env.aW + 4)) / env.c; + } + const t = spow(alpha * Math.pow(1.64 - Math.pow(0.29, env.n), -0.73), 10 / 9); + + // Eccentricity + const et = 0.25 * (Math.cos(hRad + 2) + 3.8); + + // Achromatic response + const A = env.aW * spow(Jroot, 2 / env.c / env.z); + + // Calculate red-green and yellow-blue components + const p1 = (5e4 / 13) * env.nc * env.ncb * et; + const p2 = A / env.nbb; + const r = 23 * (p2 + 0.305) * zdiv(t, 23 * p1 + t * (11 * cosh + 108 * sinh)); + const a = r * cosh; + const b = r * sinh; + + // Calculate back from cone response to XYZ + const rgb_c = unadapt( + /** @type {Vector3} */ + ( + multiply_v3_m3x3([p2, a, b], m1).map(c => { + return (c * 1) / 1403; + }) + ), + env.fl, + ); + return /** @type {Vector3} */ ( + multiply_v3_m3x3( + /** @type {Vector3} */ ( + rgb_c.map((c, i) => { + return c * env.dRgbInv[i]; + }) + ), + cat16Inv, + ).map(c => { + return c / 100; + }) + ); + } + + /** + * @param {[number, number, number]} xyzd65 + * @param {Cam16Environment} env + * @returns {Cam16Object} + */ + function toCam16 (xyzd65, env) { + // Cone response + const xyz100 = /** @type {Vector3} */ ( + xyzd65.map(c => { + return c * 100; + }) + ); + const rgbA = adapt$1( + /** @type {[number, number, number]} */ + ( + multiply_v3_m3x3(xyz100, cat16).map((c, i) => { + return c * env.dRgb[i]; + }) + ), + env.fl, + ); + + // Calculate hue from red-green and yellow-blue components + const a = rgbA[0] + (-12 * rgbA[1] + rgbA[2]) / 11; + const b = (rgbA[0] + rgbA[1] - 2 * rgbA[2]) / 9; + const hRad = ((Math.atan2(b, a) % tau$1) + tau$1) % tau$1; + + // Eccentricity + const et = 0.25 * (Math.cos(hRad + 2) + 3.8); + + const t = + (5e4 / 13) * + env.nc * + env.ncb * + zdiv(et * Math.sqrt(a ** 2 + b ** 2), rgbA[0] + rgbA[1] + 1.05 * rgbA[2] + 0.305); + const alpha = spow(t, 0.9) * Math.pow(1.64 - Math.pow(0.29, env.n), 0.73); + + // Achromatic response + const A = env.nbb * (2 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]); + + const Jroot = spow(A / env.aW, 0.5 * env.c * env.z); + + // Lightness + const J = 100 * spow(Jroot, 2); + + // Brightness + const Q = (4 / env.c) * Jroot * (env.aW + 4) * env.flRoot; + + // Chroma + const C = alpha * Jroot; + + // Colorfulness + const M = C * env.flRoot; + + // Hue + const h = constrain(hRad * rad2deg); + + // Hue quadrature + const H = hueQuadrature(h); + + // Saturation + const s = 50 * spow((env.c * alpha) / (env.aW + 4), 1 / 2); + + // console.log({J: J, C: C, h: h, s: s, Q: Q, M: M, H: H}); + + return { J: J, C: C, h: h, s: s, Q: Q, M: M, H: H }; + } + + // Provided as a way to directly evaluate the CAM16 model + // https://observablehq.com/@jrus/cam16: reference implementation + // https://arxiv.org/pdf/1802.06067.pdf: Nico Schlömer + // https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324: hue quadrature + // https://www.researchgate.net/publication/318152296_Comprehensive_color_solutions_CAM16_CAT16_and_CAM16-UCS + // Results compared against: https://github.com/colour-science/colour + var cam16 = new ColorSpace({ + id: "cam16-jmh", + cssId: "--cam16-jmh", + name: "CAM16-JMh", + coords: { + j: { + refRange: [0, 100], + name: "J", + }, + m: { + refRange: [0, 105.0], + name: "Colorfulness", + }, + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + + base: xyz_d65, + + fromBase (xyz) { + // If another derivation is created, ε could vary, so we can't hardcode + if (this.ε === undefined) { + this.ε = Object.values(this.coords)[1].refRange[1] / 100000; + } + const cam16 = toCam16(xyz, viewingConditions$1); + const isAchromatic = Math.abs(cam16.M) < this.ε; + return [cam16.J, isAchromatic ? 0 : cam16.M, isAchromatic ? null : cam16.h]; + }, + toBase (cam16) { + return fromCam16({ J: cam16[0], M: cam16[1], h: cam16[2] }, viewingConditions$1); + }, + }); + + const white$2 = WHITES.D65; + const ε$4 = 216 / 24389; // 6^3/29^3 == (24/116)^3 + const κ$3 = 24389 / 27; // 29^3/3^3 + + function toLstar (y) { + // Convert XYZ Y to L* + + const fy = y > ε$4 ? Math.cbrt(y) : (κ$3 * y + 16) / 116; + return 116.0 * fy - 16.0; + } + + function fromLstar (lstar) { + // Convert L* back to XYZ Y + + return lstar > 8 ? Math.pow((lstar + 16) / 116, 3) : lstar / κ$3; + } + + function fromHct (coords, env) { + // Use Newton's method to try and converge as quick as possible or + // converge as close as we can. While the requested precision is achieved + // most of the time, it may not always be achievable. Especially past the + // visible spectrum, the algorithm will likely struggle to get the same + // precision. If, for whatever reason, we cannot achieve the accuracy we + // seek in the allotted iterations, just return the closest we were able to + // get. + + let [h, c, t] = coords; + let xyz = []; + let j = 0; + + // Shortcut out for black + if (t === 0) { + return [0.0, 0.0, 0.0]; + } + + // Calculate the Y we need to target + let y = fromLstar(t); + + // A better initial guess yields better results. Polynomials come from + // curve fitting the T vs J response. + if (t > 0) { + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; + } + else { + j = 9.514440756550361e-6 * t ** 2 + 0.08693057439788597 * t - 21.928975842194614; + } + + // Threshold of how close is close enough, and max number of attempts. + // More precision and more attempts means more time spent iterating. Higher + // required precision gives more accuracy but also increases the chance of + // not hitting the goal. 2e-12 allows us to convert round trip with + // reasonable accuracy of six decimal places or more. + const threshold = 2e-12; + const max_attempts = 15; + + let attempt = 0; + let last = Infinity; + + // Try to find a J such that the returned y matches the returned y of the L* + while (attempt <= max_attempts) { + xyz = fromCam16({ J: j, C: c, h: h }, env); + + // If we are within range, return XYZ + // If we are closer than last time, save the values + const delta = Math.abs(xyz[1] - y); + if (delta < last) { + if (delta <= threshold) { + return xyz; + } + last = delta; + } + + // f(j_root) = (j ** (1 / 2)) * 0.1 + // f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + // f(j_root) = Y = y / 100 + // f(j) = (y ** 2) / j - 1 + // f'(j) = (2 * y) / j + j = j - ((xyz[1] - y) * j) / (2 * xyz[1]); + + attempt += 1; + } + + // We could not acquire the precision we desired, + // return our closest attempt. + return fromCam16({ J: j, C: c, h: h }, env); + } + + function toHct (xyz, env) { + // Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue. + + const t = toLstar(xyz[1]); + if (t === 0.0) { + return [0.0, 0.0, 0.0]; + } + const cam16 = toCam16(xyz, viewingConditions); + return [constrain(cam16.h), cam16.C, t]; + } + + // Pre-calculate everything we can with the viewing conditions + const viewingConditions = environment( + white$2, + (200 / Math.PI) * fromLstar(50.0), + fromLstar(50.0) * 100, + "average", + false, + ); + + // https://material.io/blog/science-of-color-design + // This is not a port of the material-color-utilities, + // but instead implements the full color space as described, + // combining CAM16 JCh and Lab D65. This does not clamp conversion + // to HCT to specific chroma bands and provides support for wider + // gamuts than Google currently supports and does so at a greater + // precision (> 8 bits back to sRGB). + // This implementation comes from https://github.com/facelessuser/coloraide + // which is licensed under MIT. + var hct = new ColorSpace({ + id: "hct", + name: "HCT", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + c: { + refRange: [0, 145], + name: "Colorfulness", + }, + t: { + refRange: [0, 100], + name: "Tone", + }, + }, + + base: xyz_d65, + + fromBase (xyz) { + if (this.ε === undefined) { + this.ε = Object.values(this.coords)[1].refRange[1] / 100000; + } + let hct = toHct(xyz); + if (hct[1] < this.ε) { + hct[1] = 0.0; + hct[0] = null; + } + return hct; + }, + toBase (hct) { + return fromHct(hct, viewingConditions); + }, + formats: { + color: { + id: "--hct", + coords: [" | ", " | ", " | "], + }, + }, + }); + + const deg2rad = Math.PI / 180; + const ucsCoeff = [1.0, 0.007, 0.0228]; + + /** + * Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) using UCS logic for a and b. + * @param {Coords} coords - HCT coordinates. + * @return {number[]} + */ + function convertUcsAb (coords) { + // We want the distance between the actual color. + // If chroma is negative, it will throw off our calculations. + // Normally, converting back to the base and forward will correct it. + // If we have a negative chroma after this, then we have a color that + // cannot resolve to positive chroma. + if (coords[1] < 0) { + coords = hct.fromBase(hct.toBase(coords)); + } + + // Only in extreme cases (usually outside the visible spectrum) + // can the input value for log become negative. + // Avoid domain error by forcing a zero result via "max" if necessary. + const M = + Math.log(Math.max(1 + ucsCoeff[2] * coords[1] * viewingConditions.flRoot, 1.0)) / + ucsCoeff[2]; + const hrad = coords[0] * deg2rad; + const a = M * Math.cos(hrad); + const b = M * Math.sin(hrad); + + return [coords[2], a, b]; + } + + /** + * Color distance using HCT. + * @param {import("../types.js").ColorTypes} color + * @param {import("../types.js").ColorTypes} sample + * @returns {number} + */ + function deltaEHCT (color, sample) { + [color, sample] = getColor([color, sample]); + + let [t1, a1, b1] = convertUcsAb(hct.from(color)); + let [t2, a2, b2] = convertUcsAb(hct.from(sample)); + + // Use simple euclidean distance with a and b using UCS conversion + // and LCh lightness (HCT tone). + return Math.sqrt((t1 - t2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2); + } + + /** + * @packageDocumentation + * This module defines all the builtin deltaE methods. + */ + + var deltaEMethods = { + deltaE76, + deltaECMC, + deltaE2000, + deltaEJz, + deltaEITP, + deltaEOK, + deltaEOK2, + deltaEHCT, + }; + + /** @typedef {keyof typeof import("./index.js").default extends `deltaE${infer Method}` ? Method : string} Methods */ + + /** @import { ColorTypes, PlainColorObject } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").ToGamutOptions} ToGamutOptions */ + + /** + * Calculate the epsilon to 2 degrees smaller than the specified JND. + * @param {number} jnd The target "just noticeable difference". + * @returns {number} + */ + function calcEpsilon (jnd) { + // Calculate the epsilon to 2 degrees smaller than the specified JND. + + const order = !jnd ? 0 : Math.floor(Math.log10(Math.abs(jnd))); + // Limit to an arbitrary value to ensure value is never too small and causes infinite loops. + return Math.max(parseFloat(`1e${order - 2}`), 1e-6); + } + + const GMAPPRESET = { + hct: { + method: "hct.c", + jnd: 2, + deltaEMethod: "hct", + blackWhiteClamp: {}, + }, + "hct-tonal": { + method: "hct.c", + jnd: 0, + deltaEMethod: "hct", + blackWhiteClamp: { channel: "hct.t", min: 0, max: 100 }, + }, + }; + + /** + * Force coordinates to be in gamut of a certain color space. + * Mutates the color it is passed. + * @overload + * @param {ColorTypes} color + * @param {ToGamutOptions} [options] + * @returns {PlainColorObject} + */ + /** + * @overload + * @param {ColorTypes} color + * @param {string} [space] + * @returns {PlainColorObject} + */ + /** + * @param {ColorTypes} color + * @param {string & Partial | ToGamutOptions} [space] + * @returns {PlainColorObject} + */ + function toGamut ( + color, + { + method = defaults.gamut_mapping, + space = undefined, + deltaEMethod = "", + jnd = 2, + blackWhiteClamp = undefined, + } = {}, + ) { + color = getColor(color); + + if (isString(arguments[1])) { + space = arguments[1]; + } + else if (!space) { + space = color.space; + } + + space = ColorSpace.get(space); + + // 3 spaces: + // color.space: current color space + // space: space whose gamut we are mapping to + // mapSpace: space with the coord we're reducing + + if (inGamut(color, space, { epsilon: 0 })) { + return /** @type {PlainColorObject} */ (color); + } + + let spaceColor; + if (method === "css") { + spaceColor = toGamutCSS(color, { space }); + } + else { + if (method !== "clip" && !inGamut(color, space)) { + if (Object.prototype.hasOwnProperty.call(GMAPPRESET, method)) { + ({ method, jnd, deltaEMethod, blackWhiteClamp } = GMAPPRESET[method]); + } + + // Get the correct delta E method + let de = deltaE2000; + if (deltaEMethod !== "") { + for (let m in deltaEMethods) { + if ("deltae" + deltaEMethod.toLowerCase() === m.toLowerCase()) { + de = deltaEMethods[m]; + break; + } + } + } + + if (jnd === 0) { + jnd = 1e-16; + } + + let clipped = toGamut(to(color, space), { method: "clip", space }); + if (de(color, clipped) > jnd) { + // Clamp to SDR white and black if required + if (blackWhiteClamp && Object.keys(blackWhiteClamp).length === 3) { + let channelMeta = ColorSpace.resolveCoord(blackWhiteClamp.channel); + let channel = get(to(color, channelMeta.space), channelMeta.id); + if (isNone(channel)) { + channel = 0; + } + if (channel >= blackWhiteClamp.max) { + return to({ space: "xyz-d65", coords: WHITES["D65"] }, color.space); + } + else if (channel <= blackWhiteClamp.min) { + return to({ space: "xyz-d65", coords: [0, 0, 0] }, color.space); + } + } + + // Reduce a coordinate of a certain color space until the color is in gamut + let coordMeta = ColorSpace.resolveCoord(method); + let mapSpace = coordMeta.space; + let coordId = coordMeta.id; + + let mappedColor = to(color, mapSpace); + // If we were already in the mapped color space, we need to resolve undefined channels + mappedColor.coords.forEach((c, i) => { + if (isNone(c)) { + mappedColor.coords[i] = 0; + } + }); + let bounds = coordMeta.range || coordMeta.refRange; + let min = bounds[0]; + let ε = calcEpsilon(jnd); + let low = min; + let high = get(mappedColor, coordId); + + while (high - low > ε) { + let clipped = clone(mappedColor); + clipped = toGamut(clipped, { space, method: "clip" }); + let deltaE = de(mappedColor, clipped); + + if (deltaE - jnd < ε) { + low = get(mappedColor, coordId); + } + else { + high = get(mappedColor, coordId); + } + + set(mappedColor, coordId, (low + high) / 2); + } + + spaceColor = to(mappedColor, space); + } + else { + spaceColor = clipped; + } + } + else { + spaceColor = to(color, space); + } + + if ( + method === "clip" || // Dumb coord clipping + // finish off smarter gamut mapping with clip to get rid of ε, see #17 + !inGamut(spaceColor, space, { epsilon: 0 }) + ) { + let bounds = Object.values(space.coords).map(c => c.range || []); + + spaceColor.coords = /** @type {[number, number, number]} */ ( + spaceColor.coords.map((c, i) => { + let [min, max] = bounds[i]; + + if (min !== undefined) { + c = Math.max(min, c); + } + + if (max !== undefined) { + c = Math.min(c, max); + } + + return c; + }) + ); + } + } + + if (space !== color.space) { + spaceColor = to(spaceColor, color.space); + } + + color.coords = spaceColor.coords; + return /** @type {PlainColorObject} */ (color); + } + + /** @type {"color"} */ + toGamut.returns = "color"; + + /** + * The reference colors to be used if lightness is out of the range 0-1 in the + * `Oklch` space. These are created in the `Oklab` space, as it is used by the + * DeltaEOK calculation, so it is guaranteed to be imported. + * @satisfies {Record} + */ + const COLORS = { + WHITE: { space: Oklab, coords: [1, 0, 0], alpha: 1 }, + BLACK: { space: Oklab, coords: [0, 0, 0], alpha: 1 }, + }; + + /** + * Given a color `origin`, returns a new color that is in gamut using + * the CSS Gamut Mapping Algorithm. If `space` is specified, it will be in gamut + * in `space`, and returned in `space`. Otherwise, it will be in gamut and + * returned in the color space of `origin`. + * @param {ColorTypes} origin + * @param {{ space?: string | ColorSpace | undefined }} param1 + * @returns {PlainColorObject} + */ + function toGamutCSS (origin, { space } = {}) { + const JND = 0.02; + const ε = 0.0001; + + origin = getColor(origin); + + if (!space) { + space = origin.space; + } + + space = ColorSpace.get(space); + const oklchSpace = ColorSpace.get("oklch"); + + if (space.isUnbounded) { + return to(origin, space); + } + + const origin_OKLCH = to(origin, oklchSpace); + let L = origin_OKLCH.coords[0]; + + // return media white or black, if lightness is out of range + if (L >= 1) { + const white = to(COLORS.WHITE, space); + white.alpha = origin.alpha; + return to(white, space); + } + if (L <= 0) { + const black = to(COLORS.BLACK, space); + black.alpha = origin.alpha; + return to(black, space); + } + + if (inGamut(origin_OKLCH, space, { epsilon: 0 })) { + return to(origin_OKLCH, space); + } + + function clip (_color) { + const destColor = to(_color, space); + const spaceCoords = Object.values(/** @type {ColorSpace} */ (space).coords); + destColor.coords = /** @type {[number, number, number]} */ ( + destColor.coords.map((coord, index) => { + if ("range" in spaceCoords[index]) { + const [min, max] = spaceCoords[index].range; + return clamp(min, coord, max); + } + return coord; + }) + ); + return destColor; + } + let min = 0; + let max = origin_OKLCH.coords[1]; + let min_inGamut = true; + let current = clone(origin_OKLCH); + let clipped = clip(current); + + let E = deltaEOK(clipped, current); + if (E < JND) { + return clipped; + } + + while (max - min > ε) { + const chroma = (min + max) / 2; + current.coords[1] = chroma; + if (min_inGamut && inGamut(current, space, { epsilon: 0 })) { + min = chroma; + } + else { + clipped = clip(current); + E = deltaEOK(clipped, current); + if (E < JND) { + if (JND - E < ε) { + break; + } + else { + min_inGamut = false; + min = chroma; + } + } + else { + max = chroma; + } + } + } + return clipped; + } + + /** @import { ColorTypes, PlainColorObject, ToGamutOptions } from "./types.js" */ + + /** + * Convert to color space and return a new color + * @param {ColorTypes} color + * @param {string | ColorSpace} space + * @param {{ inGamut?: boolean | ToGamutOptions | undefined }} options + * @returns {PlainColorObject} + */ + function to (color, space, { inGamut } = {}) { + color = getColor(color); + space = ColorSpace.get(space); + + let coords = space.from(color); + let ret = { space, coords, alpha: color.alpha }; + + if (inGamut) { + ret = toGamut(ret, inGamut === true ? undefined : inGamut); + } + + return ret; + } + + /** @type {"color"} */ + to.returns = "color"; + + // @ts-nocheck + + + /** @import { ColorTypes, ParseOptions, PlainColorObject } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").SerializeOptions} SerializeOptions */ + + /** + * Generic toString() method, outputs a color(spaceId ...coords) function, a functional syntax, or custom formats defined by the color space + * @param {ColorTypes} color + * @param {SerializeOptions & Record} options + * @returns {string} + */ + function serialize (color, options = {}) { + let { + precision = defaults.precision, + format, + inGamut: inGamut$1 = true, + coords: coordFormat, + alpha: alphaFormat, + commas, + } = options; + let ret; + + let colorWithMeta = /** @type {PlainColorObject & ParseOptions} */ (getColor(color)); + + let formatId = format; + let parseMeta = colorWithMeta.parseMeta; + + if (parseMeta && !format) { + if (parseMeta.format.canSerialize()) { + format = parseMeta.format; + formatId = parseMeta.formatId; + } + + coordFormat ??= parseMeta.types; + alphaFormat ??= parseMeta.alphaType; + commas ??= parseMeta.commas; + } + + if (formatId) { + // A format is explicitly specified + format = colorWithMeta.space.getFormat(format) ?? ColorSpace.findFormat(formatId); + } + + if (!format) { + // No format specified, or format not found + format = colorWithMeta.space.getFormat("default") ?? ColorSpace.DEFAULT_FORMAT; + formatId = format.name; + } + + if (format && format.space && format.space !== colorWithMeta.space) { + // Format specified belongs to a different color space, + // need to convert to it first + colorWithMeta = to(colorWithMeta, format.space); + } + + // The assignment to coords and inGamut needs to stay in the order they are now + // The order of the assignment was changed as a workaround for a bug in Next.js + // See this issue for details: https://github.com/color-js/color.js/issues/260 + + let coords = colorWithMeta.coords.slice(); // clone so we can manipulate it + + inGamut$1 ||= format.toGamut; + + if (inGamut$1 && !inGamut(colorWithMeta)) { + // FIXME what happens if the color contains none values? + coords = toGamut(clone(colorWithMeta), inGamut$1 === true ? undefined : inGamut$1).coords; + } + + if (format.type === "custom") { + if (format.serialize) { + ret = format.serialize(coords, colorWithMeta.alpha, options); + } + else { + throw new TypeError( + `format ${formatId} can only be used to parse colors, not for serialization`, + ); + } + } + else { + // Functional syntax + let name = format.name || "color"; + + let args = format.serializeCoords(coords, precision, coordFormat); + + if (name === "color") { + // If output is a color() function, add colorspace id as first argument + let cssId = + format.id || format.ids?.[0] || colorWithMeta.space.cssId || colorWithMeta.space.id; + args.unshift(cssId); + } + + // Serialize alpha? + /** @type {string | number} */ + let alpha = colorWithMeta.alpha; + + if (alphaFormat !== undefined && !(typeof alphaFormat === "object")) { + alphaFormat = + typeof alphaFormat === "string" ? { type: alphaFormat } : { include: alphaFormat }; + } + + let alphaType = alphaFormat?.type ?? ""; + let serializeAlpha = + alphaFormat?.include === true || + format.alpha === true || + (alphaFormat?.include !== false && format.alpha !== false && alpha < 1); + let strAlpha = ""; + + commas ??= format.commas; + + if (serializeAlpha) { + if (precision !== null) { + let unit; + + if (alphaType === "") { + unit = "%"; + alpha *= 100; + } + + alpha = serializeNumber(alpha, { precision, unit }); + } + + strAlpha = `${commas ? "," : " /"} ${alpha}`; + } + + ret = `${name}(${args.join(commas ? ", " : " ")}${strAlpha})`; + } + + return ret; + } + + /** @import { Matrix3x3 } from "../types.js" */ + + // convert an array of linear-light rec2020 values to CIE XYZ + // using D65 (no chromatic adaptation) + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + // 0 is actually calculated as 4.994106574466076e-17 + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M$5 = [ + [ 0.6369580483012914, 0.14461690358620832, 0.1688809751641721 ], + [ 0.2627002120112671, 0.6779980715188708, 0.05930171646986196 ], + [ 0.000000000000000, 0.028072693049087428, 1.060985057710791 ], + ]; + + // from ITU-R BT.2124-0 Annex 2 p.3 + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M$5 = [ + [ 1.716651187971268, -0.355670783776392, -0.253366281373660 ], + [ -0.666684351832489, 1.616481236634939, 0.0157685458139111 ], + [ 0.017639857445311, -0.042770613257809, 0.942103121235474 ], + ]; + + var REC_2020_Linear = new RGBColorSpace({ + id: "rec2020-linear", + cssId: "--rec2020-linear", + name: "Linear REC.2020", + white: "D65", + toXYZ_M: toXYZ_M$5, + fromXYZ_M: fromXYZ_M$5, + }); + + // import sRGB from "./srgb.js"; + + var REC2020 = new RGBColorSpace({ + id: "rec2020", + name: "REC.2020", + base: REC_2020_Linear, + // Reference electro-optical transfer function from Rec. ITU-R BT.1886 Annex 1 + // with b (black lift) = 0 and a (user gain) = 1 + // defined over the extended range, not clamped + toBase (RGB) { + return RGB.map(function (val) { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + return sign * Math.pow(abs, 2.4); + }); + }, + fromBase (RGB) { + return RGB.map(function (val) { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + return sign * Math.pow(abs, 1 / 2.4); + }); + }, + }); + + /** @import { Matrix3x3 } from "../types.js" */ + + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M$4 = [ + [0.4865709486482162, 0.26566769316909306, 0.1982172852343625], + [0.2289745640697488, 0.6917385218365064, 0.079286914093745], + [0.0000000000000000, 0.04511338185890264, 1.043944368900976], + ]; + + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M$4 = [ + [ 2.493496911941425, -0.9313836179191239, -0.40271078445071684], + [-0.8294889695615747, 1.7626640603183463, 0.023624685841943577], + [ 0.03584583024378447, -0.07617238926804182, 0.9568845240076872], + ]; + + var P3Linear = new RGBColorSpace({ + id: "p3-linear", + cssId: "display-p3-linear", + name: "Linear P3", + white: "D65", + toXYZ_M: toXYZ_M$4, + fromXYZ_M: fromXYZ_M$4, + }); + + /** @import { Matrix3x3 } from "../types.js" */ + + // This is the linear-light version of sRGB + // as used for example in SVG filters + // or in Canvas + + // This matrix was calculated directly from the RGB and white chromaticities + // when rounded to 8 decimal places, it agrees completely with the official matrix + // see https://github.com/w3c/csswg-drafts/issues/5922 + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M$3 = [ + [ 0.41239079926595934, 0.357584339383878, 0.1804807884018343 ], + [ 0.21263900587151027, 0.715168678767756, 0.07219231536073371 ], + [ 0.01933081871559182, 0.11919477979462598, 0.9505321522496607 ], + ]; + + // This matrix is the inverse of the above; + // again it agrees with the official definition when rounded to 8 decimal places + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M$3 = [ + [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ], + [ -0.9692436362808796, 1.8759675015077202, 0.04155505740717559 ], + [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786 ], + ]; + + var sRGBLinear = new RGBColorSpace({ + id: "srgb-linear", + name: "Linear sRGB", + white: "D65", + toXYZ_M: toXYZ_M$3, + fromXYZ_M: fromXYZ_M$3, + }); + + // To produce: Visit https://www.w3.org/TR/css-color-4/#named-colors + // and run in the console: + // copy($$("tr", $(".named-color-table tbody")).map(tr => `"${tr.cells[2].textContent.trim()}": [${tr.cells[4].textContent.trim().split(/\s+/).map(c => c === "0"? "0" : c === "255"? "1" : c + " / 255").join(", ")}]`).join(",\n")) + + /** List of CSS color keywords + * Note that this does not include currentColor, transparent, + * or system colors + * + * @type {Record} + */ + var KEYWORDS = { + aliceblue: [240 / 255, 248 / 255, 1], + antiquewhite: [250 / 255, 235 / 255, 215 / 255], + aqua: [0, 1, 1], + aquamarine: [127 / 255, 1, 212 / 255], + azure: [240 / 255, 1, 1], + beige: [245 / 255, 245 / 255, 220 / 255], + bisque: [1, 228 / 255, 196 / 255], + black: [0, 0, 0], + blanchedalmond: [1, 235 / 255, 205 / 255], + blue: [0, 0, 1], + blueviolet: [138 / 255, 43 / 255, 226 / 255], + brown: [165 / 255, 42 / 255, 42 / 255], + burlywood: [222 / 255, 184 / 255, 135 / 255], + cadetblue: [95 / 255, 158 / 255, 160 / 255], + chartreuse: [127 / 255, 1, 0], + chocolate: [210 / 255, 105 / 255, 30 / 255], + coral: [1, 127 / 255, 80 / 255], + cornflowerblue: [100 / 255, 149 / 255, 237 / 255], + cornsilk: [1, 248 / 255, 220 / 255], + crimson: [220 / 255, 20 / 255, 60 / 255], + cyan: [0, 1, 1], + darkblue: [0, 0, 139 / 255], + darkcyan: [0, 139 / 255, 139 / 255], + darkgoldenrod: [184 / 255, 134 / 255, 11 / 255], + darkgray: [169 / 255, 169 / 255, 169 / 255], + darkgreen: [0, 100 / 255, 0], + darkgrey: [169 / 255, 169 / 255, 169 / 255], + darkkhaki: [189 / 255, 183 / 255, 107 / 255], + darkmagenta: [139 / 255, 0, 139 / 255], + darkolivegreen: [85 / 255, 107 / 255, 47 / 255], + darkorange: [1, 140 / 255, 0], + darkorchid: [153 / 255, 50 / 255, 204 / 255], + darkred: [139 / 255, 0, 0], + darksalmon: [233 / 255, 150 / 255, 122 / 255], + darkseagreen: [143 / 255, 188 / 255, 143 / 255], + darkslateblue: [72 / 255, 61 / 255, 139 / 255], + darkslategray: [47 / 255, 79 / 255, 79 / 255], + darkslategrey: [47 / 255, 79 / 255, 79 / 255], + darkturquoise: [0, 206 / 255, 209 / 255], + darkviolet: [148 / 255, 0, 211 / 255], + deeppink: [1, 20 / 255, 147 / 255], + deepskyblue: [0, 191 / 255, 1], + dimgray: [105 / 255, 105 / 255, 105 / 255], + dimgrey: [105 / 255, 105 / 255, 105 / 255], + dodgerblue: [30 / 255, 144 / 255, 1], + firebrick: [178 / 255, 34 / 255, 34 / 255], + floralwhite: [1, 250 / 255, 240 / 255], + forestgreen: [34 / 255, 139 / 255, 34 / 255], + fuchsia: [1, 0, 1], + gainsboro: [220 / 255, 220 / 255, 220 / 255], + ghostwhite: [248 / 255, 248 / 255, 1], + gold: [1, 215 / 255, 0], + goldenrod: [218 / 255, 165 / 255, 32 / 255], + gray: [128 / 255, 128 / 255, 128 / 255], + green: [0, 128 / 255, 0], + greenyellow: [173 / 255, 1, 47 / 255], + grey: [128 / 255, 128 / 255, 128 / 255], + honeydew: [240 / 255, 1, 240 / 255], + hotpink: [1, 105 / 255, 180 / 255], + indianred: [205 / 255, 92 / 255, 92 / 255], + indigo: [75 / 255, 0, 130 / 255], + ivory: [1, 1, 240 / 255], + khaki: [240 / 255, 230 / 255, 140 / 255], + lavender: [230 / 255, 230 / 255, 250 / 255], + lavenderblush: [1, 240 / 255, 245 / 255], + lawngreen: [124 / 255, 252 / 255, 0], + lemonchiffon: [1, 250 / 255, 205 / 255], + lightblue: [173 / 255, 216 / 255, 230 / 255], + lightcoral: [240 / 255, 128 / 255, 128 / 255], + lightcyan: [224 / 255, 1, 1], + lightgoldenrodyellow: [250 / 255, 250 / 255, 210 / 255], + lightgray: [211 / 255, 211 / 255, 211 / 255], + lightgreen: [144 / 255, 238 / 255, 144 / 255], + lightgrey: [211 / 255, 211 / 255, 211 / 255], + lightpink: [1, 182 / 255, 193 / 255], + lightsalmon: [1, 160 / 255, 122 / 255], + lightseagreen: [32 / 255, 178 / 255, 170 / 255], + lightskyblue: [135 / 255, 206 / 255, 250 / 255], + lightslategray: [119 / 255, 136 / 255, 153 / 255], + lightslategrey: [119 / 255, 136 / 255, 153 / 255], + lightsteelblue: [176 / 255, 196 / 255, 222 / 255], + lightyellow: [1, 1, 224 / 255], + lime: [0, 1, 0], + limegreen: [50 / 255, 205 / 255, 50 / 255], + linen: [250 / 255, 240 / 255, 230 / 255], + magenta: [1, 0, 1], + maroon: [128 / 255, 0, 0], + mediumaquamarine: [102 / 255, 205 / 255, 170 / 255], + mediumblue: [0, 0, 205 / 255], + mediumorchid: [186 / 255, 85 / 255, 211 / 255], + mediumpurple: [147 / 255, 112 / 255, 219 / 255], + mediumseagreen: [60 / 255, 179 / 255, 113 / 255], + mediumslateblue: [123 / 255, 104 / 255, 238 / 255], + mediumspringgreen: [0, 250 / 255, 154 / 255], + mediumturquoise: [72 / 255, 209 / 255, 204 / 255], + mediumvioletred: [199 / 255, 21 / 255, 133 / 255], + midnightblue: [25 / 255, 25 / 255, 112 / 255], + mintcream: [245 / 255, 1, 250 / 255], + mistyrose: [1, 228 / 255, 225 / 255], + moccasin: [1, 228 / 255, 181 / 255], + navajowhite: [1, 222 / 255, 173 / 255], + navy: [0, 0, 128 / 255], + oldlace: [253 / 255, 245 / 255, 230 / 255], + olive: [128 / 255, 128 / 255, 0], + olivedrab: [107 / 255, 142 / 255, 35 / 255], + orange: [1, 165 / 255, 0], + orangered: [1, 69 / 255, 0], + orchid: [218 / 255, 112 / 255, 214 / 255], + palegoldenrod: [238 / 255, 232 / 255, 170 / 255], + palegreen: [152 / 255, 251 / 255, 152 / 255], + paleturquoise: [175 / 255, 238 / 255, 238 / 255], + palevioletred: [219 / 255, 112 / 255, 147 / 255], + papayawhip: [1, 239 / 255, 213 / 255], + peachpuff: [1, 218 / 255, 185 / 255], + peru: [205 / 255, 133 / 255, 63 / 255], + pink: [1, 192 / 255, 203 / 255], + plum: [221 / 255, 160 / 255, 221 / 255], + powderblue: [176 / 255, 224 / 255, 230 / 255], + purple: [128 / 255, 0, 128 / 255], + rebeccapurple: [102 / 255, 51 / 255, 153 / 255], + red: [1, 0, 0], + rosybrown: [188 / 255, 143 / 255, 143 / 255], + royalblue: [65 / 255, 105 / 255, 225 / 255], + saddlebrown: [139 / 255, 69 / 255, 19 / 255], + salmon: [250 / 255, 128 / 255, 114 / 255], + sandybrown: [244 / 255, 164 / 255, 96 / 255], + seagreen: [46 / 255, 139 / 255, 87 / 255], + seashell: [1, 245 / 255, 238 / 255], + sienna: [160 / 255, 82 / 255, 45 / 255], + silver: [192 / 255, 192 / 255, 192 / 255], + skyblue: [135 / 255, 206 / 255, 235 / 255], + slateblue: [106 / 255, 90 / 255, 205 / 255], + slategray: [112 / 255, 128 / 255, 144 / 255], + slategrey: [112 / 255, 128 / 255, 144 / 255], + snow: [1, 250 / 255, 250 / 255], + springgreen: [0, 1, 127 / 255], + steelblue: [70 / 255, 130 / 255, 180 / 255], + tan: [210 / 255, 180 / 255, 140 / 255], + teal: [0, 128 / 255, 128 / 255], + thistle: [216 / 255, 191 / 255, 216 / 255], + tomato: [1, 99 / 255, 71 / 255], + turquoise: [64 / 255, 224 / 255, 208 / 255], + violet: [238 / 255, 130 / 255, 238 / 255], + wheat: [245 / 255, 222 / 255, 179 / 255], + white: [1, 1, 1], + whitesmoke: [245 / 255, 245 / 255, 245 / 255], + yellow: [1, 1, 0], + yellowgreen: [154 / 255, 205 / 255, 50 / 255], + }; + + /** @import { Coords } from "../types.js" */ + + let coordGrammar = Array(3).fill(" | [0, 255]"); + let coordGrammarNumber = Array(3).fill("[0, 255]"); + + var sRGB = new RGBColorSpace({ + id: "srgb", + name: "sRGB", + base: sRGBLinear, + fromBase: rgb => { + // convert an array of linear-light sRGB values in the range 0.0-1.0 + // to gamma corrected form + // https://en.wikipedia.org/wiki/SRGB + return rgb.map(val => { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + + if (abs > 0.0031308) { + return sign * (1.055 * abs ** (1 / 2.4) - 0.055); + } + + return 12.92 * val; + }); + }, + toBase: rgb => { + // convert an array of sRGB values in the range 0.0 - 1.0 + // to linear light (un-companded) form. + // https://en.wikipedia.org/wiki/SRGB + return rgb.map(val => { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + + if (abs <= 0.04045) { + return val / 12.92; + } + + return sign * ((abs + 0.055) / 1.055) ** 2.4; + }); + }, + formats: { + rgb: { + coords: coordGrammar, + }, + rgb_number: { + name: "rgb", + commas: true, + coords: coordGrammarNumber, + alpha: false, + }, + color: { + /* use defaults */ + }, + rgba: { + coords: coordGrammar, + commas: true, + alpha: true, + }, + rgba_number: { + name: "rgba", + commas: true, + coords: coordGrammarNumber, + }, + hex: { + type: "custom", + toGamut: true, + test: str => /^#(([a-f0-9]{2}){3,4}|[a-f0-9]{3,4})$/i.test(str), + parse (str) { + if (str.length <= 5) { + // #rgb or #rgba, duplicate digits + str = str.replace(/[a-f0-9]/gi, "$&$&"); + } + + /** @type {number[]} */ + let rgba = []; + // @ts-expect-error Type 'void' is not assignable to type 'string' + str.replace(/[a-f0-9]{2}/gi, component => { + rgba.push(parseInt(component, 16) / 255); + }); + + return { + spaceId: "srgb", + coords: /** @type {Coords} */ (rgba.slice(0, 3)), + alpha: /** @type {number} */ (rgba.slice(3)[0]), + }; + }, + serialize: ( + coords, + alpha, + { + collapse = true, // collapse to 3-4 digit hex when possible? + alpha: alphaFormat, + } = {}, + ) => { + if ((alphaFormat !== false && alpha < 1) || alphaFormat === true) { + coords.push(alpha); + } + + coords = /** @type {[number, number, number]} */ ( + coords.map(c => Math.round(c * 255)) + ); + + let collapsible = collapse && coords.every(c => c % 17 === 0); + + let hex = coords + .map(c => { + if (collapsible) { + return (c / 17).toString(16); + } + + return c.toString(16).padStart(2, "0"); + }) + .join(""); + + return "#" + hex; + }, + }, + keyword: { + type: "custom", + test: str => /^[a-z]+$/i.test(str), + parse (str) { + str = str.toLowerCase(); + let ret = { spaceId: "srgb", coords: null, alpha: 1 }; + + if (str === "transparent") { + ret.coords = KEYWORDS.black; + ret.alpha = 0; + } + else { + ret.coords = KEYWORDS[str]; + } + + if (ret.coords) { + return ret; + } + }, + }, + }, + }); + + var P3 = new RGBColorSpace({ + id: "p3", + cssId: "display-p3", + name: "P3", + base: P3Linear, + // Gamma encoding/decoding is the same as sRGB + fromBase: sRGB.fromBase, + toBase: sRGB.toBase, + }); + + /** @import ColorSpace from "./ColorSpace.js" */ + /** @import { ColorTypes, PlainColorObject } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").Display} Display */ + + // Default space for CSS output. Code in Color.js makes this wider if there's a DOM available + defaults.display_space = sRGB; + + let supportsNone; + + if (typeof CSS !== "undefined" && CSS.supports) { + // Find widest supported color space for CSS + for (let space of [lab, REC2020, P3]) { + let coords = space.getMinCoords(); + let color = { space, coords, alpha: 1 }; + let str = serialize(color); + + if (CSS.supports("color", str)) { + defaults.display_space = space; + break; + } + } + } + + /** + * Returns a serialization of the color that can actually be displayed in the browser. + * If the default serialization can be displayed, it is returned. + * Otherwise, the color is converted to Lab, REC2020, or P3, whichever is the widest supported. + * In Node.js, this is basically equivalent to `serialize()` but returns a `String` object instead. + * @param {ColorTypes} color + * @param {{ space?: string | ColorSpace | undefined } & Record} param1 + * Options to be passed to `serialize()` + * @returns {Display} String object containing the serialized color + * with a color property containing the converted color (or the original, if no conversion was necessary) + */ + function display (color, { space = defaults.display_space, ...options } = {}) { + color = getColor(color); + + let ret = /** @type {Display} */ (serialize(color, options)); + + if ( + typeof CSS === "undefined" || + CSS.supports("color", /** @type {string} */ (ret)) || + !defaults.display_space + ) { + ret = /** @type {Display} */ (new String(ret)); + ret.color = /** @type {PlainColorObject} */ (color); + } + else { + // If we're here, what we were about to output is not supported + let fallbackColor = /** @type {PlainColorObject} */ (color); + + // First, check if the culprit is none values + let hasNone = color.coords.some(isNone) || isNone(color.alpha); + + if (hasNone) { + // Does the browser support none values? + if (!(supportsNone ??= CSS.supports("color", "hsl(none 50% 50%)"))) { + // Nope, try again without none + fallbackColor = clone(/** @type {PlainColorObject} */ (color)); + fallbackColor.coords = /** @type {[number, number, number]} */ ( + fallbackColor.coords.map(skipNone) + ); + fallbackColor.alpha = skipNone(fallbackColor.alpha); + + // @ts-expect-error This is set to the correct type later + ret = serialize(fallbackColor, options); + + if (CSS.supports("color", /** @type {string} */ (ret))) { + // We're done, now it's supported + ret = /** @type {Display} */ (new String(ret)); + ret.color = fallbackColor; + return ret; + } + } + } + + // If we're here, the color function is not supported + // Fall back to fallback space + fallbackColor = to(fallbackColor, space); + ret = /** @type {Display} */ (new String(serialize(fallbackColor, options))); + ret.color = fallbackColor; + } + + return ret; + } + + /** @import { ColorTypes } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").DeltasReturn} DeltasReturn */ + + /** + * Get color differences per-component, on any color space + * @param {ColorTypes} c1 + * @param {ColorTypes} c2 + * @param {object} options + * @param {string | ColorSpace} [options.space=c1.space] - The color space to use for the delta calculation. Defaults to the color space of the first color. + * @param {Parameters[0]} [options.hue="shorter"] - How to handle hue differences. Same as hue interpolation option. + * @returns {DeltasReturn} + */ + function deltas (c1, c2, { space, hue = "shorter" } = {}) { + c1 = getColor(c1); + space ||= c1.space; + space = ColorSpace.get(space); + let spaceCoords = Object.values(space.coords); + + [c1, c2] = [c1, c2].map(c => to(c, space)); + let [coords1, coords2] = [c1, c2].map(c => c.coords); + + let coords = /** @type {[number, number, number]} */ ( + coords1.map((coord1, i) => { + let coordMeta = spaceCoords[i]; + let coord2 = coords2[i]; + + if (coordMeta.type === "angle") { + [coord1, coord2] = adjust(hue, [coord1, coord2]); + } + + return subtractCoords(coord1, coord2); + }) + ); + + let alpha = subtractCoords(c1.alpha, c2.alpha); + + return { space: /** @type {ColorSpace} */ (space), coords, alpha }; + } + + function subtractCoords (c1, c2) { + if (isNone(c1) || isNone(c2)) { + return c1 === c2 ? null : 0; + } + + return c1 - c2; + } + + /** @import { ColorTypes } from "./types.js" */ + + /** + * @param {ColorTypes} color1 + * @param {ColorTypes} color2 + * @returns {boolean} + */ + function equals (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + return ( + color1.space === color2.space && + color1.alpha === color2.alpha && + color1.coords.every((c, i) => c === color2.coords[i]) + ); + } + + /** + * Relative luminance + */ + + /** @import { ColorTypes } from "./types.js" */ + + /** + * + * @param {ColorTypes} color + * @returns {number} + */ + function getLuminance (color) { + // Assume getColor() is called on color in get() + return get(color, [xyz_d65, "y"]); + } + + /** + * @param {ColorTypes} color + * @param {number | ((coord: number) => number)} value + */ + function setLuminance (color, value) { + // Assume getColor() is called on color in set() + set(color, [xyz_d65, "y"], value); + } + + /** + * @param {typeof import("./color.js").default} Color + */ + function register$2 (Color) { + Object.defineProperty(Color.prototype, "luminance", { + get () { + return getLuminance(this); + }, + set (value) { + setLuminance(this, value); + }, + }); + } + + var luminance = /*#__PURE__*/Object.freeze({ + __proto__: null, + getLuminance: getLuminance, + register: register$2, + setLuminance: setLuminance + }); + + // WCAG 2.0 contrast https://www.w3.org/TR/WCAG20-TECHS/G18.html + // Simple contrast, with fixed 5% viewing flare contribution + // Symmetric, does not matter which is foreground and which is background + + + /** + * @param {import("../types.js").ColorTypes} color1 + * @param {import("../types.js").ColorTypes} color2 + * @returns {number} + */ + function contrastWCAG21 (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + let Y1 = Math.max(getLuminance(color1), 0); + let Y2 = Math.max(getLuminance(color2), 0); + + if (Y2 > Y1) { + [Y1, Y2] = [Y2, Y1]; + } + + return (Y1 + 0.05) / (Y2 + 0.05); + } + + // APCA 0.0.98G + // https://github.com/Myndex/apca-w3 + // see also https://github.com/w3c/silver/issues/643 + + + // exponents + const normBG = 0.56; + const normTXT = 0.57; + const revTXT = 0.62; + const revBG = 0.65; + + // clamps + const blkThrs = 0.022; + const blkClmp = 1.414; + const loClip = 0.1; + const deltaYmin = 0.0005; + + // scalers + // see https://github.com/w3c/silver/issues/645 + const scaleBoW = 1.14; + const loBoWoffset = 0.027; + const scaleWoB = 1.14; + + function fclamp (Y) { + if (Y >= blkThrs) { + return Y; + } + return Y + (blkThrs - Y) ** blkClmp; + } + + function linearize (val) { + let sign = val < 0 ? -1 : 1; + let abs = Math.abs(val); + return sign * Math.pow(abs, 2.4); + } + + /** + * Not symmetric, requires a foreground (text) color, and a background color + * @param {import("../types.js").ColorTypes} background + * @param {import("../types.js").ColorTypes} foreground + * @returns {number} + */ + function contrastAPCA (background, foreground) { + foreground = getColor(foreground); + background = getColor(background); + + let S; + let C; + let Sapc; + + // Myndex as-published, assumes sRGB inputs + let R, G, B; + + foreground = to(foreground, "srgb"); + // Should these be clamped to in-gamut values? + + // Calculates "screen luminance" with non-standard simple gamma EOTF + // weights should be from CSS Color 4, not the ones here which are via Myndex and copied from Lindbloom + [R, G, B] = foreground.coords.map(c => { + return isNone(c) ? 0 : c; + }); + let lumTxt = linearize(R) * 0.2126729 + linearize(G) * 0.7151522 + linearize(B) * 0.072175; + + background = to(background, "srgb"); + [R, G, B] = background.coords.map(c => { + return isNone(c) ? 0 : c; + }); + let lumBg = linearize(R) * 0.2126729 + linearize(G) * 0.7151522 + linearize(B) * 0.072175; + + // toe clamping of very dark values to account for flare + let Ytxt = fclamp(lumTxt); + let Ybg = fclamp(lumBg); + + // are we "Black on White" (dark on light), or light on dark? + let BoW = Ybg > Ytxt; + + // why is this a delta, when Y is not perceptually uniform? + // Answer: it is a noise gate, see + // https://github.com/LeaVerou/color.js/issues/208 + if (Math.abs(Ybg - Ytxt) < deltaYmin) { + C = 0; + } + else { + if (BoW) { + // dark text on light background + S = Ybg ** normBG - Ytxt ** normTXT; + C = S * scaleBoW; + } + else { + // light text on dark background + S = Ybg ** revBG - Ytxt ** revTXT; + C = S * scaleWoB; + } + } + if (Math.abs(C) < loClip) { + Sapc = 0; + } + else if (C > 0) { + // not clear whether Woffset is loBoWoffset or loWoBoffset + // but they have the same value + Sapc = C - loBoWoffset; + } + else { + Sapc = C + loBoWoffset; + } + + return Sapc * 100; + } + + // Michelson luminance contrast + // the relation between the spread and the sum of the two luminances + // Symmetric, does not matter which is foreground and which is background + // No black level compensation for flare. + + + /** + * @param {import("../types.js").ColorTypes} color1 + * @param {import("../types.js").ColorTypes} color2 + * @returns {number} + */ + function contrastMichelson (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + let Y1 = Math.max(getLuminance(color1), 0); + let Y2 = Math.max(getLuminance(color2), 0); + + if (Y2 > Y1) { + [Y1, Y2] = [Y2, Y1]; + } + + let denom = Y1 + Y2; + return denom === 0 ? 0 : (Y1 - Y2) / denom; + } + + // Weber luminance contrast + // The difference between the two luminances divided by the lower luminance + // Symmetric, does not matter which is foreground and which is background + // No black level compensation for flare. + + + // the darkest sRGB color above black is #000001 and this produces + // a plain Weber contrast of ~45647. + // So, setting the divide-by-zero result at 50000 is a reasonable + // max clamp for the plain Weber + const max = 50000; + + /** + * @param {import("../types.js").ColorTypes} color1 + * @param {import("../types.js").ColorTypes} color2 + * @returns {number} + */ + function contrastWeber (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + let Y1 = Math.max(getLuminance(color1), 0); + let Y2 = Math.max(getLuminance(color2), 0); + + if (Y2 > Y1) { + [Y1, Y2] = [Y2, Y1]; + } + + return Y2 === 0 ? max : (Y1 - Y2) / Y2; + } + + // CIE Lightness difference, as used by Google Material Design + // Google HCT Tone is the same as CIE Lightness + // https://material.io/blog/science-of-color-design + + + /** + * @param {import("../types.js").ColorTypes} color1 + * @param {import("../types.js").ColorTypes} color2 + * @returns {number} + */ + function contrastLstar (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + let L1 = get(color1, [lab, "l"]); + let L2 = get(color2, [lab, "l"]); + + return Math.abs(L1 - L2); + } + + // κ * ε = 2^3 = 8 + const ε$3 = 216 / 24389; // 6^3/29^3 == (24/116)^3 + const ε3 = 24 / 116; + const κ$2 = 24389 / 27; // 29^3/3^3 + + let white$1 = WHITES.D65; + + var lab_d65 = new ColorSpace({ + id: "lab-d65", + name: "Lab D65", + coords: { + l: { + refRange: [0, 100], + name: "Lightness", + }, + a: { + refRange: [-125, 125], + }, + b: { + refRange: [-125, 125], + }, + }, + + // Assuming XYZ is relative to D65, convert to CIE Lab + // from CIE standard, which now defines these as a rational fraction + white: white$1, + + base: xyz_d65, + // Convert D65-adapted XYZ to Lab + // CIE 15.3:2004 section 8.2.1.1 + fromBase (XYZ) { + // compute xyz, which is XYZ scaled relative to reference white + let xyz = XYZ.map((value, i) => value / white$1[i]); + + // now compute f + let f = xyz.map(value => (value > ε$3 ? Math.cbrt(value) : (κ$2 * value + 16) / 116)); + + return [ + 116 * f[1] - 16, // L + 500 * (f[0] - f[1]), // a + 200 * (f[1] - f[2]), // b + ]; + }, + // Convert Lab to D65-adapted XYZ + // Same result as CIE 15.3:2004 Appendix D although the derivation is different + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + toBase (Lab) { + // compute f, starting with the luminance-related term + let f = []; + f[1] = (Lab[0] + 16) / 116; + f[0] = Lab[1] / 500 + f[1]; + f[2] = f[1] - Lab[2] / 200; + + // compute xyz + let xyz = [ + f[0] > ε3 ? Math.pow(f[0], 3) : (116 * f[0] - 16) / κ$2, + Lab[0] > 8 ? Math.pow((Lab[0] + 16) / 116, 3) : Lab[0] / κ$2, + f[2] > ε3 ? Math.pow(f[2], 3) : (116 * f[2] - 16) / κ$2, + ]; + + // Compute XYZ by scaling xyz by reference white + return xyz.map((value, i) => value * white$1[i]); + }, + + formats: { + "lab-d65": { + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + // Delta Phi Star perceptual lightness contrast + // See https://github.com/Myndex/deltaphistar + // The (difference between two Lstars each raised to phi) raised to (1/phi) + // Symmetric, does not matter which is foreground and which is background + + + const phi = Math.pow(5, 0.5) * 0.5 + 0.5; // Math.phi can be used if Math.js + + /** + * @param {import("../types.js").ColorTypes} color1 + * @param {import("../types.js").ColorTypes} color2 + * @returns {number} + */ + function contrastDeltaPhi (color1, color2) { + color1 = getColor(color1); + color2 = getColor(color2); + + let Lstr1 = get(color1, [lab_d65, "l"]); + let Lstr2 = get(color2, [lab_d65, "l"]); + + let deltaPhiStar = Math.abs(Math.pow(Lstr1, phi) - Math.pow(Lstr2, phi)); + + let contrast = Math.pow(deltaPhiStar, 1 / phi) * Math.SQRT2 - 40; + + return contrast < 7.5 ? 0.0 : contrast; + } + + /** @typedef {keyof typeof import("./index.js") extends `contrast${infer Alg}` ? Alg : string} Algorithms */ + + var contrastMethods = /*#__PURE__*/Object.freeze({ + __proto__: null, + contrastAPCA: contrastAPCA, + contrastDeltaPhi: contrastDeltaPhi, + contrastLstar: contrastLstar, + contrastMichelson: contrastMichelson, + contrastWCAG21: contrastWCAG21, + contrastWeber: contrastWeber + }); + + /** @import { ColorTypes } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").Algorithms} Algorithms */ + + /** + * + * @param {ColorTypes} background + * @param {ColorTypes} foreground + * @param {Algorithms | ({ algorithm: Algorithms } & Record)} o + * Algorithm to use as well as any other options to pass to the contrast function + * @returns {number} + * @throws {TypeError} Unknown or unspecified algorithm + */ + function contrast (background, foreground, o) { + if (isString(o)) { + o = { algorithm: o }; + } + + let { algorithm, ...rest } = o || {}; + + if (!algorithm) { + let algorithms = Object.keys(contrastMethods) + .map(a => a.replace(/^contrast/, "")) + .join(", "); + throw new TypeError( + `contrast() function needs a contrast algorithm. Please specify one of: ${algorithms}`, + ); + } + + background = getColor(background); + foreground = getColor(foreground); + + for (let a in contrastMethods) { + if ("contrast" + algorithm.toLowerCase() === a.toLowerCase()) { + return contrastMethods[a](background, foreground, rest); + } + } + + throw new TypeError(`Unknown contrast algorithm: ${algorithm}`); + } + + /** @import Color, { ColorTypes } from "./color.js" */ + + // Chromaticity coordinates + /** + * @param {ColorTypes} color + * @returns {[number, number]} + */ + function uv (color) { + // Assumes getAll() calls getColor() on color + let [X, Y, Z] = getAll(color, xyz_d65); + let denom = X + 15 * Y + 3 * Z; + return [(4 * X) / denom, (9 * Y) / denom]; + } + + /** + * @param {ColorTypes} color + * @returns {[number, number]} + */ + function xy (color) { + // Assumes getAll() calls getColor() on color + let [X, Y, Z] = getAll(color, xyz_d65); + let sum = X + Y + Z; + return [X / sum, Y / sum]; + } + + /** + * @param {typeof Color} Color + */ + function register$1 (Color) { + // no setters, as lightness information is lost + // when converting color to chromaticity + Object.defineProperty(Color.prototype, "uv", { + get () { + return uv(this); + }, + }); + + Object.defineProperty(Color.prototype, "xy", { + get () { + return xy(this); + }, + }); + } + + var chromaticity = /*#__PURE__*/Object.freeze({ + __proto__: null, + register: register$1, + uv: uv, + xy: xy + }); + + /** @import { ColorTypes } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").Methods} Methods */ + + /** + * + * @param {ColorTypes} c1 + * @param {ColorTypes} c2 + * @param {Methods | ({ method?: Methods | undefined } & Record)} [o] + * deltaE method to use as well as any other options to pass to the deltaE function + * @returns {number} + * @throws {TypeError} Unknown or unspecified method + */ + function deltaE (c1, c2, o = {}) { + if (isString(o)) { + o = { method: o }; + } + + let { method = defaults.deltaE, ...rest } = o; + + for (let m in deltaEMethods) { + if ("deltae" + method.toLowerCase() === m.toLowerCase()) { + return deltaEMethods[m](c1, c2, rest); + } + } + + throw new TypeError(`Unknown deltaE method: ${method}`); + } + + /** @import { ColorTypes, PlainColorObject, Ref } from "./types.js" */ + + /** + * @param {ColorTypes} color + * @param {number} amount + * @returns {PlainColorObject} + */ + function lighten (color, amount = 0.25) { + let space = ColorSpace.get("oklch", "lch"); + let /** @type {Ref} */ lightness = [space, "l"]; + return set(color, lightness, l => l * (1 + amount)); + } + + /** + * @param {ColorTypes} color + * @param {number} amount + * @returns {PlainColorObject} + */ + function darken (color, amount = 0.25) { + let space = ColorSpace.get("oklch", "lch"); + let /** @type {Ref} */ lightness = [space, "l"]; + return set(color, lightness, l => l * (1 - amount)); + } + + /** @type {"color"} */ + lighten.returns = "color"; + + /** @type {"color"} */ + darken.returns = "color"; + + var variations = /*#__PURE__*/Object.freeze({ + __proto__: null, + darken: darken, + lighten: lighten + }); + + /** + * Functions related to color interpolation + */ + + /** @import { ColorTypes, PlainColorObject, Ref } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").MixOptions} MixOptions */ + /** @typedef {import("./types.js").Range} Range */ + /** @typedef {import("./types.js").RangeOptions} RangeOptions */ + /** @typedef {import("./types.js").StepsOptions} StepsOptions */ + + /** + * Return an intermediate color between two colors + * @overload + * @param {ColorTypes} c1 + * @param {ColorTypes} c2 + * @param {MixOptions} [options] + * @returns {PlainColorObject} + */ + /** + * @overload + * @param {ColorTypes} c1 + * @param {ColorTypes} c2 + * @param {number} [p=0.5] + * @param {MixOptions} [options] + * @returns {PlainColorObject} + */ + function mix (c1, c2, p, o = {}) { + [c1, c2] = [getColor(c1), getColor(c2)]; + + if (type(p) === "object") { + [p, o] = [0.5, p]; + } + + let r = range(c1, c2, o); + return r(p ?? 0.5); // why not give p a default value like we do for options? Overloading doesn't work, and TS complains + } + + /** + * Get an array of discrete steps + * @overload + * @param {ColorTypes} c1 + * @param {ColorTypes} c2 + * @param {StepsOptions} [options] + * @returns {PlainColorObject[]} + */ + /** + * @overload + * @param {Range} range + * @param {StepsOptions} [options] + * @returns {PlainColorObject[]} + */ + function steps (c1, c2, options = {}) { + let colorRange; + + if (isRange(c1)) { + // Tweaking existing range + [colorRange, options] = [c1, c2]; + [c1, c2] = colorRange.rangeArgs.colors; + } + + let { maxDeltaE, deltaEMethod, steps = 2, maxSteps = 1000, ...rangeOptions } = options; + + if (!colorRange) { + [c1, c2] = [getColor(c1), getColor(c2)]; + colorRange = range(c1, c2, rangeOptions); + } + + let totalDelta = deltaE(c1, c2); + let actualSteps = + maxDeltaE > 0 ? Math.max(steps, Math.ceil(totalDelta / maxDeltaE) + 1) : steps; + let ret = []; + + if (maxSteps !== undefined) { + actualSteps = Math.min(actualSteps, maxSteps); + } + + if (actualSteps === 1) { + ret = [{ p: 0.5, color: colorRange(0.5) }]; + } + else { + let step = 1 / (actualSteps - 1); + ret = Array.from({ length: actualSteps }, (_, i) => { + let p = i * step; + return { p, color: colorRange(p) }; + }); + } + + if (maxDeltaE > 0) { + // Iterate over all stops and find max deltaE + let maxDelta = ret.reduce((acc, cur, i) => { + if (i === 0) { + return 0; + } + + let ΔΕ = deltaE(cur.color, ret[i - 1].color, deltaEMethod); + return Math.max(acc, ΔΕ); + }, 0); + + while (maxDelta > maxDeltaE) { + // Insert intermediate stops and measure maxDelta again + // We need to do this for all pairs, otherwise the midpoint shifts + maxDelta = 0; + + for (let i = 1; i < ret.length && ret.length < maxSteps; i++) { + let prev = ret[i - 1]; + let cur = ret[i]; + + let p = (cur.p + prev.p) / 2; + let color = colorRange(p); + maxDelta = Math.max(maxDelta, deltaE(color, prev.color), deltaE(color, cur.color)); + ret.splice(i, 0, { p, color: colorRange(p) }); + i++; + } + } + } + + ret = ret.map(a => a.color); + + return ret; + } + + /** + * Creates a function that accepts a number and returns a color. + * For numbers in the range 0 to 1, the function interpolates; + * for numbers outside that range, the function extrapolates + * (and thus may not return the results you expect) + * @overload + * @param {Range} range + * @param {RangeOptions} [options] + * @returns {Range} + */ + /** + * @overload + * @param {ColorTypes} color1 + * @param {ColorTypes} color2 + * @param {RangeOptions & Record} [options] + * @returns {Range} + */ + function range (color1, color2, options = {}) { + if (isRange(color1)) { + // Tweaking existing range + let [r, options] = [color1, color2]; + + return range(...r.rangeArgs.colors, { ...r.rangeArgs.options, ...options }); + } + + let { space, outputSpace, progression, premultiplied } = options; + + color1 = getColor(color1); + color2 = getColor(color2); + + // Make sure we're working on copies of these colors + color1 = clone(color1); + color2 = clone(color2); + + let rangeArgs = { colors: [color1, color2], options }; + + if (space) { + space = ColorSpace.get(space); + } + else { + space = ColorSpace.registry[defaults.interpolationSpace] || color1.space; + } + + outputSpace = outputSpace ? ColorSpace.get(outputSpace) : space; + + color1 = to(color1, space); + color2 = to(color2, space); + + // Gamut map to avoid areas of flat color + color1 = toGamut(color1); + color2 = toGamut(color2); + + // Handle hue interpolation + // See https://github.com/w3c/csswg-drafts/issues/4735#issuecomment-635741840 + if (space.coords.h && space.coords.h.type === "angle") { + let arc = (options.hue = options.hue || "shorter"); + + let /** @type {Ref} */ hue = [space, "h"]; + let [θ1, θ2] = [get(color1, hue), get(color2, hue)]; + // Undefined hues must be evaluated before hue fix-up to properly + // calculate hue arcs between undefined and defined hues. + // See https://github.com/w3c/csswg-drafts/issues/9436#issuecomment-1746957545 + if (isNone(θ1) && !isNone(θ2)) { + θ1 = θ2; + } + else if (isNone(θ2) && !isNone(θ1)) { + θ2 = θ1; + } + [θ1, θ2] = adjust(arc, [θ1, θ2]); + set(color1, hue, θ1); + set(color2, hue, θ2); + } + + if (premultiplied) { + // not coping with polar spaces yet + color1.coords = /** @type {[number, number, number]} */ ( + color1.coords.map(c => c * color1.alpha) + ); + color2.coords = /** @type {[number, number, number]} */ ( + color2.coords.map(c => c * color2.alpha) + ); + } + + return Object.assign( + p => { + p = progression ? progression(p) : p; + let coords = color1.coords.map((start, i) => { + let end = color2.coords[i]; + return interpolate(start, end, p); + }); + + let alpha = interpolate(color1.alpha, color2.alpha, p); + let ret = { space, coords, alpha }; + + if (premultiplied) { + // undo premultiplication + ret.coords = ret.coords.map(c => c / alpha); + } + + if (outputSpace !== space) { + ret = to(ret, outputSpace); + } + + return ret; + }, + { + rangeArgs, + }, + ); + } + + /** + * @param {any} val + * @returns {val is Range} + */ + function isRange (val) { + return type(val) === "function" && !!val.rangeArgs; + } + + defaults.interpolationSpace = "lab"; + + /** + * @param {typeof import("./color.js").default} Color + */ + function register (Color) { + Color.defineFunction("mix", mix, { returns: "color" }); + Color.defineFunction("range", range, { returns: "function" }); + Color.defineFunction("steps", steps, { returns: "array" }); + } + + var interpolation = /*#__PURE__*/Object.freeze({ + __proto__: null, + isRange: isRange, + mix: mix, + range: range, + register: register, + steps: steps + }); + + var hsl = new ColorSpace({ + id: "hsl", + name: "HSL", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 100], + name: "Saturation", + }, + l: { + range: [0, 100], + name: "Lightness", + }, + }, + + base: sRGB, + + // Adapted from https://drafts.csswg.org/css-color-4/better-rgbToHsl.js + fromBase: rgb => { + let max = Math.max(...rgb); + let min = Math.min(...rgb); + let [r, g, b] = rgb; + let [h, s, l] = [null, 0, (min + max) / 2]; + let d = max - min; + + if (d !== 0) { + s = l === 0 || l === 1 ? 0 : (max - l) / Math.min(l, 1 - l); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + } + + h = h * 60; + } + + // Very out of gamut colors can produce negative saturation + // If so, just rotate the hue by 180 and use a positive saturation + // see https://github.com/w3c/csswg-drafts/issues/9222 + if (s < 0) { + h += 180; + s = Math.abs(s); + } + + if (h >= 360) { + h -= 360; + } + + return [h, s * 100, l * 100]; + }, + + // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative + toBase: hsl => { + let [h, s, l] = hsl; + h = h % 360; + + if (h < 0) { + h += 360; + } + + s /= 100; + l /= 100; + + function f (n) { + let k = (n + h / 30) % 12; + let a = s * Math.min(l, 1 - l); + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + } + + return [f(0), f(8), f(4)]; + }, + + formats: { + hsl: { + coords: [" | ", " | ", " | "], + }, + hsla: { + coords: [" | ", " | ", " | "], + commas: true, + alpha: true, + }, + }, + }); + + // Note that, like HSL, calculations are done directly on + // gamma-corrected sRGB values rather than linearising them first. + + var HSV = new ColorSpace({ + id: "hsv", + name: "HSV", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 100], + name: "Saturation", + }, + v: { + range: [0, 100], + name: "Value", + }, + }, + + base: sRGB, + // https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation + fromBase (rgb) { + let max = Math.max(...rgb); + let min = Math.min(...rgb); + let [r, g, b] = rgb; + let [h, s, v] = [null, 0, max]; + let d = max - min; + + if (d !== 0) { + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + } + + h = h * 60; + } + + if (v) { + s = d / v; + } + + if (h >= 360) { + h -= 360; + } + + return [h, s * 100, v * 100]; + }, + // Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative + toBase (hsv) { + let [h, s, v] = hsv; + h = h % 360; + + if (h < 0) { + h += 360; + } + + s /= 100; + v /= 100; + + function f (n) { + let k = (n + h / 60) % 6; + return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); + } + + return [f(5), f(3), f(1)]; + }, + + formats: { + color: { + id: "--hsv", + coords: [" | ", " | ", " | "], + }, + }, + }); + + // The Hue, Whiteness Blackness (HWB) colorspace + // See https://drafts.csswg.org/css-color-4/#the-hwb-notation + // Note that, like HSL, calculations are done directly on + // gamma-corrected sRGB values rather than linearising them first. + + var hwb = new ColorSpace({ + id: "hwb", + name: "HWB", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + w: { + range: [0, 100], + name: "Whiteness", + }, + b: { + range: [0, 100], + name: "Blackness", + }, + }, + + base: HSV, + fromBase (hsv) { + let [h, s, v] = hsv; + + return [h, (v * (100 - s)) / 100, 100 - v]; + }, + toBase (hwb) { + let [h, w, b] = hwb; + + // Now convert percentages to [0..1] + w /= 100; + b /= 100; + + // Achromatic check (white plus black >= 1) + let sum = w + b; + if (sum >= 1) { + let gray = w / sum; + return [h, 0, gray * 100]; + } + + let v = 1 - b; + let s = v === 0 ? 0 : 1 - w / v; + return [h, s * 100, v * 100]; + }, + + formats: { + hwb: { + coords: [" | ", " | ", " | "], + }, + }, + }); + + /** @import { Matrix3x3 } from "../types.js" */ + + // convert an array of linear-light a98-rgb values to CIE XYZ + // http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html + // has greater numerical precision than section 4.3.5.3 of + // https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf + // but the values below were calculated from first principles + // from the chromaticity coordinates of R G B W + + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M$2 = [ + [ 0.5766690429101305, 0.1855582379065463, 0.1882286462349947 ], + [ 0.29734497525053605, 0.6273635662554661, 0.07529145849399788 ], + [ 0.02703136138641234, 0.07068885253582723, 0.9913375368376388 ], + ]; + + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M$2 = [ + [ 2.0415879038107465, -0.5650069742788596, -0.34473135077832956 ], + [ -0.9692436362808795, 1.8759675015077202, 0.04155505740717557 ], + [ 0.013444280632031142, -0.11836239223101838, 1.0151749943912054 ], + ]; + + var A98Linear = new RGBColorSpace({ + id: "a98rgb-linear", + cssId: "--a98-rgb-linear", + name: "Linear Adobe® 98 RGB compatible", + white: "D65", + toXYZ_M: toXYZ_M$2, + fromXYZ_M: fromXYZ_M$2, + }); + + var a98rgb = new RGBColorSpace({ + id: "a98rgb", + cssId: "a98-rgb", + name: "Adobe® 98 RGB compatible", + base: A98Linear, + toBase: RGB => RGB.map(val => Math.pow(Math.abs(val), 563 / 256) * Math.sign(val)), + fromBase: RGB => RGB.map(val => Math.pow(Math.abs(val), 256 / 563) * Math.sign(val)), + }); + + /** @import { Matrix3x3 } from "../types.js" */ + + // convert an array of prophoto-rgb values to CIE XYZ + // using D50 (so no chromatic adaptation needed afterwards) + // matrix cannot be expressed in rational form, but is calculated to 64 bit accuracy + // see https://github.com/w3c/csswg-drafts/issues/7675 + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M$1 = [ + [ 0.79776664490064230, 0.13518129740053308, 0.03134773412839220 ], + [ 0.28807482881940130, 0.71183523424187300, 0.00008993693872564 ], + [ 0.00000000000000000, 0.00000000000000000, 0.82510460251046020 ], + ]; + + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M$1 = [ + [ 1.34578688164715830, -0.25557208737979464, -0.05110186497554526 ], + [ -0.54463070512490190, 1.50824774284514680, 0.02052744743642139 ], + [ 0.00000000000000000, 0.00000000000000000, 1.21196754563894520 ], + ]; + + var ProPhotoLinear = new RGBColorSpace({ + id: "prophoto-linear", + cssId: "--prophoto-rgb-linear", + name: "Linear ProPhoto", + white: "D50", + base: XYZ_D50, + toXYZ_M: toXYZ_M$1, + fromXYZ_M: fromXYZ_M$1, + }); + + const Et = 1 / 512; + const Et2 = 16 / 512; + + var prophoto = new RGBColorSpace({ + id: "prophoto", + cssId: "prophoto-rgb", + name: "ProPhoto", + base: ProPhotoLinear, + toBase (RGB) { + // Transfer curve is gamma 1.8 with a small linear portion + return RGB.map(v => { + let sign = v < 0 ? -1 : 1; + let abs = v * sign; + + if (abs < Et2) { + return v / 16; + } + + return sign * abs ** 1.8; + }); + }, + fromBase (RGB) { + return RGB.map(v => { + let sign = v < 0 ? -1 : 1; + let abs = v * sign; + + if (abs >= Et) { + return sign * abs ** (1 / 1.8); + } + + return 16 * v; + }); + }, + }); + + // import sRGB from "./srgb.js"; + + const α = 1.09929682680944; + const β = 0.018053968510807; + + var rec2020Oetf = new RGBColorSpace({ + id: "--rec2020-oetf", + name: "REC.2020_Scene_Referred", + base: REC_2020_Linear, + referred: "scene", + // Non-linear transfer function from Rec. ITU-R BT.2020-2 table 4 + toBase (RGB) { + return RGB.map(function (val) { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + + if (abs < β * 4.5) { + return val / 4.5; + } + + return sign * Math.pow((abs + α - 1) / α, 1 / 0.45); + }); + }, + fromBase (RGB) { + return RGB.map(function (val) { + let sign = val < 0 ? -1 : 1; + let abs = val * sign; + + if (abs >= β) { + return sign * (α * Math.pow(abs, 0.45) - (α - 1)); + } + + return 4.5 * val; + }); + }, + }); + + var oklch = new ColorSpace({ + id: "oklch", + name: "OkLCh", + coords: { + l: { + refRange: [0, 1], + name: "Lightness", + }, + c: { + refRange: [0, 0.4], + name: "Chroma", + }, + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + white: "D65", + + base: Oklab, + fromBase: lch.fromBase, + toBase: lch.toBase, + + formats: { + oklch: { + coords: [" | ", " | ", " | "], + }, + }, + }); + + // Okhsl class. + // + // ---- License ---- + // + // Copyright (c) 2021 Björn Ottosson + // + // Permission 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: + // + // The above copyright notice and this permission notice shall be included in all + // copies or substantial portions of the Software. + // + // THE 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. + + /** @import { Matrix3x3, Vector3 } from "../types.js" */ + + // Type re-exports + /** @typedef {import("../types.js").OKCoeff} OKCoeff */ + + const tau = 2 * Math.PI; + + /** @type {Matrix3x3} */ + // prettier-ignore + const toSRGBLinear = [ + [ 4.0767416360759583, -3.3077115392580629, 0.2309699031821043], + [-1.2684379732850315, 2.6097573492876882, -0.3413193760026570], + [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117], + ]; + + /** @type {OKCoeff} */ + const RGBCoeff = [ + // Red + [ + // Limit + [-1.8817031, -0.80936501], + // `Kn` coefficients + [1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245], + ], + // Green + [ + // Limit + [1.8144408, -1.19445267], + // `Kn` coefficients + [0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204], + ], + // Blue + [ + // Limit + [0.13110758, 1.81333971], + // `Kn` coefficients + [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167], + ], + ]; + + const floatMax = Number.MAX_VALUE; + const K1 = 0.206; + const K2 = 0.03; + const K3 = (1.0 + K1) / (1.0 + K2); + + function vdot (a, b) { + // Dot two vectors + + let l = a.length; + if (l !== b.length) { + throw new Error(`Vectors of size ${l} and ${b.length} are not aligned`); + } + + let s = 0.0; + a.forEach((c, i) => { + s += c * b[i]; + }); + + return s; + } + + /** + * Toe function for L_r + * @param {number} x + */ + function toe (x) { + return 0.5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x)); + } + + /** + * Inverse toe function for L_r + * @param {number} x + */ + function toeInv (x) { + return (x ** 2 + K1 * x) / (K3 * (x + K2)); + } + + /** + * @param {readonly [number, number]} cusp + * @returns {[number, number]} + */ + function toSt (cusp) { + // To ST. + + let [l, c] = cusp; + return [c / l, c / (1 - l)]; + } + + function getStMid (a, b) { + // Returns a smooth approximation of the location of the cusp. + // + // This polynomial was created by an optimization process. + // It has been designed so that S_mid < S_max and T_mid < T_max. + + // prettier-ignore + let s = 0.11516993 + 1.0 / ( + 7.44778970 + 4.15901240 * b + + a * ( + -2.19557347 + 1.75198401 * b + + a * ( + -2.13704948 - 10.02301043 * b + + a * ( + -4.24894561 + 5.38770819 * b + 4.69891013 * a + ) + ) + ) + ); + + // prettier-ignore + let t = 0.11239642 + 1.0 / ( + 1.61320320 - 0.68124379 * b + + a * ( + 0.40370612 + 0.90148123 * b + + a * ( + -0.27087943 + 0.61223990 * b + + a * ( + 0.00299215 - 0.45399568 * b - 0.14661872 * a + ) + ) + ) + ); + + return [s, t]; + } + + /** + * @param {Vector3} lab + * @param {Matrix3x3} lmsToRgb + */ + function oklabToLinearRGB (lab, lmsToRgb) { + // Convert from Oklab to linear RGB. + // + // Can be any gamut as long as `lmsToRgb` is a matrix + // that transform the LMS values to the linear RGB space. + + let lms = multiply_v3_m3x3(lab, LabtoLMS_M); + + lms[0] = lms[0] ** 3; + lms[1] = lms[1] ** 3; + lms[2] = lms[2] ** 3; + + return multiply_v3_m3x3(lms, lmsToRgb, lms); + } + + /** + * @param {number} a + * @param {number} b + * @param {Matrix3x3} lmsToRgb + * @param {OKCoeff} okCoeff + * @returns {[number, number]} + * @todo Could probably make these types more specific/better-documented if desired + */ + function findCusp (a, b, lmsToRgb, okCoeff) { + // Finds L_cusp and C_cusp for a given hue. + // + // `a` and `b` must be normalized so `a^2 + b^2 == 1`. + + // First, find the maximum saturation (saturation `S = C/L`) + let sCusp = computeMaxSaturation(a, b, lmsToRgb, okCoeff); + + // Convert to linear RGB to find the first point where at least one of r, g or b >= 1: + let rgb = oklabToLinearRGB([1, sCusp * a, sCusp * b], lmsToRgb); + let lCusp = spow(1.0 / Math.max(...rgb), 1 / 3); + let cCusp = lCusp * sCusp; + + return [lCusp, cCusp]; + } + + /** + * @param {number} a + * @param {number} b + * @param {number} l1 + * @param {number} c1 + * @param {number} l0 + * @param {Matrix3x3} lmsToRgb + * @param {OKCoeff} okCoeff + * @param {[number, number]} cusp + * @returns {Number} + * @todo Could probably make these types more specific/better-documented if desired + */ + function findGamutIntersection (a, b, l1, c1, l0, lmsToRgb, okCoeff, cusp) { + // Finds intersection of the line. + // + // Defined by the following: + // + // ``` + // L = L0 * (1 - t) + t * L1 + // C = t * C1 + // ``` + // + // `a` and `b` must be normalized so `a^2 + b^2 == 1`. + + let t; + + if (cusp === undefined) { + cusp = findCusp(a, b, lmsToRgb, okCoeff); + } + + // Find the intersection for upper and lower half separately + if ((l1 - l0) * cusp[1] - (cusp[0] - l0) * c1 <= 0.0) { + // Lower half + t = (cusp[1] * l0) / (c1 * cusp[0] + cusp[1] * (l0 - l1)); + } + else { + // Upper half + + // First intersect with triangle + t = (cusp[1] * (l0 - 1.0)) / (c1 * (cusp[0] - 1.0) + cusp[1] * (l0 - l1)); + + // Then one step Halley's method + let dl = l1 - l0; + let dc = c1; + + let kl = vdot(LabtoLMS_M[0].slice(1), [a, b]); + let km = vdot(LabtoLMS_M[1].slice(1), [a, b]); + let ks = vdot(LabtoLMS_M[2].slice(1), [a, b]); + + let ldt_ = dl + dc * kl; + let mdt_ = dl + dc * km; + let sdt_ = dl + dc * ks; + + // If higher accuracy is required, 2 or 3 iterations of the following block can be used: + let L = l0 * (1.0 - t) + t * l1; + let C = t * c1; + + let l_ = L + C * kl; + let m_ = L + C * km; + let s_ = L + C * ks; + + let l = l_ ** 3; + let m = m_ ** 3; + let s = s_ ** 3; + + let ldt = 3 * ldt_ * l_ ** 2; + let mdt = 3 * mdt_ * m_ ** 2; + let sdt = 3 * sdt_ * s_ ** 2; + + let ldt2 = 6 * ldt_ ** 2 * l_; + let mdt2 = 6 * mdt_ ** 2 * m_; + let sdt2 = 6 * sdt_ ** 2 * s_; + + let r_ = vdot(lmsToRgb[0], [l, m, s]) - 1; + let r1 = vdot(lmsToRgb[0], [ldt, mdt, sdt]); + let r2 = vdot(lmsToRgb[0], [ldt2, mdt2, sdt2]); + + let ur = r1 / (r1 * r1 - 0.5 * r_ * r2); + let tr = -r_ * ur; + + let g_ = vdot(lmsToRgb[1], [l, m, s]) - 1; + let g1 = vdot(lmsToRgb[1], [ldt, mdt, sdt]); + let g2 = vdot(lmsToRgb[1], [ldt2, mdt2, sdt2]); + + let ug = g1 / (g1 * g1 - 0.5 * g_ * g2); + let tg = -g_ * ug; + + let b_ = vdot(lmsToRgb[2], [l, m, s]) - 1; + let b1 = vdot(lmsToRgb[2], [ldt, mdt, sdt]); + let b2 = vdot(lmsToRgb[2], [ldt2, mdt2, sdt2]); + + let ub = b1 / (b1 * b1 - 0.5 * b_ * b2); + let tb = -b_ * ub; + + tr = ur >= 0.0 ? tr : floatMax; + tg = ug >= 0.0 ? tg : floatMax; + tb = ub >= 0.0 ? tb : floatMax; + + t += Math.min(tr, Math.min(tg, tb)); + } + + return t; + } + + function getCs (lab, lmsToRgb, okCoeff) { + // Get Cs + + let [l, a, b] = lab; + + let cusp = findCusp(a, b, lmsToRgb, okCoeff); + + let cMax = findGamutIntersection(a, b, l, 1, l, lmsToRgb, okCoeff, cusp); + let stMax = toSt(cusp); + + // Scale factor to compensate for the curved part of gamut shape: + let k = cMax / Math.min(l * stMax[0], (1 - l) * stMax[1]); + + let stMid = getStMid(a, b); + + // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma. + let ca = l * stMid[0]; + let cb = (1.0 - l) * stMid[1]; + let cMid = 0.9 * k * Math.sqrt(Math.sqrt(1.0 / (1.0 / ca ** 4 + 1.0 / cb ** 4))); + + // For `C_0`, the shape is independent of hue, so `ST` are constant. + // Values picked to roughly be the average values of `ST`. + ca = l * 0.4; + cb = (1.0 - l) * 0.8; + + // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma. + let c0 = Math.sqrt(1.0 / (1.0 / ca ** 2 + 1.0 / cb ** 2)); + + return [c0, cMid, cMax]; + } + + function computeMaxSaturation (a, b, lmsToRgb, okCoeff) { + // Finds the maximum saturation possible for a given hue that fits in RGB. + // + // Saturation here is defined as `S = C/L`. + // `a` and `b` must be normalized so `a^2 + b^2 == 1`. + + // Max saturation will be when one of r, g or b goes below zero. + + // Select different coefficients depending on which component goes below zero first. + + let k0, k1, k2, k3, k4, wl, wm, ws; + + if (vdot(okCoeff[0][0], [a, b]) > 1) { + // Red component + [k0, k1, k2, k3, k4] = okCoeff[0][1]; + [wl, wm, ws] = lmsToRgb[0]; + } + else if (vdot(okCoeff[1][0], [a, b]) > 1) { + // Green component + [k0, k1, k2, k3, k4] = okCoeff[1][1]; + [wl, wm, ws] = lmsToRgb[1]; + } + else { + // Blue component + [k0, k1, k2, k3, k4] = okCoeff[2][1]; + [wl, wm, ws] = lmsToRgb[2]; + } + + // Approximate max saturation using a polynomial: + let sat = k0 + k1 * a + k2 * b + k3 * a ** 2 + k4 * a * b; + + // Do one step Halley's method to get closer. + // This gives an error less than 10e6, except for some blue hues where the `dS/dh` is close to infinite. + // This should be sufficient for most applications, otherwise do two/three steps. + + let kl = vdot(LabtoLMS_M[0].slice(1), [a, b]); + let km = vdot(LabtoLMS_M[1].slice(1), [a, b]); + let ks = vdot(LabtoLMS_M[2].slice(1), [a, b]); + + let l_ = 1.0 + sat * kl; + let m_ = 1.0 + sat * km; + let s_ = 1.0 + sat * ks; + + let l = l_ ** 3; + let m = m_ ** 3; + let s = s_ ** 3; + + let lds = 3.0 * kl * l_ ** 2; + let mds = 3.0 * km * m_ ** 2; + let sds = 3.0 * ks * s_ ** 2; + + let lds2 = 6.0 * kl ** 2 * l_; + let mds2 = 6.0 * km ** 2 * m_; + let sds2 = 6.0 * ks ** 2 * s_; + + let f = wl * l + wm * m + ws * s; + let f1 = wl * lds + wm * mds + ws * sds; + let f2 = wl * lds2 + wm * mds2 + ws * sds2; + + sat = sat - (f * f1) / (f1 ** 2 - 0.5 * f * f2); + + return sat; + } + + function okhslToOklab (hsl, lmsToRgb, okCoeff) { + // Convert Okhsl to Oklab. + + let [h, s, l] = hsl; + let L = toeInv(l); + let a = null; + let b = null; + h = constrain(h) / 360.0; + + if (L !== 0.0 && L !== 1.0 && s !== 0) { + let a_ = Math.cos(tau * h); + let b_ = Math.sin(tau * h); + + let [c0, cMid, cMax] = getCs([L, a_, b_], lmsToRgb, okCoeff); + + // Interpolate the three values for C so that: + // ``` + // At s=0: dC/ds = C_0, C=0 + // At s=0.8: C=C_mid + // At s=1.0: C=C_max + // ``` + + let mid = 0.8; + let midInv = 1.25; + let t, k0, k1, k2; + + if (s < mid) { + t = midInv * s; + k0 = 0.0; + k1 = mid * c0; + k2 = 1.0 - k1 / cMid; + } + else { + t = 5 * (s - 0.8); + k0 = cMid; + k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0; + k2 = 1.0 - k1 / (cMax - cMid); + } + + let c = k0 + (t * k1) / (1.0 - k2 * t); + + a = c * a_; + b = c * b_; + } + + return [L, a, b]; + } + + function oklabToOkhsl (lab, lmsToRgb, okCoeff) { + // Oklab to Okhsl. + + // Epsilon for lightness should approach close to 32 bit lightness + // Epsilon for saturation just needs to be sufficiently close when denoting achromatic + let εL = 1e-7; + let εS = 1e-4; + let L = lab[0]; + let s = 0.0; + let l = toe(L); + + let c = Math.sqrt(lab[1] ** 2 + lab[2] ** 2); + let h = 0.5 + Math.atan2(-lab[2], -lab[1]) / tau; + + if (l !== 0.0 && l !== 1.0 && c !== 0) { + let a_ = lab[1] / c; + let b_ = lab[2] / c; + + let [c0, cMid, cMax] = getCs([L, a_, b_], lmsToRgb, okCoeff); + + let mid = 0.8; + let midInv = 1.25; + let k0, k1, k2, t; + + if (c < cMid) { + k1 = mid * c0; + k2 = 1.0 - k1 / cMid; + + t = c / (k1 + k2 * c); + s = t * mid; + } + else { + k0 = cMid; + k1 = (0.2 * cMid ** 2 * midInv ** 2) / c0; + k2 = 1.0 - k1 / (cMax - cMid); + + t = (c - k0) / (k1 + k2 * (c - k0)); + s = mid + 0.2 * t; + } + } + + const achromatic = Math.abs(s) < εS; + if (achromatic || l === 0.0 || Math.abs(1 - l) < εL) { + h = null; + // Due to floating point imprecision near lightness of 1, we can end up + // with really high around white, this is to provide consistency as + // saturation can be really high for white due this imprecision. + if (!achromatic) { + s = 0.0; + } + } + else { + h = constrain(h * 360); + } + + return [h, s, l]; + } + + var okhsl = new ColorSpace({ + id: "okhsl", + name: "Okhsl", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 1], + name: "Saturation", + }, + l: { + range: [0, 1], + name: "Lightness", + }, + }, + + base: Oklab, + gamutSpace: "self", + + // Convert Oklab to Okhsl + fromBase (lab) { + return oklabToOkhsl(lab, toSRGBLinear, RGBCoeff); + }, + + // Convert Okhsl to Oklab + toBase (hsl) { + return okhslToOklab(hsl, toSRGBLinear, RGBCoeff); + }, + + formats: { + color: { + id: "--okhsl", + coords: [" | ", " | ", " | "], + }, + }, + }); + + var OKLrab = new ColorSpace({ + id: "oklrab", + name: "Oklrab", + coords: { + l: { + refRange: [0, 1], + name: "Lightness", + }, + a: { + refRange: [-0.4, 0.4], + }, + b: { + refRange: [-0.4, 0.4], + }, + }, + + // Note that XYZ is relative to D65 + white: "D65", + + base: Oklab, + fromBase (oklab) { + return [toe(oklab[0]), oklab[1], oklab[2]]; + }, + toBase (oklrab) { + return [toeInv(oklrab[0]), oklrab[1], oklrab[2]]; + }, + + formats: { + color: { + coords: [ + " | ", + " | [-1,1]", + " | [-1,1]", + ], + }, + }, + }); + + var oklrch = new ColorSpace({ + id: "oklrch", + name: "Oklrch", + coords: { + l: { + refRange: [0, 1], + name: "Lightness", + }, + c: { + refRange: [0, 0.4], + name: "Chroma", + }, + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + white: "D65", + + base: OKLrab, + fromBase: lch.fromBase, + toBase: lch.toBase, + + formats: { + color: { + coords: [ + " | ", + " | [0,1]", + " | ", + ], + }, + }, + }); + + // Okhsv class. + // + // ---- License ---- + // + // Copyright (c) 2021 Björn Ottosson + // + // Permission 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: + // + // The above copyright notice and this permission notice shall be included in all + // copies or substantial portions of the Software. + // + // THE 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. + + /** @import { Coords, Matrix3x3, OKCoeff, Vector3 } from "../types.js" */ + + /** + * + * @param {Vector3} hsv + * @param {Matrix3x3} lmsToRgb + * @param {OKCoeff} okCoeff + * @returns {Coords} + */ + function okhsvToOklab (hsv, lmsToRgb, okCoeff) { + // Convert from Okhsv to Oklab.""" + + let [h, s, v] = hsv; + h = constrain(h) / 360.0; + + let l = toeInv(v); + let a = null; + let b = null; + + // Avoid processing gray or colors with undefined hues + if (l !== 0.0 && s !== 0.0) { + let a_ = Math.cos(tau * h); + let b_ = Math.sin(tau * h); + + let cusp = findCusp(a_, b_, lmsToRgb, okCoeff); + let [sMax, tMax] = toSt(cusp); + let s0 = 0.5; + let k = 1 - s0 / sMax; + + // first we compute L and V as if the gamut is a perfect triangle: + + // L, C when v==1: + let lv = 1 - (s * s0) / (s0 + tMax - tMax * k * s); + let cv = (s * tMax * s0) / (s0 + tMax - tMax * k * s); + + l = v * lv; + let c = v * cv; + + // then we compensate for both toe and the curved top part of the triangle: + let lvt = toeInv(lv); + let cvt = (cv * lvt) / lv; + + let lNew = toeInv(l); + c = (c * lNew) / l; + l = lNew; + + // RGB scale + let [rs, gs, bs] = oklabToLinearRGB([lvt, a_ * cvt, b_ * cvt], lmsToRgb); + let scaleL = spow(1.0 / Math.max(Math.max(rs, gs), Math.max(bs, 0.0)), 1 / 3); + + l = l * scaleL; + c = c * scaleL; + + a = c * a_; + b = c * b_; + } + + return [l, a, b]; + } + + /** + * + * @param {Vector3} lab + * @param {Matrix3x3} lmsToRgb + * @param {OKCoeff} okCoeff + * @returns {Coords} + */ + function oklabToOkhsv (lab, lmsToRgb, okCoeff) { + // Oklab to Okhsv. + + // Epsilon for saturation just needs to be sufficiently close when denoting achromatic + let ε = 1e-4; + let l = lab[0]; + let s = 0.0; + let v = toe(l); + let c = Math.sqrt(lab[1] ** 2 + lab[2] ** 2); + let h = 0.5 + Math.atan2(-lab[2], -lab[1]) / tau; + + if (l !== 0.0 && l !== 1 && c !== 0.0) { + let a_ = lab[1] / c; + let b_ = lab[2] / c; + + let cusp = findCusp(a_, b_, lmsToRgb, okCoeff); + let [sMax, tMax] = toSt(cusp); + let s0 = 0.5; + let k = 1 - s0 / sMax; + + // first we find `L_v`, `C_v`, `L_vt` and `C_vt` + let t = tMax / (c + l * tMax); + let lv = t * l; + let cv = t * c; + + let lvt = toeInv(lv); + let cvt = (cv * lvt) / lv; + + // we can then use these to invert the step that compensates + // for the toe and the curved top part of the triangle: + let [rs, gs, bs] = oklabToLinearRGB([lvt, a_ * cvt, b_ * cvt], lmsToRgb); + let scaleL = spow(1.0 / Math.max(Math.max(rs, gs), Math.max(bs, 0.0)), 1 / 3); + + l = l / scaleL; + c = c / scaleL; + + c = (c * toe(l)) / l; + l = toe(l); + + // we can now compute v and s: + v = l / lv; + s = ((s0 + tMax) * cv) / (tMax * s0 + tMax * k * cv); + } + + if (Math.abs(s) < ε || v === 0.0) { + h = null; + } + else { + h = constrain(h * 360); + } + + return [h, s, v]; + } + + var okhsv = new ColorSpace({ + id: "okhsv", + name: "Okhsv", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 1], + name: "Saturation", + }, + v: { + range: [0, 1], + name: "Value", + }, + }, + + base: Oklab, + gamutSpace: "self", + + // Convert Oklab to Okhsl + fromBase (lab) { + return oklabToOkhsv(lab, toSRGBLinear, RGBCoeff); + }, + + // Convert Okhsl to Oklab + toBase (hsl) { + return okhsvToOklab(hsl, toSRGBLinear, RGBCoeff); + }, + + formats: { + color: { + id: "--okhsv", + coords: [" | ", " | ", " | "], + }, + }, + }); + + let white = WHITES.D65; + + const ε$2 = 216 / 24389; // 6^3/29^3 == (24/116)^3 + const κ$1 = 24389 / 27; // 29^3/3^3 + const [U_PRIME_WHITE, V_PRIME_WHITE] = uv({ space: xyz_d65, coords: white }); + + var Luv = new ColorSpace({ + id: "luv", + name: "Luv", + coords: { + l: { + refRange: [0, 100], + name: "Lightness", + }, + // Reference ranges from https://facelessuser.github.io/coloraide/colors/luv/ + u: { + refRange: [-215, 215], + }, + v: { + refRange: [-215, 215], + }, + }, + + white: white, + base: xyz_d65, + + // Convert D65-adapted XYZ to Luv + // https://en.wikipedia.org/wiki/CIELUV#The_forward_transformation + fromBase (XYZ) { + let xyz = /** @type {[number, number, number]} */ ([ + skipNone(XYZ[0]), + skipNone(XYZ[1]), + skipNone(XYZ[2]), + ]); + let y = xyz[1]; + + let [up, vp] = uv({ space: xyz_d65, coords: xyz }); + + // Protect against XYZ of [0, 0, 0] + if (!Number.isFinite(up) || !Number.isFinite(vp)) { + return [0, 0, 0]; + } + + let L = y <= ε$2 ? κ$1 * y : 116 * Math.cbrt(y) - 16; + return [L, 13 * L * (up - U_PRIME_WHITE), 13 * L * (vp - V_PRIME_WHITE)]; + }, + + // Convert Luv to D65-adapted XYZ + // https://en.wikipedia.org/wiki/CIELUV#The_reverse_transformation + toBase (Luv) { + let [L, u, v] = Luv; + + // Protect against division by zero and none Lightness + if (L === 0 || isNone(L)) { + return [0, 0, 0]; + } + + u = skipNone(u); + v = skipNone(v); + + let up = u / (13 * L) + U_PRIME_WHITE; + let vp = v / (13 * L) + V_PRIME_WHITE; + + let y = L <= 8 ? L / κ$1 : Math.pow((L + 16) / 116, 3); + + return [y * ((9 * up) / (4 * vp)), y, y * ((12 - 3 * up - 20 * vp) / (4 * vp))]; + }, + + formats: { + color: { + id: "--luv", + coords: [ + " | ", + " | ", + " | ", + ], + }, + }, + }); + + var LCHuv = new ColorSpace({ + id: "lchuv", + name: "LChuv", + coords: { + l: { + refRange: [0, 100], + name: "Lightness", + }, + c: { + refRange: [0, 220], + name: "Chroma", + }, + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + }, + + base: Luv, + fromBase: lch.fromBase, + toBase: lch.toBase, + + formats: { + color: { + id: "--lchuv", + coords: [" | ", " | ", " | "], + }, + }, + }); + + /* + Adapted from: https://github.com/hsluv/hsluv-javascript/blob/14b49e6cf9a9137916096b8487a5372626b57ba4/src/hsluv.ts + + Copyright (c) 2012-2022 Alexei Boronine + + Permission 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: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE 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. + */ + + + const ε$1 = 216 / 24389; // 6^3/29^3 == (24/116)^3 + const κ = 24389 / 27; // 29^3/3^3 + + const m_r0 = fromXYZ_M$3[0][0]; + const m_r1 = fromXYZ_M$3[0][1]; + const m_r2 = fromXYZ_M$3[0][2]; + const m_g0 = fromXYZ_M$3[1][0]; + const m_g1 = fromXYZ_M$3[1][1]; + const m_g2 = fromXYZ_M$3[1][2]; + const m_b0 = fromXYZ_M$3[2][0]; + const m_b1 = fromXYZ_M$3[2][1]; + const m_b2 = fromXYZ_M$3[2][2]; + + function distanceFromOriginAngle (slope, intercept, angle) { + const d = intercept / (Math.sin(angle) - slope * Math.cos(angle)); + return d < 0 ? Infinity : d; + } + + /** + * @param {number} l + */ + function calculateBoundingLines (l) { + const sub1 = Math.pow(l + 16, 3) / 1560896; + const sub2 = sub1 > ε$1 ? sub1 : l / κ; + const s1r = sub2 * (284517 * m_r0 - 94839 * m_r2); + const s2r = sub2 * (838422 * m_r2 + 769860 * m_r1 + 731718 * m_r0); + const s3r = sub2 * (632260 * m_r2 - 126452 * m_r1); + const s1g = sub2 * (284517 * m_g0 - 94839 * m_g2); + const s2g = sub2 * (838422 * m_g2 + 769860 * m_g1 + 731718 * m_g0); + const s3g = sub2 * (632260 * m_g2 - 126452 * m_g1); + const s1b = sub2 * (284517 * m_b0 - 94839 * m_b2); + const s2b = sub2 * (838422 * m_b2 + 769860 * m_b1 + 731718 * m_b0); + const s3b = sub2 * (632260 * m_b2 - 126452 * m_b1); + + return { + r0s: s1r / s3r, + r0i: (s2r * l) / s3r, + r1s: s1r / (s3r + 126452), + r1i: ((s2r - 769860) * l) / (s3r + 126452), + g0s: s1g / s3g, + g0i: (s2g * l) / s3g, + g1s: s1g / (s3g + 126452), + g1i: ((s2g - 769860) * l) / (s3g + 126452), + b0s: s1b / s3b, + b0i: (s2b * l) / s3b, + b1s: s1b / (s3b + 126452), + b1i: ((s2b - 769860) * l) / (s3b + 126452), + }; + } + + function calcMaxChromaHsluv (lines, h) { + const hueRad = (h / 360) * Math.PI * 2; + const r0 = distanceFromOriginAngle(lines.r0s, lines.r0i, hueRad); + const r1 = distanceFromOriginAngle(lines.r1s, lines.r1i, hueRad); + const g0 = distanceFromOriginAngle(lines.g0s, lines.g0i, hueRad); + const g1 = distanceFromOriginAngle(lines.g1s, lines.g1i, hueRad); + const b0 = distanceFromOriginAngle(lines.b0s, lines.b0i, hueRad); + const b1 = distanceFromOriginAngle(lines.b1s, lines.b1i, hueRad); + + return Math.min(r0, r1, g0, g1, b0, b1); + } + + var hsluv = new ColorSpace({ + id: "hsluv", + name: "HSLuv", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 100], + name: "Saturation", + }, + l: { + range: [0, 100], + name: "Lightness", + }, + }, + + base: LCHuv, + gamutSpace: sRGB, + + // Convert LCHuv to HSLuv + fromBase (lch) { + let [l, c, h] = [skipNone(lch[0]), skipNone(lch[1]), skipNone(lch[2])]; + let s; + + if (l > 99.9999999) { + s = 0; + l = 100; + } + else if (l < 0.00000001) { + s = 0; + l = 0; + } + else { + let lines = calculateBoundingLines(l); + let max = calcMaxChromaHsluv(lines, h); + s = (c / max) * 100; + } + + return [h, s, l]; + }, + + // Convert HSLuv to LCHuv + toBase (hsl) { + let [h, s, l] = [skipNone(hsl[0]), skipNone(hsl[1]), skipNone(hsl[2])]; + let c; + + if (l > 99.9999999) { + l = 100; + c = 0; + } + else if (l < 0.00000001) { + l = 0; + c = 0; + } + else { + let lines = calculateBoundingLines(l); + let max = calcMaxChromaHsluv(lines, h); + c = (max / 100) * s; + } + + return [l, c, h]; + }, + + formats: { + color: { + id: "--hsluv", + coords: [" | ", " | ", " | "], + }, + }, + }); + + /* + Adapted from: https://github.com/hsluv/hsluv-javascript/blob/14b49e6cf9a9137916096b8487a5372626b57ba4/src/hsluv.ts + + Copyright (c) 2012-2022 Alexei Boronine + + Permission 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: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE 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. + */ + + + fromXYZ_M$3[0][0]; + fromXYZ_M$3[0][1]; + fromXYZ_M$3[0][2]; + fromXYZ_M$3[1][0]; + fromXYZ_M$3[1][1]; + fromXYZ_M$3[1][2]; + fromXYZ_M$3[2][0]; + fromXYZ_M$3[2][1]; + fromXYZ_M$3[2][2]; + + function distanceFromOrigin (slope, intercept) { + return Math.abs(intercept) / Math.sqrt(Math.pow(slope, 2) + 1); + } + + function calcMaxChromaHpluv (lines) { + let r0 = distanceFromOrigin(lines.r0s, lines.r0i); + let r1 = distanceFromOrigin(lines.r1s, lines.r1i); + let g0 = distanceFromOrigin(lines.g0s, lines.g0i); + let g1 = distanceFromOrigin(lines.g1s, lines.g1i); + let b0 = distanceFromOrigin(lines.b0s, lines.b0i); + let b1 = distanceFromOrigin(lines.b1s, lines.b1i); + + return Math.min(r0, r1, g0, g1, b0, b1); + } + + var hpluv = new ColorSpace({ + id: "hpluv", + name: "HPLuv", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + s: { + range: [0, 100], + name: "Saturation", + }, + l: { + range: [0, 100], + name: "Lightness", + }, + }, + + base: LCHuv, + gamutSpace: "self", + + // Convert LCHuv to HPLuv + fromBase (lch) { + let [l, c, h] = [skipNone(lch[0]), skipNone(lch[1]), skipNone(lch[2])]; + let s; + + if (l > 99.9999999) { + s = 0; + l = 100; + } + else if (l < 0.00000001) { + s = 0; + l = 0; + } + else { + let lines = calculateBoundingLines(l); + let max = calcMaxChromaHpluv(lines); + s = (c / max) * 100; + } + return [h, s, l]; + }, + + // Convert HPLuv to LCHuv + toBase (hsl) { + let [h, s, l] = [skipNone(hsl[0]), skipNone(hsl[1]), skipNone(hsl[2])]; + let c; + + if (l > 99.9999999) { + l = 100; + c = 0; + } + else if (l < 0.00000001) { + l = 0; + c = 0; + } + else { + let lines = calculateBoundingLines(l); + let max = calcMaxChromaHpluv(lines); + c = (max / 100) * s; + } + + return [l, c, h]; + }, + + formats: { + color: { + id: "--hpluv", + coords: [" | ", " | ", " | "], + }, + }, + }); + + var REC_2100_Linear = new RGBColorSpace({ + id: "rec2100-linear", + name: "Linear REC.2100", + white: "D65", + toBase: REC_2020_Linear.toBase, + fromBase: REC_2020_Linear.fromBase, + }); + + const Yw = 203; // absolute luminance of media white, cd/m² + const n = 2610 / 2 ** 14; + const ninv = 2 ** 14 / 2610; + const m = 2523 / 2 ** 5; + const minv = 2 ** 5 / 2523; + const c1 = 3424 / 2 ** 12; + const c2 = 2413 / 2 ** 7; + const c3 = 2392 / 2 ** 7; + + var rec2100Pq = new RGBColorSpace({ + id: "rec2100pq", + cssId: "rec2100-pq", + name: "REC.2100-PQ", + base: REC_2100_Linear, + toBase (RGB) { + // given PQ encoded component in range [0, 1] + // return media-white relative linear-light + return RGB.map(function (val) { + let x = (Math.max(val ** minv - c1, 0) / (c2 - c3 * val ** minv)) ** ninv; + return (x * 10000) / Yw; // luminance relative to diffuse white, [0, 70 or so]. + }); + }, + fromBase (RGB) { + // given media-white relative linear-light + // returnPQ encoded component in range [0, 1] + return RGB.map(function (val) { + let x = Math.max((val * Yw) / 10000, 0); // absolute luminance of peak white is 10,000 cd/m². + let num = c1 + c2 * x ** n; + let denom = 1 + c3 * x ** n; + + return (num / denom) ** m; + }); + }, + }); + + const a = 0.17883277; + const b = 0.28466892; // 1 - (4 * a) + const c = 0.55991073; // 0.5 - a * Math.log(4 *a) + + const scale = 3.7743; // Place 18% grey at HLG 0.38, so media white at 0.75 + + var rec2100Hlg = new RGBColorSpace({ + id: "rec2100hlg", + cssId: "rec2100-hlg", + name: "REC.2100-HLG", + referred: "scene", + + base: REC_2100_Linear, + toBase (RGB) { + // given HLG encoded component in range [0, 1] + // return media-white relative linear-light + return RGB.map(function (val) { + // first the HLG EOTF + // ITU-R BT.2390-10 p.30 section + // 6.3 The hybrid log-gamma electro-optical transfer function (EOTF) + // Then scale by 3 so media white is 1.0 + if (val <= 0.5) { + return (val ** 2 / 3) * scale; + } + return ((Math.exp((val - c) / a) + b) / 12) * scale; + }); + }, + fromBase (RGB) { + // given media-white relative linear-light + // where diffuse white is 1.0, + // return HLG encoded component in range [0, 1] + return RGB.map(function (val) { + // first scale to put linear-light media white at 1/3 + val /= scale; + // now the HLG OETF + // ITU-R BT.2390-10 p.23 + // 6.1 The hybrid log-gamma opto-electronic transfer function (OETF) + if (val <= 1 / 12) { + return spow(3 * val, 0.5); + } + return a * Math.log(12 * val - b) + c; + }); + }, + }); + + /** @import { White } from "./types.js" */ + + // Type re-exports + /** @typedef {import("./types.js").CAT} CAT */ + + /** @type {Record} */ + const CATs = {}; + + hooks.add("chromatic-adaptation-start", env => { + if (env.options.method) { + env.M = adapt(env.W1, env.W2, env.options.method); + } + }); + + hooks.add("chromatic-adaptation-end", env => { + if (!env.M) { + env.M = adapt(env.W1, env.W2, env.options.method); + } + }); + + function defineCAT (/** @type {CAT} */ { id, toCone_M, fromCone_M }) { + // Use id, toCone_M, fromCone_M like variables + CATs[id] = arguments[0]; + } + + /** + * + * @param {White} W1 + * @param {White} W2 + * @param {string} id + * @returns {number[][]} + */ + function adapt (W1, W2, id = "Bradford") { + // adapt from a source whitepoint or illuminant W1 + // to a destination whitepoint or illuminant W2, + // using the given chromatic adaptation transform (CAT) + // debugger; + let method = CATs[id]; + + let [ρs, γs, βs] = multiplyMatrices(method.toCone_M, W1); + let [ρd, γd, βd] = multiplyMatrices(method.toCone_M, W2); + + // all practical illuminants have non-zero XYZ so no division by zero can occur below + // prettier-ignore + let scale = [ + [ρd / ρs, 0, 0 ], + [0, γd / γs, 0 ], + [0, 0, βd / βs], + ]; + // console.log({scale}); + + let scaled_cone_M = multiplyMatrices(scale, method.toCone_M); + let adapt_M = multiplyMatrices(method.fromCone_M, scaled_cone_M); + // console.log({scaled_cone_M, adapt_M}); + return adapt_M; + } + + // prettier-ignore + defineCAT({ + id: "von Kries", + toCone_M: [ + [ 0.4002400, 0.7076000, -0.0808100 ], + [ -0.2263000, 1.1653200, 0.0457000 ], + [ 0.0000000, 0.0000000, 0.9182200 ], + ], + fromCone_M: [ + [ 1.8599363874558397, -1.1293816185800916, 0.21989740959619328 ], + [ 0.3611914362417676, 0.6388124632850422, -0.000006370596838649899 ], + [ 0, 0, 1.0890636230968613 ], + ], + }); + + // prettier-ignore + defineCAT({ + id: "Bradford", + // Convert an array of XYZ values in the range 0.0 - 1.0 + // to cone fundamentals + toCone_M: [ + [ 0.8951000, 0.2664000, -0.1614000 ], + [ -0.7502000, 1.7135000, 0.0367000 ], + [ 0.0389000, -0.0685000, 1.0296000 ], + ], + // and back + fromCone_M: [ + [ 0.9869929054667121, -0.14705425642099013, 0.15996265166373122 ], + [ 0.4323052697233945, 0.5183602715367774, 0.049291228212855594 ], + [ -0.00852866457517732, 0.04004282165408486, 0.96848669578755 ], + ], + }); + + // prettier-ignore + defineCAT({ + id: "CAT02", + // with complete chromatic adaptation to W2, so D = 1.0 + toCone_M: [ + [ 0.7328000, 0.4296000, -0.1624000 ], + [ -0.7036000, 1.6975000, 0.0061000 ], + [ 0.0030000, 0.0136000, 0.9834000 ], + ], + fromCone_M: [ + [ 1.0961238208355142, -0.27886900021828726, 0.18274517938277307 ], + [ 0.4543690419753592, 0.4735331543074117, 0.07209780371722911 ], + [ -0.009627608738429355, -0.00569803121611342, 1.0153256399545427 ], + ], + }); + + // prettier-ignore + defineCAT({ + id: "CAT16", + toCone_M: [ + [ 0.401288, 0.650173, -0.051461 ], + [ -0.250268, 1.204414, 0.045854 ], + [ -0.002079, 0.048952, 0.953127 ], + ], + // the extra precision is needed to avoid roundtripping errors + fromCone_M: [ + [ 1.862067855087233, -1.0112546305316845, 0.14918677544445172 ], + [ 0.3875265432361372, 0.6214474419314753, -0.008973985167612521 ], + [ -0.01584149884933386, -0.03412293802851557, 1.0499644368778496 ], + ], + }); + + // prettier-ignore + Object.assign(WHITES, { + // whitepoint values from ASTM E308-01 with 10nm spacing, 1931 2 degree observer + // all normalized to Y (luminance) = 1.00000 + // Illuminant A is a tungsten electric light, giving a very warm, orange light. + A: [1.09850, 1.00000, 0.35585], + + // Illuminant C was an early approximation to daylight: illuminant A with a blue filter. + C: [0.98074, 1.000000, 1.18232], + + // The daylight series of illuminants simulate natural daylight. + // The color temperature (in degrees Kelvin/100) ranges from + // cool, overcast daylight (D50) to bright, direct sunlight (D65). + D55: [0.95682, 1.00000, 0.92149], + D75: [0.94972, 1.00000, 1.22638], + + // Equal-energy illuminant, used in two-stage CAT16 + E: [1.00000, 1.00000, 1.00000], + + // The F series of illuminants represent fluorescent lights + F2: [0.99186, 1.00000, 0.67393], + F7: [0.95041, 1.00000, 1.08747], + F11: [1.00962, 1.00000, 0.64350], + }); + + /** @import { Matrix3x3 } from "../types.js" */ + + // The ACES whitepoint + // see TB-2018-001 Derivation of the ACES White Point CIE Chromaticity Coordinates + // also https://github.com/ampas/aces-dev/blob/master/documents/python/TB-2018-001/aces_wp.py + // Similar to D60 + WHITES.ACES = [0.32168 / 0.33767, 1.0, (1.0 - 0.32168 - 0.33767) / 0.33767]; + + // convert an array of linear-light ACEScc values to CIE XYZ + /** @type {Matrix3x3} */ + // prettier-ignore + const toXYZ_M = [ + [ 0.6624541811085053, 0.13400420645643313, 0.1561876870049078 ], + [ 0.27222871678091454, 0.6740817658111484, 0.05368951740793705 ], + [ -0.005574649490394108, 0.004060733528982826, 1.0103391003129971 ], + ]; + /** @type {Matrix3x3} */ + // prettier-ignore + const fromXYZ_M = [ + [ 1.6410233796943257, -0.32480329418479, -0.23642469523761225 ], + [ -0.6636628587229829, 1.6153315916573379, 0.016756347685530137 ], + [ 0.011721894328375376, -0.008284441996237409, 0.9883948585390215 ], + ]; + + var ACEScg = new RGBColorSpace({ + id: "acescg", + cssId: "--acescg", + name: "ACEScg", + + // ACEScg – A scene-referred, linear-light encoding of ACES Data + // https://docs.acescentral.com/specifications/acescg/ + // uses the AP1 primaries, see section 4.3.1 Color primaries + coords: { + r: { + range: [0, 65504], + name: "Red", + }, + g: { + range: [0, 65504], + name: "Green", + }, + b: { + range: [0, 65504], + name: "Blue", + }, + }, + + referred: "scene", + + white: WHITES.ACES, + + toXYZ_M, + fromXYZ_M, + }); + + // export default Color; + + const ε = 2 ** -16; + + // the smallest value which, in the 32bit IEEE 754 float encoding, + // decodes as a non-negative value + const ACES_min_nonzero = -0.35828683; + + // brightest encoded value, decodes to 65504 + const ACES_cc_max = (Math.log2(65504) + 9.72) / 17.52; // 1.468 + + var acescc = new RGBColorSpace({ + id: "acescc", + cssId: "--acescc", + name: "ACEScc", + // see S-2014-003 ACEScc – A Logarithmic Encoding of ACES Data + // https://docs.acescentral.com/specifications/acescc/ + // uses the AP1 primaries, see section 4.3.1 Color primaries + + // Appendix A: "Very small ACES scene referred values below 7 1/4 stops + // below 18% middle gray are encoded as negative ACEScc values. + // These values should be preserved per the encoding in Section 4.4 + // so that all positive ACES values are maintained." + coords: { + r: { + range: [ACES_min_nonzero, ACES_cc_max], + name: "Red", + }, + g: { + range: [ACES_min_nonzero, ACES_cc_max], + name: "Green", + }, + b: { + range: [ACES_min_nonzero, ACES_cc_max], + name: "Blue", + }, + }, + referred: "scene", + + base: ACEScg, + // from section 4.4.2 Decoding Function + toBase (RGB) { + const low = (9.72 - 15) / 17.52; // -0.3014 + + return RGB.map(function (val) { + if (val <= low) { + return (2 ** (val * 17.52 - 9.72) - ε) * 2; // very low values, below -0.3014 + } + else if (val < ACES_cc_max) { + return 2 ** (val * 17.52 - 9.72); + } + else { + // val >= ACES_cc_max + return 65504; + } + }); + }, + + // Non-linear encoding function from S-2014-003, section 4.4.1 Encoding Function + fromBase (RGB) { + return RGB.map(function (val) { + if (val <= 0) { + return (Math.log2(ε) + 9.72) / 17.52; // -0.3584 + } + else if (val < ε) { + return (Math.log2(ε + val * 0.5) + 9.72) / 17.52; + } + else { + // val >= ε + return (Math.log2(val) + 9.72) / 17.52; + } + }); + }, + // encoded media white (rgb 1,1,1) => linear [ 222.861, 222.861, 222.861 ] + // encoded media black (rgb 0,0,0) => linear [ 0.0011857, 0.0011857, 0.0011857] + }); + + /** + * @packageDocumentation + * Re-exports all the spaces built into Color.js. + */ + + var spaces = /*#__PURE__*/Object.freeze({ + __proto__: null, + A98RGB: a98rgb, + A98RGB_Linear: A98Linear, + ACEScc: acescc, + ACEScg: ACEScg, + CAM16_JMh: cam16, + HCT: hct, + HPLuv: hpluv, + HSL: hsl, + HSLuv: hsluv, + HSV: HSV, + HWB: hwb, + ICTCP: ictcp, + JzCzHz: jzczhz, + Jzazbz: Jzazbz, + LCH: lch, + LCHuv: LCHuv, + Lab: lab, + Lab_D65: lab_d65, + Luv: Luv, + OKLCH: oklch, + OKLab: Oklab, + OKLrCH: oklrch, + OKLrab: OKLrab, + Okhsl: okhsl, + Okhsv: okhsv, + P3: P3, + P3_Linear: P3Linear, + ProPhoto: prophoto, + ProPhoto_Linear: ProPhotoLinear, + REC_2020: REC2020, + REC_2020_Linear: REC_2020_Linear, + REC_2020_Scene_Referred: rec2020Oetf, + REC_2100_HLG: rec2100Hlg, + REC_2100_Linear: REC_2100_Linear, + REC_2100_PQ: rec2100Pq, + XYZ_ABS_D65: XYZ_Abs_D65, + XYZ_D50: XYZ_D50, + XYZ_D65: xyz_d65, + sRGB: sRGB, + sRGB_Linear: sRGBLinear + }); + + /** + * @packageDocumentation + * @class Color + * Class that represents a single color. + * All of Color.js’s tree-shakeable methods are also available as instance methods on this class, + * as well as static methods that take the color as the first argument. + */ + + + class Color { + /** + * Creates an instance of Color. + * Signatures: + * - `new Color(stringToParse)` + * - `new Color(otherColor)` + * - `new Color({space, coords, alpha})` + * - `new Color(space, coords, alpha)` + * - `new Color(spaceId, coords, alpha)` + */ + constructor (...args) { + let color; + + if (args.length === 1) { + let parseMeta = {}; + // Clone simple objects to avoid mutating original in getColor + if ( + typeof args[0] === "object" && + Object.getPrototypeOf(args[0]).constructor === Object + ) { + args[0] = { ...args[0] }; + } + color = getColor(args[0], { parseMeta }); + + if (parseMeta.format) { + // Color actually came from a string + this.parseMeta = parseMeta; + } + } + + let space, coords, alpha; + + if (color) { + space = color.space || color.spaceId; + coords = color.coords; + alpha = color.alpha; + } + else { + // default signature new Color(ColorSpace, array [, alpha]) + [space, coords, alpha] = args; + } + + Object.defineProperty(this, "space", { + value: ColorSpace.get(space), + writable: false, + enumerable: true, + configurable: true, // see note in https://262.ecma-international.org/8.0/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver + }); + + this.coords = coords ? coords.slice() : [0, 0, 0]; + + // Clamp alpha to [0, 1] + this.alpha = isNone(alpha) ? alpha : alpha === undefined ? 1 : clamp(0, alpha, 1); + + // Define getters and setters for each coordinate + for (let id in this.space.coords) { + Object.defineProperty(this, id, { + get: () => this.get(id), + set: value => this.set(id, value), + }); + } + } + + get spaceId () { + return this.space.id; + } + + clone () { + return new Color(this.space, this.coords, this.alpha); + } + + toJSON () { + return { + spaceId: this.spaceId, + coords: this.coords, + alpha: this.alpha, + }; + } + + display (...args) { + let ret = display(this, ...args); + + // Convert color object to Color instance + ret.color = new Color(ret.color); + + return ret; + } + + /** + * Get a color from the argument(s) passed + * Basically gets us the same result as new Color(color) but doesn't clone an existing color object + */ + static get (color, ...args) { + if (isInstance(color, this)) { + return color; + } + + return new Color(color, ...args); + } + + /** + * Get a color instance from the argument passed or `null` if resolution fails (instead of throwing an error). + * Additionally, it supports passing an element to resolve complex CSS colors through the DOM (slow). + * @see {@link tryColor} for more details + */ + static try (color, options) { + if (isInstance(color, this)) { + return color; + } + + let ret = tryColor(color, options); + + if (ret) { + return new Color(ret); + } + + return null; + } + + static defineFunction (name, code, o = code) { + let { instance = true, returns } = o; + + let func = function (...args) { + let ret = code(...args); + + if (returns === "color") { + ret = Color.get(ret); + } + else if (returns === "function") { + let f = ret; + ret = function (...args) { + let ret = f(...args); + return Color.get(ret); + }; + // Copy any function metadata + Object.assign(ret, f); + } + else if (returns === "array") { + ret = ret.map(c => Color.get(c)); + } + + return ret; + }; + + if (!(name in Color)) { + Color[name] = func; + } + + if (instance) { + Color.prototype[name] = function (...args) { + return func(this, ...args); + }; + } + } + + static defineFunctions (o) { + for (let name in o) { + Color.defineFunction(name, o[name], o[name]); + } + } + + static extend (exports) { + if (exports.register) { + exports.register(Color); + } + else { + // No register method, just add the module's functions + for (let name in exports) { + Color.defineFunction(name, exports[name]); + } + } + } + } + + Color.defineFunctions({ + get, + getAll, + set, + setAll, + to, + equals, + inGamut, + toGamut, + distance, + deltas, + toString: serialize, + }); + + Object.assign(Color, { + util, + hooks, + WHITES, + Space: ColorSpace, + spaces: ColorSpace.registry, + parse, + + // Global defaults one may want to configure + defaults, + }); + + /** + * @packageDocumentation + * This module contains {@link spaces a namespace} with all the spaces built into Color.js. + */ + + for (let key of Object.keys(spaces)) { + ColorSpace.register(spaces[key]); + } + + /** + * This plugin defines getters and setters for color[spaceId] + * e.g. color.lch on *any* color gives us the lch coords + */ + + // Add space accessors to existing color spaces + for (let id in ColorSpace.registry) { + addSpaceAccessors(id, ColorSpace.registry[id]); + } + + // Add space accessors to color spaces not yet created + hooks.add("colorspace-init-end", space => { + addSpaceAccessors(space.id, space); + space.aliases?.forEach(alias => { + addSpaceAccessors(alias, space); + }); + }); + + function addSpaceAccessors (id, space) { + let propId = id.replace(/-/g, "_"); + + Object.defineProperty(Color.prototype, propId, { + // Convert coords to coords in another colorspace and return them + // Source colorspace: this.spaceId + // Target colorspace: id + get () { + let ret = this.getAll(id); + + if (typeof Proxy === "undefined") { + // If proxies are not supported, just return a static array + return ret; + } + + // Enable color.spaceId.coordName syntax + let proxy = new Proxy(ret, { + has: /** @param {string} property */ (obj, property) => { + try { + ColorSpace.resolveCoord([space, property]); + return true; + } + catch (e) {} + + return Reflect.has(obj, property); + }, + get: (obj, property, receiver) => { + if ( + property && + typeof property !== "symbol" && + !(property in obj) && + property in proxy + ) { + let { index } = ColorSpace.resolveCoord([space, property]); + + if (index >= 0) { + return obj[index]; + } + } + + return Reflect.get(obj, property, receiver); + }, + set: (obj, property, value, receiver) => { + if ( + (property && typeof property !== "symbol" && !(property in obj)) || + Number(property) >= 0 + ) { + let { index } = ColorSpace.resolveCoord([ + space, + /** @type {string} */ (property), + ]); + + if (index >= 0) { + obj[index] = value; + + // Update color.coords + this.setAll(id, obj); + + return true; + } + } + + return Reflect.set(obj, property, value, receiver); + }, + }); + + return proxy; + }, + // Convert coords in another colorspace to internal coords and set them + // Target colorspace: this.spaceId + // Source colorspace: id + set (coords) { + this.setAll(id, coords); + }, + configurable: true, + enumerable: true, + }); + } + + /** + * Entry point for the OOP flavor of the API + * Import as `colorjs.io` + */ + + Color.extend(deltaEMethods); + Color.extend({ deltaE }); + Object.assign(Color, { deltaEMethods }); + Color.extend(variations); + Color.extend({ contrast }); + Color.extend(chromaticity); + Color.extend(luminance); + Color.extend(interpolation); + Color.extend(contrastMethods); + + return Color; + +})(); +//# sourceMappingURL=color.global.js.map diff --git a/luci-app-aurora-config/.dev/src/resource/utils/color.global.js.license b/luci-app-aurora-config/.dev/src/resource/utils/color.global.js.license new file mode 100644 index 00000000..22406678 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/color.global.js.license @@ -0,0 +1,47 @@ +colorjs.io -- https://colorjs.io -- MIT License +Copyright (c) 2021 Lea Verou, Chris Lilley + +This build embeds the following, each under the MIT License: + +The MIT License (MIT) +Copyright (c) 2024 Matt DesLauriers + +Permission 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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE 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. +---------------------------------------------------------------- + +Copyright (c) 2021 Björn Ottosson + +Permission 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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE 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. + diff --git a/luci-app-aurora-config/.dev/src/resource/utils/feed-check.js b/luci-app-aurora-config/.dev/src/resource/utils/feed-check.js new file mode 100644 index 00000000..ed96476b --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/feed-check.js @@ -0,0 +1,52 @@ +"use strict"; +"require baseclass"; + +// 更新源相关的纯判断:版本比对与软件包页定位。 +// 刻意不碰 DOM、不发请求 —— 这样它们能被真正单元测试,而不是只做正则断言。 + +// 软件包页的地址在各 OpenWrt 版本间不一致(主题支持 23.05 及以后): +// snapshot 上实测是 package-manager,更早的版本用 opkg。不硬编码猜测,而是 +// 从 ui.menu.load() 的真实菜单树里找,顺带覆盖两种本来要单独处理的情况: +// 用户无权访问该页(节点带 satisfied:false),以及精简固件根本没装这个页面。 +const PACKAGE_PAGE_CANDIDATES = ["package-manager", "opkg", "packages"]; + +return baseclass.extend({ + PACKAGE_PAGE_CANDIDATES: PACKAGE_PAGE_CANDIDATES, + + // "1.1.3-r20260803" → 20260803;其余形态一律 null。 + parseRevision: function (version) { + const match = /-r(\d{8})\b/.exec(String(version == null ? "" : version)); + return match ? Number(match[1]) : null; + }, + + // install.sh 写明 opkg 与 apk 的版本方案不可比较、脚本从不排序它们。 + // 这里沿用同一条纪律:只有两边都带 rYYYYMMDD 戳时才敢说"有新版"。 + // 判不出来就沉默 —— 误报"有新版"比不报更糟。 + isNewer: function (installed, available) { + const a = this.parseRevision(installed); + const b = this.parseRevision(available); + if (a === null || b === null) return false; + return b > a; + }, + + // 从 manifest.json 里取某渠道某包的版本。结构由 feed 的 gen-manifest.sh + // 生成:channels[渠道][格式] 是一个 {pkg, version} 数组。 + findManifestVersion: function (manifest, channel, format, pkg) { + const entries = manifest?.channels?.[channel]?.[format]; + if (!Array.isArray(entries)) return null; + const hit = entries.find((entry) => entry?.pkg === pkg); + return hit?.version || null; + }, + + pickPackageManagerPath: function (menuTree) { + const system = menuTree?.children?.admin?.children?.system?.children; + if (!system) return null; + const name = PACKAGE_PAGE_CANDIDATES.find((candidate) => { + const node = system[candidate]; + // ui.menu 保留无权限的节点但标记 satisfied:false(getChildren 就是按它 + // 过滤的)。不检查这个的话,会给没权限的用户渲染出一条点了吃 403 的链接。 + return node && node.satisfied !== false; + }); + return name ? `admin/system/${name}` : null; + }, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/hub-api.js b/luci-app-aurora-config/.dev/src/resource/utils/hub-api.js new file mode 100644 index 00000000..8f038217 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/hub-api.js @@ -0,0 +1,385 @@ +"use strict"; +"require baseclass"; +"require rpc"; +"require utils.notices as notices"; + +const CACHE_KEY = "aurora.hub.list"; +const ME_CACHE_KEY = "aurora.hub.me"; +const NOTICES_CACHE_KEY = "aurora.hub.notices"; +const INBOX_READ_KEY = "aurora.hub.inboxRead"; +const INBOX_DONE_KEY = "aurora.hub.inboxDone"; +const NOTICES_CHECKED_KEY = "aurora.hub.noticesChecked"; + +const HUB_BASE = "https://themes.eamonxg.fun"; +const HUB_TIMEOUT_MS = 15000; +const HUB_NOT_FOUND = "invalid_id"; + +// 本客户端读得懂的最高 payload schema。升 schema 时只改这一处。 +const CLIENT_SCHEMA = 1; + +// 浏览器直连 hub 的读路径。返回包络刻意与 rpcd 保持一致 +// ({result:0,data} / {result:1,error}),marketplace.js 无需感知传输方式的变化。 +const hubFetch = (path) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HUB_TIMEOUT_MS); + return fetch(HUB_BASE + path, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((res) => { + if (res.status === 404) return { result: 1, error: HUB_NOT_FOUND }; + if (!res.ok) return { result: 1, error: "hub_unreachable" }; + return res.json().then((data) => ({ result: 0, data })); + }) + .catch(() => ({ result: 1, error: "hub_unreachable" })) + .finally(() => clearTimeout(timer)); +}; + +// 资产字节走浏览器,不走路由器。原因不是"更快"(虽然确实快),是 OpenWrt 的 +// uclient-fetch 走 TLS 根本推不动:实测同一台机器,512KB 三次里挂一次、1MB +// 必断("Connection reset prematurely"),而同机 curl 传 1.6MB 只要 3.4 秒 +// —— 是它的 TLS 写路径在缓冲填满后不再续写。浏览器 fetch/XHR 没这个毛病。 +// +// 浏览器拿到的是一张票据,不是 device_token:票据把 (draft_id, kind, size, +// sha256) 全钉死,泄漏了也只能把同一份字节再传一遍。device_token 是创作者 +// 身份本身 —— 谁拿到谁能以你的名义发布、删掉你所有作品 —— 它留在路由器 +// 那个 0600 的文件里。 +// +// 用 XHR 而不是 fetch,只为了 upload.onprogress:一张 1.2MB 的图在慢上行的 +// 线路上要传十几秒,没有进度条的等待会被当成卡死。 +const putAssetBytes = (url, ticket, blob, onProgress) => + new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url, true); + xhr.setRequestHeader("Authorization", "Bearer " + ticket); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.timeout = 120000; + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); + }; + xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300); + xhr.onerror = () => resolve(false); + xhr.ontimeout = () => resolve(false); + xhr.send(blob); + }); + +// hub 的资产 url 是相对路径("/assets/{id}/{kind}"),和详情接口一直以来的 +// 返回一致 —— 拼接 base 是客户端的活,这样缓存下来的列表里不会烙进主机名。 +// HUB_BASE 归本模块所有,所以拼接也放在这里。 +// +// 路径来自 hub,属于不可信输入,因此按 hub 唯一可能产出的形状做匹配,而不是 +// 直接拼。其它一律返回 "",调用方退回纯色块。 +const ASSET_PATH_RE = /^\/assets\/[A-Za-z0-9]{1,32}\/[a-z0-9_]{1,32}$/; + +// 两份 localStorage 缓存的共同实现。它们只作首帧的种子:render 先用缓存画出 +// 画面,随后 fetchSort / refreshMyShares 的结果无条件覆盖 —— 所以刻意不设 +// TTL。到 hub 的每次往返都是 205ms 起步、首连约 800ms(TLS 握手不可复用), +// 一份"旧但立刻可用"的数据,比一次准确的白屏有用得多。 +// +// 信封保留 timestamp:它没有读者,但在真机上翻 localStorage 时,它是唯一能看 +// 出这帧画面有多旧的东西,而且保持了与旧版本写入的条目同形,无需迁移。 +const makeCache = (key, label) => ({ + getStale() { + try { + const cached = localStorage.getItem(key); + if (!cached) return null; + return JSON.parse(cached).value ?? null; + } catch (e) { + return null; + } + }, + + set(value) { + try { + localStorage.setItem(key, JSON.stringify({ timestamp: Date.now(), value })); + } catch (e) { + console.error("Failed to cache hub " + label + " data:", e); + } + }, + + clear() { + try { + localStorage.removeItem(key); + } catch (e) {} + }, +}); + +const readStored = (key, fallback) => { + try { + const parsed = JSON.parse(localStorage.getItem(key)); + return parsed == null ? fallback : parsed; + } catch (e) { + return fallback; + } +}; + +const writeStored = (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) {} +}; + +const patchSnapshot = (cache, patch) => { + const stale = cache.getStale(); + const snapshot = Object.assign( + { + notices: notices.sanitize(stale && stale.notices), + local: (stale && stale.local) || null, + }, + patch, + ); + cache.set(snapshot); + return snapshot; +}; + +return baseclass.extend({ + listCache: makeCache(CACHE_KEY, "list"), + + meCache: makeCache(ME_CACHE_KEY, "me"), + + // { notices, local } + noticesCache: makeCache(NOTICES_CACHE_KEY, "notices"), + + CLIENT_SCHEMA: CLIENT_SCHEMA, + + callHubList(sort, page) { + const safeSort = sort === "new" ? "new" : "hot"; + const safePage = Number.isInteger(page) && page > 0 ? page : 1; + return hubFetch( + "/api/v1/themes/aurora/configs?sort=" + + safeSort + + "&page=" + + safePage + + "&schema=" + + CLIENT_SCHEMA, + ); + }, + + callHubNotices() { + return hubFetch("/api/v1/notices?theme=aurora&schema=" + CLIENT_SCHEMA).then((res) => + res.error === HUB_NOT_FOUND ? { result: 0, data: { notices: [] } } : res, + ); + }, + + noticesCheckedAt() { + const stamp = readStored(NOTICES_CHECKED_KEY, 0); + return Number.isFinite(stamp) ? stamp : 0; + }, + + markNoticesChecked(nowMs) { + writeStored(NOTICES_CHECKED_KEY, nowMs); + }, + + inboxState() { + const keys = (key) => { + const list = readStored(key, []); + return Array.isArray(list) ? list.filter((item) => typeof item === "string") : []; + }; + return { read: keys(INBOX_READ_KEY), done: keys(INBOX_DONE_KEY) }; + }, + + setInboxState(state) { + writeStored(INBOX_READ_KEY, state.read); + writeStored(INBOX_DONE_KEY, state.done); + }, + + // local 随 feed 一起落缓存:preload 在两次轮询之间只读缓存, + // 不为"缺不缺更新源"去问 ubus。 + setNoticesLocal(local) { + return patchSnapshot(this.noticesCache, { local: local }); + }, + + // options.me:调用方自己已经在跑 hub_me 时,把那份结果(或它的 promise)传 + // 进来。不传则只在 feed 里有 creators 通知、或缓存显示本机发过作品时才调 + // hub_me(路由器 → hub,约 1 s)。 + refreshNotices(options) { + const opts = options || {}; + const supplied = + opts.me === undefined ? null : Promise.resolve(opts.me).catch(() => null); + this.markNoticesChecked(Date.now()); + const probed = L.resolveDefault(this.callGetInitData(), null).then((reply) => + notices.localState(reply), + ); + return Promise.all([this.callHubNotices(), probed]).then(([res, fresh]) => { + const stale = this.noticesCache.getStale(); + const local = fresh || (stale && stale.local) || null; + const known = { feed: Boolean(res && res.result === 0), me: false, local: Boolean(fresh) }; + const snapshot = { + notices: notices.sanitize(known.feed ? res.data && res.data.notices : stale && stale.notices), + local: local, + }; + const list = snapshot.notices; + const cachedMe = this.meCache.getStale(); + const ask = () => + known.feed && + (list.some((notice) => notice.audience === "creators") || notices.hasShares(cachedMe)) + ? L.resolveDefault(this.callHubMe(), null).then((reply) => { + const me = reply && reply.result === 0 && reply.data ? reply.data : null; + if (me) this.meCache.set(me); + return me; + }) + : Promise.resolve(null); + return (supplied || ask()).then((me) => { + this.noticesCache.set(snapshot); + const active = notices.inbox(snapshot, me || cachedMe, null).map((item) => item.key); + const state = this.inboxState(); + known.me = Boolean(me); + this.setInboxState({ + read: notices.pruneKeys(state.read, active, known), + done: notices.pruneKeys(state.done, active, known), + }); + return snapshot; + }); + }); + }, + + callHubGet(id) { + if (!/^[A-Za-z0-9]+$/.test(String(id || ""))) { + return Promise.resolve({ result: 1, error: HUB_NOT_FOUND }); + } + return hubFetch("/api/v1/themes/aurora/configs/" + id); + }, + + hubAssetUrl(path) { + return typeof path === "string" && ASSET_PATH_RE.test(path) + ? HUB_BASE + path + : ""; + }, + + callHubApply: rpc.declare({ + object: "luci.aurora", + method: "hub_apply", + params: ["id"], + }), + + callGetHubStatus: rpc.declare({ + object: "luci.aurora", + method: "get_hub_status", + params: ["job_id"], + }), + + callHubRestore: rpc.declare({ + object: "luci.aurora", + method: "hub_restore_backup", + }), + + // 纯本地:回答「路由器现在穿的是哪一套、还是不是那一套、被盖掉的那套长什么 + // 样」。不走 hub,所以不进上面那套缓存 —— 它便宜到可以每次开页面都问一遍。 + callHubLocalState: rpc.declare({ + object: "luci.aurora", + method: "hub_local_state", + }), + + // 三段式发布的两端。中间那段(资产字节)由 publishCurrentConfig 用浏览器 + // 直接 PUT 到 hub —— 见下。target_id 为空串是新发布、非空是更新那一条, + // 所以"更新分享"不需要自己的一套方法:它跟发布本来就是同一件事,只差目标。 + callHubShareBegin: rpc.declare({ + object: "luci.aurora", + method: "hub_share_begin", + params: ["name", "description", "target_id"], + }), + + callHubShareCommit: rpc.declare({ + object: "luci.aurora", + method: "hub_share_commit", + params: ["draft_id"], + }), + + // 发布一整套配置:路由器建草稿并拿票据 -> 浏览器把字节直接送到 hub -> + // 路由器提交。返回的信封与其它 hub 调用一致({result:0,...})。 + publishCurrentConfig(options) { + const opts = options || {}; + const report = (info) => { + if (typeof opts.onProgress === "function") opts.onProgress(info); + }; + + report({ phase: "begin" }); + return L.resolveDefault( + this.callHubShareBegin(opts.name, opts.description || "", opts.targetId || ""), + null, + ).then((begun) => { + if (!begun || begun.result !== 0) { + return { result: 1, error: (begun && begun.error) || "hub_unreachable" }; + } + // 内容与已有的一模一样:hub 直接给了那条的 id,一个字节都不必传。 + if (begun.duplicate) return { result: 0, id: begun.id, duplicate: true }; + + const entries = begun.assets || []; + let index = 0; + + // 顺序上传,不并发:一台路由器的上行本来就窄,几条流互相抢只会让每一条 + // 都变慢,而进度条上也读不出"到底传到哪了"。 + const next = () => { + if (index >= entries.length) { + report({ phase: "commit" }); + // assets 是这一趟真正传上去的份数。调用方据此决定要不要提审核 —— + // 它已经在手里,不必为一句话再往 hub 跑一趟。 + return L.resolveDefault(this.callHubShareCommit(begun.draft_id), null).then( + (done) => + done && done.result === 0 + ? { result: 0, id: done.id, assets: entries.length } + : { result: 1, error: (done && done.error) || "hub_unreachable" }, + ); + } + + const entry = entries[index]; + const position = { kind: entry.kind, index: index + 1, count: entries.length }; + index += 1; + + // 字节从路由器自己的 web 服务器上读,同源,不需要任何凭证。 + return fetch(entry.src) + .then((res) => (res.ok ? res.blob() : null)) + .catch(() => null) + .then((blob) => { + if (!blob) return { result: 1, error: "asset_unreadable" }; + report({ phase: "upload", loaded: 0, total: blob.size, ...position }); + return putAssetBytes(HUB_BASE + entry.url, entry.ticket, blob, (loaded, total) => + report({ phase: "upload", loaded, total, ...position }), + ).then((ok) => (ok ? next() : { result: 1, error: "asset_upload_failed" })); + }); + }; + + return next(); + }); + }, + + callHubSetNickname: rpc.declare({ + object: "luci.aurora", + method: "hub_set_nickname", + params: ["nickname"], + }), + + callGetInitData: rpc.declare({ + object: "luci.aurora", + method: "get_init_data", + }), + + callAddFeed: rpc.declare({ + object: "luci.aurora", + method: "add_feed", + }), + + callHubMe: rpc.declare({ + object: "luci.aurora", + method: "hub_me", + }), + + // Both sit behind the write ACL: the key is the creator account's password, + // so reading it out is as much a privileged act as replacing it. + callHubExportKey: rpc.declare({ + object: "luci.aurora", + method: "hub_export_key", + }), + + callHubImportKey: rpc.declare({ + object: "luci.aurora", + method: "hub_import_key", + params: ["key"], + }), + + callHubDelete: rpc.declare({ + object: "luci.aurora", + method: "hub_delete", + params: ["id"], + }), +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/markdown.js b/luci-app-aurora-config/.dev/src/resource/utils/markdown.js new file mode 100644 index 00000000..4575c587 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/markdown.js @@ -0,0 +1,249 @@ +"use strict"; +"require baseclass"; + +// 两条标记注释之间是纯函数,hub 管理台的预览由同步脚本逐字取用这一段: +// 里面不得引用 LuCI、window 或本文件的其它部分。 +// +const MD_MAX_DEPTH = 2; +const MD_HEADING_OFFSET = 3; +const MD_FENCE = "```"; +const MD_HEADING_RE = /^(#{1,3}) +(.*)$/; +const MD_RULE_RE = /^-{3,} *$/; +const MD_QUOTE_RE = /^> ?/; +const MD_BULLET_RE = /^[-*] +/; +const MD_NUMBER_RE = /^[0-9]{1,9}[.] +/; +const MD_EXTERNAL_RE = /^https:/; + +function mdClean(src) { + const text = typeof src === "string" ? src : ""; + let out = ""; + let from = 0; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if ((code < 32 && code !== 9 && code !== 10) || code === 127) { + out += text.slice(from, i); + from = i + 1; + } + } + return out + text.slice(from); +} + +// 每个分隔符只向右找一次「最近的闭合」;找不到就记下来,同一种分隔符不再找。 +// 方括号的扫描遇到下一个 "[" 就停。两条合起来保证整体是线性的。 +function mdInline(text, depth) { + const tokens = []; + const dead = {}; + let plain = ""; + let i = 0; + + const flush = () => { + if (plain) tokens.push({ type: "text", text: plain }); + plain = ""; + }; + const closer = (mark, from) => { + if (dead[mark]) return -1; + const at = text.indexOf(mark, from); + if (at === -1) dead[mark] = true; + return at; + }; + const nested = (inner) => + depth < MD_MAX_DEPTH ? mdInline(inner, depth + 1) : [{ type: "text", text: inner }]; + + while (i < text.length) { + const ch = text[i]; + + if (ch === "`") { + const end = closer("`", i + 1); + if (end > i + 1) { + flush(); + tokens.push({ type: "code", text: text.slice(i + 1, end) }); + i = end + 1; + continue; + } + } else if (ch === "*") { + const mark = text[i + 1] === "*" ? "**" : "*"; + const start = i + mark.length; + if (start < text.length && text[start] !== " ") { + const end = closer(mark, start); + if (end > start && text[end - 1] !== " ") { + flush(); + tokens.push({ + type: mark === "**" ? "strong" : "em", + children: nested(text.slice(start, end)), + }); + i = end + mark.length; + continue; + } + } + if (mark === "**") { + plain += mark; + i += 2; + continue; + } + } else if (ch === "[") { + let j = i + 1; + while (j < text.length && text[j] !== "[" && text[j] !== "]") j += 1; + if (j > i + 1 && text[j] === "]" && text[j + 1] === "(") { + let k = j + 2; + while (k < text.length && text[k] !== ")" && text[k] !== "(" && text[k] !== " ") k += 1; + if (k > j + 2 && text[k] === ")") { + flush(); + tokens.push({ type: "link", text: text.slice(i + 1, j), url: text.slice(j + 2, k) }); + i = k + 1; + continue; + } + } + } + + plain += ch; + i += 1; + } + + flush(); + return tokens; +} + +function mdBlocks(src) { + const lines = mdClean(src).split("\n"); + const blocks = []; + let open = null; + + const close = () => { + open = null; + }; + const extend = (type, text) => { + if (open && open.type === type) open.lines.push(text); + else { + open = { type: type, lines: [text] }; + blocks.push(open); + } + }; + const item = (type, text) => { + if (open && open.type === type) open.items.push(text); + else { + open = { type: type, items: [text] }; + blocks.push(open); + } + }; + + for (let n = 0; n < lines.length; n += 1) { + const line = lines[n]; + const trimmed = line.trim(); + + if (trimmed.startsWith(MD_FENCE)) { + const code = []; + n += 1; + while (n < lines.length && !lines[n].trim().startsWith(MD_FENCE)) { + code.push(lines[n]); + n += 1; + } + close(); + blocks.push({ type: "code", text: code.join("\n") }); + continue; + } + if (trimmed === "") { + close(); + continue; + } + + const heading = MD_HEADING_RE.exec(trimmed); + if (heading) { + close(); + blocks.push({ type: "heading", level: heading[1].length, text: heading[2] }); + } else if (MD_RULE_RE.test(trimmed)) { + close(); + blocks.push({ type: "rule" }); + } else if (MD_QUOTE_RE.test(trimmed)) extend("quote", trimmed.replace(MD_QUOTE_RE, "")); + else if (MD_BULLET_RE.test(trimmed)) item("bullets", trimmed.replace(MD_BULLET_RE, "")); + else if (MD_NUMBER_RE.test(trimmed)) item("numbers", trimmed.replace(MD_NUMBER_RE, "")); + else if (open && open.items) open.items[open.items.length - 1] += " " + trimmed; + else extend("paragraph", trimmed); + } + + return blocks; +} + +function mdPlain(tokens) { + return tokens + .map((token) => (token.children ? mdPlain(token.children) : token.text)) + .join(""); +} + +function renderMarkdown(src, doc, safeUrl) { + const root = doc.createDocumentFragment(); + + const inline = (parent, tokens) => { + tokens.forEach((token) => { + if (token.type === "text") parent.appendChild(doc.createTextNode(token.text)); + else if (token.type === "code") { + const code = doc.createElement("code"); + code.appendChild(doc.createTextNode(token.text)); + parent.appendChild(code); + } else if (token.type === "link") { + const href = typeof safeUrl === "function" ? safeUrl(token.url) : ""; + if (typeof href !== "string" || href === "") { + parent.appendChild(doc.createTextNode(token.text)); + return; + } + const link = doc.createElement("a"); + link.setAttribute("href", href); + if (MD_EXTERNAL_RE.test(href)) { + link.setAttribute("target", "_blank"); + link.setAttribute("rel", "noreferrer"); + } + link.appendChild(doc.createTextNode(token.text)); + parent.appendChild(link); + } else { + const node = doc.createElement(token.type === "strong" ? "strong" : "em"); + inline(node, token.children); + parent.appendChild(node); + } + }); + return parent; + }; + + const element = (tag, text) => inline(doc.createElement(tag), mdInline(text, 0)); + + mdBlocks(src).forEach((block) => { + if (block.type === "code") { + const pre = doc.createElement("pre"); + const code = doc.createElement("code"); + code.appendChild(doc.createTextNode(block.text)); + pre.appendChild(code); + root.appendChild(pre); + } else if (block.type === "rule") root.appendChild(doc.createElement("hr")); + else if (block.type === "heading") + root.appendChild(element("h" + (block.level + MD_HEADING_OFFSET), block.text)); + else if (block.items) { + const list = doc.createElement(block.type === "numbers" ? "ol" : "ul"); + block.items.forEach((text) => list.appendChild(element("li", text))); + root.appendChild(list); + } else + root.appendChild( + element(block.type === "quote" ? "blockquote" : "p", block.lines.join(" ")), + ); + }); + + return root; +} + +function markdownToText(src) { + const blocks = mdBlocks(src); + for (let n = 0; n < blocks.length; n += 1) { + const block = blocks[n]; + let text = ""; + if (block.type === "code") + text = block.text.split("\n").filter((line) => line.trim() !== "")[0] || ""; + else if (block.type === "heading") text = mdPlain(mdInline(block.text, 0)); + else if (block.items) text = mdPlain(mdInline(block.items[0], 0)); + else if (block.lines) text = mdPlain(mdInline(block.lines[0], 0)); + if (text.trim() !== "") return text.trim(); + } + return ""; +} +// + +return baseclass.extend({ + render: renderMarkdown, + toText: markdownToText, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/notices.js b/luci-app-aurora-config/.dev/src/resource/utils/notices.js new file mode 100644 index 00000000..f0f3b332 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/notices.js @@ -0,0 +1,411 @@ +"use strict"; +"require baseclass"; + +const POLL_INTERVAL_MS = 12 * 60 * 60 * 1000; +const MAX_NOTICES = 20; +const MAX_TITLE_CHARS = 120; +const MAX_BODY_CHARS = 4000; +const MAX_REASON_CHARS = 1000; +const MAX_VERSION_CHARS = 64; + +const ID_RE = /^[A-Za-z0-9]{1,32}$/; +const LANG_RE = /^[a-z]{2}(-[a-z]{2,4})?$/; +const LEVELS = ["info", "warning", "critical"]; +const BADGE_LEVELS = ["warning", "critical"]; +const AUDIENCES = ["all", "creators"]; +const COMPAT_STATES = ["unsupported", "deprecated"]; + +const HTTPS_PREFIX = "https://"; +const LUCI_PATH_RE = /^admin\/[A-Za-z0-9_\-./?=&%#]*$/; +const URL_FORBIDDEN_RE = /[\u0000-\u0020\u007f\\]/; +const STAMP_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/; +const SUNSET_DATE_RE = /^\d{4}-\d{2}-\d{2}(?=$|[ T])/; + +const GROUP_ACTION = "action"; +const GROUP_UPDATES = "updates"; +const SOURCE_ROUTER = "router"; +const SOURCE_SHARES = "shares"; +const SOURCE_STORE = "store"; +const FEED_KEY = "feed:missing"; +const LOCAL_KEY_PREFIX = "feed:"; +const DERIVED_KEY_MARK = ":"; + +const AGE_STEPS = [ + ["week", 7 * 24 * 60 * 60 * 1000], + ["day", 24 * 60 * 60 * 1000], + ["hour", 60 * 60 * 1000], + ["minute", 60 * 1000], +]; + +const isObject = (value) => + value !== null && typeof value === "object" && !Array.isArray(value); + +// 按码点计数:hub 的上限数的是字符,而 .length 数的是 UTF-16 码元。 +const charCount = (value) => Array.from(value).length; + +const isTitle = (value) => + typeof value === "string" && + value.trim() !== "" && + charCount(value) <= MAX_TITLE_CHARS; + +const isBody = (value) => + typeof value === "string" && charCount(value) <= MAX_BODY_CHARS; + +const cleanI18n = (i18n) => { + const out = {}; + if (!isObject(i18n)) return out; + Object.keys(i18n).forEach((lang) => { + const entry = i18n[lang]; + if (!LANG_RE.test(lang) || !isObject(entry)) return; + const kept = {}; + if (isTitle(entry.title)) kept.title = entry.title; + if (isBody(entry.body) && entry.body !== "") kept.body = entry.body; + if (Object.keys(kept).length) out[lang] = kept; + }); + return out; +}; + +const stampOf = (value) => { + const match = typeof value === "string" ? STAMP_RE.exec(value) : null; + return match ? match[0] : ""; +}; + +const cleanNotice = (raw) => { + if (!isObject(raw)) return null; + if (typeof raw.id !== "string" || !ID_RE.test(raw.id)) return null; + if (!LEVELS.includes(raw.level) || !AUDIENCES.includes(raw.audience)) return null; + if (!isTitle(raw.title) || !isBody(raw.body)) return null; + return { + id: raw.id, + level: raw.level, + audience: raw.audience, + title: raw.title, + body: raw.body, + url: typeof raw.url === "string" ? raw.url : "", + i18n: cleanI18n(raw.i18n), + at: stampOf(raw.starts_at) || stampOf(raw.at) || stampOf(raw.created_at), + }; +}; + +const stringList = (value) => + Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; + +const ownConfigs = (me) => + isObject(me) && Array.isArray(me.configs) + ? me.configs.filter( + (config) => isObject(config) && typeof config.id === "string" && ID_RE.test(config.id), + ) + : []; + +const isReason = (value) => + typeof value === "string" && charCount(value) <= MAX_REASON_CHARS; + +const versionOf = (pkg) => { + const version = isObject(pkg) ? pkg.installed_version : null; + return typeof version === "string" && charCount(version) <= MAX_VERSION_CHARS ? version : ""; +}; + +const nameOf = (config) => (typeof config.name === "string" ? config.name : ""); + +// FNV-1a,32 位。只为让"受影响的那一组 id"一变键就变,不承担任何安全含义。 +const shortHash = (text) => { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +}; + +return baseclass.extend({ + POLL_INTERVAL_MS: POLL_INTERVAL_MS, + MAX_NOTICES: MAX_NOTICES, + GROUP_ACTION: GROUP_ACTION, + GROUP_UPDATES: GROUP_UPDATES, + SOURCE_ROUTER: SOURCE_ROUTER, + SOURCE_SHARES: SOURCE_SHARES, + SOURCE_STORE: SOURCE_STORE, + FEED_KEY: FEED_KEY, + + localize(notice, lang) { + const source = isObject(notice) ? notice : {}; + const i18n = isObject(source.i18n) ? source.i18n : {}; + const code = String(lang == null ? "" : lang).toLowerCase().replace(/_/g, "-"); + const chain = [i18n[code], i18n[code.split("-")[0]], source]; + const pick = (field) => { + const hit = chain.find( + (entry) => isObject(entry) && typeof entry[field] === "string" && entry[field] !== "", + ); + return hit ? hit[field] : ""; + }; + return { title: pick("title"), body: pick("body") }; + }, + + safeUrl(url) { + if (typeof url !== "string" || URL_FORBIDDEN_RE.test(url)) return ""; + if (LUCI_PATH_RE.test(url)) + return url.includes("..") || url.includes("//") ? "" : url; + if (!url.startsWith(HTTPS_PREFIX)) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:" || !parsed.hostname) return ""; + if (parsed.username || parsed.password) return ""; + return url; + } catch (e) { + return ""; + } + }, + + isExternalUrl(url) { + return typeof url === "string" && url.startsWith(HTTPS_PREFIX); + }, + + present(notice, lang) { + const text = this.localize(notice, lang); + const url = this.safeUrl(notice?.url); + return { + title: text.title, + body: text.body, + url: url, + external: this.isExternalUrl(url), + }; + }, + + sanitize(list) { + if (!Array.isArray(list)) return []; + const out = []; + for (const raw of list) { + if (out.length >= MAX_NOTICES) break; + const notice = cleanNotice(raw); + if (notice && !out.some((kept) => kept.id === notice.id)) out.push(notice); + } + return out; + }, + + isDue(lastCheckedMs, nowMs) { + if (!Number.isFinite(lastCheckedMs) || lastCheckedMs <= 0) return true; + if (!Number.isFinite(nowMs) || nowMs < lastCheckedMs) return true; + return nowMs - lastCheckedMs >= POLL_INTERVAL_MS; + }, + + creatorAffected(me) { + if (!isObject(me)) return false; + const summary = isObject(me.compat_summary) ? me.compat_summary : {}; + const count = (field) => (Number.isFinite(summary[field]) ? summary[field] : 0); + if (count("deprecated") + count("unsupported") > 0) return true; + return (Array.isArray(me.configs) ? me.configs : []).some((config) => { + if (!isObject(config) || config.status === "removed") return false; + const state = isObject(config.compat) ? config.compat.state : null; + return typeof state === "string" && state !== "ok"; + }); + }, + + hasShares(me) { + return ownConfigs(me).length > 0; + }, + + compatBadge(config) { + const state = config?.compat?.state; + return COMPAT_STATES.includes(state) ? state : null; + }, + + sunsetDate(config) { + const stamp = config?.compat?.sunset_at; + const match = typeof stamp === "string" ? SUNSET_DATE_RE.exec(stamp) : null; + return match ? match[0] : ""; + }, + + compatCounts(me) { + const counts = { deprecated: 0, unsupported: 0, sunset: "" }; + ownConfigs(me) + .filter((config) => config.status !== "removed") + .forEach((config) => { + const state = this.compatBadge(config); + if (state) counts[state] += 1; + const sunset = state === "deprecated" ? this.sunsetDate(config) : ""; + if (sunset && (!counts.sunset || sunset < counts.sunset)) counts.sunset = sunset; + }); + if (isObject(me) && isObject(me.compat_summary)) + ["deprecated", "unsupported"].forEach((state) => { + const n = me.compat_summary[state]; + counts[state] = Number.isInteger(n) && n > 0 ? n : 0; + }); + return counts; + }, + + shortHash: shortHash, + + derive(me) { + const configs = ownConfigs(me); + const live = configs.filter((config) => config.status !== "removed"); + const items = []; + + COMPAT_STATES.forEach((state) => { + const hit = live.filter((config) => this.compatBadge(config) === state); + if (!hit.length) return; + const sunsets = hit + .map((config) => this.sunsetDate(config)) + .filter((day) => day) + .sort(); + items.push({ + key: + "compat:" + + state + + ":" + + shortHash( + hit + .map((config) => config.id) + .sort() + .join(","), + ), + group: GROUP_ACTION, + source: SOURCE_SHARES, + kind: "compat", + state: state, + level: "critical", + names: hit.map(nameOf), + sunset: state === "deprecated" && sunsets.length ? sunsets[0] : "", + }); + }); + + live + .filter((config) => config.assets_status === "rejected") + .forEach((config) => + items.push({ + key: "rejected:" + config.id, + group: GROUP_ACTION, + source: SOURCE_SHARES, + kind: "rejected", + level: "warning", + name: nameOf(config), + reason: isReason(config.assets_reject_reason) ? config.assets_reject_reason : "", + }), + ); + + configs + .filter((config) => config.status === "removed") + .forEach((config) => + items.push({ + key: "removed:" + config.id, + group: GROUP_ACTION, + source: SOURCE_SHARES, + kind: "removed", + level: "warning", + name: nameOf(config), + }), + ); + + return items; + }, + + // get_init_data 的应答 -> 随 feed 一起缓存的本机结论。判断不出来(没有 feed + // 字段、包管理器未知)就是 null:不知道,不等于"缺"。 + localState(initData) { + const feed = isObject(initData) && isObject(initData.feed) ? initData.feed : null; + if (!feed || typeof feed.pm !== "string" || feed.pm === "" || feed.pm === "unknown") + return null; + const versions = isObject(initData.versions) ? initData.versions : {}; + return { + feedMissing: feed.configured !== true, + theme: versionOf(versions.theme), + app: versionOf(versions.config), + }; + }, + + deriveLocal(local) { + if (!isObject(local) || local.feedMissing !== true) return []; + return [ + { + key: FEED_KEY, + group: GROUP_ACTION, + source: SOURCE_ROUTER, + kind: "feed", + level: "warning", + theme: typeof local.theme === "string" ? local.theme : "", + app: typeof local.app === "string" ? local.app : "", + }, + ]; + }, + + inbox(snapshot, me, state) { + const read = stringList(state && state.read); + const done = stringList(state && state.done); + const affected = this.creatorAffected(me); + const updates = this.sanitize(isObject(snapshot) ? snapshot.notices : null) + .filter((notice) => notice.audience !== "creators" || affected) + .map((notice) => ({ + key: notice.id, + group: GROUP_UPDATES, + source: SOURCE_STORE, + kind: "notice", + level: notice.level, + at: notice.at, + notice: notice, + })); + return this.deriveLocal(isObject(snapshot) ? snapshot.local : null) + .concat(this.derive(me), updates) + .filter((item) => !done.includes(item.key)) + .map((item) => Object.assign(item, { unread: !read.includes(item.key) })); + }, + + unreadCount(items) { + return (Array.isArray(items) ? items : []).filter((item) => item && item.unread).length; + }, + + badgeCount(snapshot, me, state) { + if (!isObject(snapshot)) return 0; + return this.inbox(snapshot, me, state).filter( + (item) => + item.unread && (item.group === GROUP_ACTION || BADGE_LEVELS.includes(item.level)), + ).length; + }, + + withKey(keys, key) { + const list = stringList(keys); + return typeof key !== "string" || list.includes(key) ? list : list.concat(key); + }, + + withoutKey(keys, key) { + return stringList(keys).filter((kept) => kept !== key); + }, + + withKeys(keys, items) { + return (Array.isArray(items) ? items : []).reduce( + (list, item) => this.withKey(list, item && item.key), + stringList(keys), + ); + }, + + // 某一路来源这次没拿到(hub 不通、hub_me 失败)时,它名下的键原样保留: + // 否则一次断网就把 Done 过的条目全放回来。 + pruneKeys(keys, activeKeys, known) { + const active = stringList(activeKeys); + const unknown = (key) => { + if (key.startsWith(LOCAL_KEY_PREFIX)) return !(known && known.local); + if (key.includes(DERIVED_KEY_MARK)) return !(known && known.me); + return !(known && known.feed); + }; + return stringList(keys).filter( + (key, index, all) => all.indexOf(key) === index && (active.includes(key) || unknown(key)), + ); + }, + + stampMs(stamp) { + const match = typeof stamp === "string" ? STAMP_RE.exec(stamp) : null; + if (!match) return null; + const [year, month, day, hour, minute, second] = match + .slice(1) + .map((part) => Number(part || 0)); + const ms = Date.UTC(year, month - 1, day, hour, minute, second); + return Number.isFinite(ms) ? ms : null; + }, + + ageOf(thenMs, nowMs) { + if (!Number.isFinite(thenMs) || !Number.isFinite(nowMs)) return null; + const elapsed = Math.max(0, nowMs - thenMs); + const step = AGE_STEPS.find((candidate) => elapsed >= candidate[1]); + return step + ? { value: -Math.floor(elapsed / step[1]), unit: step[0] } + : { value: 0, unit: "minute" }; + }, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/theme-preview.js b/luci-app-aurora-config/.dev/src/resource/utils/theme-preview.js new file mode 100644 index 00000000..7fd6a199 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/theme-preview.js @@ -0,0 +1,205 @@ +"use strict"; +"require baseclass"; + +// Palette-painted mini previews for the Marketplace (marketplace.js is the only +// caller: cards, detail drawer and share panel): a fake LuCI page drawn from +// a 4-key palette ({bg, surface, text, brand}). +// Geometry is static cssText; only sanitised +// colors vary, so untrusted hub palettes can never inject styles. +// +// SECURITY: a config's logo is a user-uploaded SVG served by the hub. It may +// only ever reach the page through , where SVG scripting does not +// run. Nothing in this package may put hub SVG text into the DOM -- not +// innerHTML, not DOMParser, not insertAdjacentHTML -- however thoroughly the +// hub sanitises it on upload. Defence in depth: the sanitiser is not the +// only thing standing between a shared config and script execution. + +const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; + +const safeHex = (value, fallback) => + HEX_RE.test(value || "") ? value : fallback; + +const mixHex = (hex, pct) => + "color-mix(in srgb, " + hex + " " + pct + "%, transparent)"; + +// An that silently degrades to `fallback` when the image cannot be +// loaded, keeping the slot it occupies exactly the same size either way. +// Callers pass the css of the box the fallback already fills, so has-logo, +// no-logo and failed-load are three states with identical geometry. +// +// No spinner and no skeleton on purpose: the store paints from a stale +// cached list whenever the hub is unreachable, and in that state every image +// request fails. A flash followed by a jump is worse than a plain colour +// block that was right from the first frame. +const logoImage = (url, fallback, styleCss) => { + if (!url) return fallback; + const img = E("img", { src: url, alt: "", style: styleCss }); + img.addEventListener("error", () => { + if (img.parentNode) img.parentNode.replaceChild(fallback, img); + }); + return img; +}; + +// opts.nav: "sidebar" draws a left rail; "mega-menu" draws the top bar plus +// the expanded panel that sits under it; anything else (notably "dropdown") +// draws the plain top bar. These are the three values validate_and_apply_hub_ +// payload accepts, so the preview covers the whole enum. +// +// opts.logo: an absolute url for the config's logo_svg. The brand square is +// exactly where a logo shows up in the real chrome, so drawing it there is +// the whole change -- one node swapped for another of the same size. That is +// why this shape survives no-asset, has-asset and failed-load without moving +// a pixel, which matters: most shared configs carry no assets at all. +const buildMini = (pal, opts) => { + const nav = (opts && opts.nav) || "top"; + const bg = safeHex(pal && pal.bg, "#f7fafc"); + const surface = safeHex(pal && pal.surface, "#ffffff"); + const text = safeHex(pal && pal.text, "#121a22"); + const brand = safeHex(pal && pal.brand, "#0086bf"); + + const bar = (width, pct, extra) => { + const el = E("span", { + style: + "height:22%;border-radius:99px;width:" + width + ";" + (extra || ""), + }); + el.style.background = mixHex(text, pct); + return el; + }; + + const row = (width) => { + const el = E("div", { + style: + "height:5px;border-radius:99px;margin-bottom:4%;width:" + width + ";", + }); + el.style.background = mixHex(text, 24); + return el; + }; + + const card = (children) => { + const el = E( + "div", + { style: "border-radius:8px;padding:4%;margin-bottom:5%;" }, + children, + ); + el.style.background = surface; + el.style.border = "1px solid " + mixHex(text, 10); + return el; + }; + + const DOT_CSS = "width:7%;aspect-ratio:1;border-radius:25%;flex:none;"; + const brandSquare = E("span", { style: DOT_CSS }); + brandSquare.style.background = brand; + const dot = logoImage( + (opts && opts.logo) || "", + brandSquare, + DOT_CSS + "object-fit:contain;", + ); + + const title = E("div", { + style: + "height:8px;max-height:14%;border-radius:99px;width:45%;margin-bottom:6%;", + }); + title.style.background = mixHex(text, 72); + + const btn = E("span", { + style: "display:inline-block;width:26%;height:12px;border-radius:99px;", + }); + btn.style.background = brand; + + const content = [card([title, row("90%"), row("70%"), btn]), card([row("85%"), row("60%")])]; + + let chromeNodes; + let main; + if (nav === "sidebar") { + const rail = E( + "div", + { + style: + "position:absolute;left:0;top:0;bottom:0;width:22%;display:flex;" + + "flex-direction:column;gap:8%;padding:6% 4%;box-sizing:border-box;", + }, + [dot, bar("80%", 28, "height:7%;"), bar("90%", 28, "height:7%;"), bar("70%", 28, "height:7%;")], + ); + rail.children[2].style.background = brand; + rail.style.background = surface; + rail.style.borderRight = "1px solid " + mixHex(text, 12); + chromeNodes = [rail]; + main = E( + "div", + { style: "position:absolute;left:27%;top:7%;right:5%;bottom:0;" }, + content, + ); + } else { + const top = E( + "div", + { + style: + "position:absolute;left:0;right:0;top:0;height:16%;display:flex;" + + "align-items:center;gap:4%;padding:0 4%;box-sizing:border-box;", + }, + [dot, bar("18%", 55), bar("12%", 34), bar("12%", 34)], + ); + top.style.background = surface; + top.style.borderBottom = "1px solid " + mixHex(text, 12); + chromeNodes = [top]; + + // A mega menu is only distinguishable from a dropdown once its panel is + // open, so the thumbnail draws it open. + if (nav === "mega-menu") { + const panel = E( + "div", + { + style: + "position:absolute;left:0;right:0;top:16%;height:13%;display:flex;" + + "align-items:center;gap:3%;padding:0 4%;box-sizing:border-box;", + }, + [ + bar("10%", 30, "height:26%;"), + bar("14%", 30, "height:26%;"), + bar("9%", 30, "height:26%;"), + bar("12%", 30, "height:26%;"), + ], + ); + panel.style.background = surface; + panel.style.borderBottom = "1px solid " + mixHex(text, 12); + chromeNodes.push(panel); + } + + main = E( + "div", + { + style: + "position:absolute;left:5%;top:" + + (nav === "mega-menu" ? "37%" : "24%") + + ";right:5%;bottom:0;", + }, + content, + ); + } + + const root = E( + "div", + { style: "position:absolute;inset:0;overflow:hidden;" }, + chromeNodes.concat([main]), + ); + root.style.background = bg; + return root; +}; + +// Light/dark diagonal split: two minis, the dark one clipped to the right. +const buildDuo = (palette, opts) => { + const dark = buildMini((palette && palette.dark) || {}, opts); + dark.style.clipPath = "polygon(58% 0, 100% 0, 100% 100%, 42% 100%)"; + return E("div", { style: "position:relative;width:100%;height:100%;" }, [ + buildMini((palette && palette.light) || {}, opts), + dark, + ]); +}; + +return baseclass.extend({ + safeHex, + mixHex, + logoImage, + buildMini, + buildDuo, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/utils/tokens.global.js b/luci-app-aurora-config/.dev/src/resource/utils/tokens.global.js new file mode 100644 index 00000000..de619e55 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/utils/tokens.global.js @@ -0,0 +1,317 @@ +/** + * @eamonxg/luci-theme-tokens v2.0.0 -- GENERATED, DO NOT EDIT. + * Aurora design-token engine (browser global). Built from aurora/spec.js + + * aurora/defaults.js + operators.js by build.mjs. Depends on the global + * `Color` (utils/color.global.js in luci-app-aurora-config); load that first. + */ +var AuroraTokens = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // global.js + var global_exports = {}; + __export(global_exports, { + DEFAULTS: () => DEFAULTS, + DERIVATIONS: () => DERIVATIONS, + DERIVED_KEYS: () => DERIVED_KEYS, + INPUTS: () => INPUTS, + resolve: () => resolve + }); + + // aurora/spec.js + var DERIVATIONS = { + light: { + text_muted: ["mix", "text", "bg", 0.62], + text_subtle: ["mix", "text", "bg", 0.48], + // Recessed fills need chroma ABOVE bg's (set, not shade): at these sizes a + // bg-inherited tint reads as dirty grey against the pure-white cards. + surface_sunken: ["set", "bg", 0.975, 5e-3], + surface_overlay: ["shade", "bg", 0.016], + hairline: ["alpha", "text", 0.13], + hover_faint: ["set", "bg", 0.95, 6e-3], + brand_hover: ["shade", "brand", -0.06], + brand_subtle: ["mix", "brand", "bg", 0.12], + brand_subtle_hover: ["shade", "brand_subtle", -0.04], + focus_ring: ["alpha", "brand", 0.6], + progress_start: ["mix", "brand", "surface_sunken", 0.65], + progress_end: ["const", "var:brand"], + info_surface: ["set", "info", 0.945, 0.042], + warning_surface: ["set", "warning", 0.955, 0.042], + success_surface: ["set", "success", 0.95, 0.042], + danger_surface: ["set", "danger", 0.95, 0.036], + danger_surface_hover: ["shade", "danger_surface", -0.04], + // Form-control fill (inputs/selects/textareas/checkboxes, login field). + // One semantic token so components never fork per mode: light rests a + // near-white overlay on the page; dark recesses below the card. + control_bg: ["const", "var:surface_overlay"], + scrim: ["const", "oklch(0 0 0 / 0.6)"], + // Fully opaque: a clean solid panel (Apple's #fafafc). Any translucency let + // the dimmed curtain bleed through, greying the panel off the header tone + // and leaking faint blurred page content into the empty columns. The header + // lifts to this same colour when the menu opens (see _layout.css) so bar and + // panel read as one continuous surface. + // Relight 2026-07: a cool snow step BELOW bg — anything brighter read as + // paper-white against the dimmed page; extra chroma keeps it icy. + mega_menu_bg: ["set", "bg", 0.972, 8e-3], + // Mega-menu curtain: a real dimming layer. A near-page-light grey (the + // earlier #e8e8ed attempt) only blurred without darkening, so the mask read + // as absent. Black at a modest alpha actually dims the page; the now-opaque + // panel + its shadow give a clean edge, so this no longer bands the way the + // old translucent panel over a heavy scrim did. Lighter than the 0.6 modal + // scrim — it's a menu backdrop, not a dialog. + // Relight 2026-07: pure black over the snow-white page greyed it dirty; + // a cool blue-black dim is the "blue hour" of the light theme. + mega_menu_scrim: ["const", "oklch(0.22 0.035 250 / 0.38)"] + }, + dark: { + text_muted: ["mix", "text", "bg", 0.62], + text_subtle: ["mix", "text", "bg", 0.42], + surface_sunken: ["shade", "surface", -0.045], + surface_overlay: ["shade", "surface", 0.02], + hairline: ["alpha", "text", 0.1], + hover_faint: ["alpha", "text", 0.05], + brand_hover: ["shade", "brand", -0.05], + brand_subtle: ["mix", "brand", "bg", 0.16], + brand_subtle_hover: ["shade", "brand_subtle", 0.04], + focus_ring: ["alpha", "brand", 0.6], + // Derived from brand like light — not hardcoded — so the bar tracks the + // active brand colour instead of a frozen teal. + progress_start: ["mix", "brand", "surface_sunken", 0.65], + progress_end: ["const", "var:brand"], + info_surface: ["set", "info", 0.32, 0.05], + warning_surface: ["set", "warning", 0.33, 0.06], + success_surface: ["set", "success", 0.3, 0.05], + danger_surface: ["set", "danger", 0.32, 0.08], + danger_surface_hover: ["shade", "danger_surface", 0.04], + // See light: same semantic token, recessed below the card in dark. + control_bg: ["const", "var:surface_sunken"], + scrim: ["const", "oklch(0 0 0 / 0.6)"], + // Deeper than surface_overlay (23%): surface_sunken (16.5%) tracks Apple's + // opened-panel #161617 (~18.5%). Fully opaque — a clean solid panel with no + // curtain bleed; the header lifts to this colour on open (see _layout.css) + // so bar and panel are one continuous surface. + mega_menu_bg: ["alpha", "surface_sunken", 1], + // Dark curtain stays a near-black dim (Apple's rgba(0,0,0,.4)); the dark + // panel and dark page already share a tone, so this only needs to deepen. + mega_menu_scrim: ["const", "oklch(0 0 0 / 0.5)"] + } + }; + + // aurora/defaults.js + var DEFAULTS = { + light: { + bg: "oklch(0.984 0.004 235)", + surface: "oklch(1 0 0)", + text: "oklch(0.215 0.02 250)", + brand: "oklch(0.58 0.14 233)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.58 0.14 233)", + // intentionally equals brand + info: "oklch(0.45 0.12 250)", + warning: "oklch(0.4 0.09 72)", + success: "oklch(0.38 0.1 172)", + danger: "oklch(0.42 0.15 22)" + }, + dark: { + bg: "oklch(0.13 0.018 264)", + surface: "oklch(0.21 0.02 264)", + text: "oklch(0.985 0.002 264)", + brand: "oklch(0.6 0.13 188.745)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.6 0.13 188.745)", + // intentionally equals brand + info: "oklch(0.8 0.11 255)", + warning: "oklch(0.82 0.13 80)", + success: "oklch(0.72 0.13 158)", + danger: "oklch(0.7 0.16 22)" + } + }; + + // shim-color-global.js + var shim_color_global_default = globalThis.Color; + + // operators.js + var C = (v) => v instanceof shim_color_global_default ? v : new shim_color_global_default(v); + var mix = (a, b, p) => shim_color_global_default.mix(C(a), C(b), 1 - p, { space: "oklab", outputSpace: "oklch" }); + var shade = (a, dl) => { + const c = C(a).to("oklch"); + c.coords[0] += dl; + return c; + }; + var set = (a, L, Ch) => { + const c = C(a).to("oklch"); + c.coords[0] = L; + const achromatic = c.coords[1] < 1e-3 || Number.isNaN(c.coords[2]); + c.coords[1] = achromatic ? 0 : Ch; + return c; + }; + var alpha = (a, p) => { + const c = C(a).to("oklch"); + c.alpha = p; + return c; + }; + var konst = (s) => C(s).to("oklch"); + var toOklch = (v) => C(v).to("oklch").toString({ precision: 4, format: "oklch" }); + + // resolve.js + function createResolver(derivations) { + return function resolveTokens2(mode, inputs) { + const derivs = derivations[mode]; + if (!derivs) throw new Error(`unknown mode: ${mode}`); + const resolved = { ...inputs }; + const ref = (name) => { + if (resolved[name] === void 0) compute(name); + return resolved[name]; + }; + function compute(name) { + const rule = derivs[name]; + if (!rule) throw new Error(`unknown derived token: ${name}`); + const [op, ...args] = rule; + let color; + switch (op) { + case "mix": + color = mix(ref(args[0]), ref(args[1]), args[2]); + break; + case "shade": + color = shade(ref(args[0]), args[1]); + break; + case "set": + color = set(ref(args[0]), args[1], args[2]); + break; + case "alpha": + color = alpha(ref(args[0]), args[1]); + break; + case "const": + if (args[0].startsWith("var:")) { + resolved[name] = ref(args[0].slice(4)); + return; + } + color = konst(args[0]); + break; + default: + throw new Error(`unknown op: ${op}`); + } + resolved[name] = toOklch(color); + } + for (const name of Object.keys(derivs)) compute(name); + return resolved; + }; + } + + // aurora/presets.js + var statusKeys = ["info", "warning", "success", "danger"]; + var STATUS = { + light: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.light[k]])), + dark: Object.fromEntries(statusKeys.map((k) => [k, DEFAULTS.dark[k]])) + }; + var PRESETS = { + default: { + light: { ...DEFAULTS.light }, + dark: { ...DEFAULTS.dark } + }, + "sage-green": { + light: { + bg: "oklch(0.9761 0.0041 91.4461)", + surface: "oklch(1 0 0)", + text: "oklch(0.2417 0.0298 269.8827)", + brand: "oklch(0.6333 0.0309 154.9039)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.6333 0.0309 154.9039)", + ...STATUS.light + }, + dark: { + bg: "oklch(0.1448 0 0)", + surface: "oklch(0.1822 0 0)", + text: "oklch(0.9702 0 0)", + brand: "oklch(0.6333 0.0309 154.9039)", + on_brand: "oklch(0 0 0)", + link: "oklch(0.6333 0.0309 154.9039)", + ...STATUS.dark + } + }, + "amber-sand": { + light: { + bg: "oklch(0.9818 0.0054 95.0986)", + surface: "oklch(0.9818 0.0054 95.0986)", + text: "oklch(0.3438 0.0269 95.7226)", + brand: "oklch(0.6171 0.1375 39.0427)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.6171 0.1375 39.0427)", + ...STATUS.light + }, + dark: { + bg: "oklch(0.2679 0.0036 106.6427)", + surface: "oklch(0.3085 0.0035 106.6039)", + text: "oklch(0.8074 0.0142 93.0137)", + brand: "oklch(0.6724 0.1308 38.7559)", + on_brand: "oklch(0 0 0)", + link: "oklch(0.6724 0.1308 38.7559)", + ...STATUS.dark + } + }, + monochrome: { + light: { + bg: "oklch(0.9900 0 0)", + surface: "oklch(1 0 0)", + text: "oklch(0 0 0)", + brand: "oklch(0 0 0)", + on_brand: "oklch(1 0 0)", + link: "oklch(0 0 0)", + ...STATUS.light + }, + dark: { + bg: "oklch(0 0 0)", + surface: "oklch(0.1400 0 0)", + text: "oklch(1 0 0)", + brand: "oklch(1 0 0)", + on_brand: "oklch(0 0 0)", + link: "oklch(1 0 0)", + ...STATUS.dark + } + }, + "sky-blue": { + light: { + bg: "oklch(1 0 0)", + surface: "oklch(0.9784 0.0011 197.1387)", + text: "oklch(0.1884 0.0128 248.5103)", + brand: "oklch(0.6723 0.1606 244.9955)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.6723 0.1606 244.9955)", + ...STATUS.light + }, + dark: { + bg: "oklch(0 0 0)", + surface: "oklch(0.2097 0.0080 274.5332)", + text: "oklch(0.9328 0.0025 228.7857)", + brand: "oklch(0.6692 0.1607 245.0110)", + on_brand: "oklch(1 0 0)", + link: "oklch(0.6692 0.1607 245.0110)", + ...STATUS.dark + } + } + }; + + // aurora/index.js + var resolveTokens = createResolver(DERIVATIONS); + + // global.js + var INPUTS = Object.keys(DEFAULTS.light); + var DERIVED_KEYS = Object.keys(DERIVATIONS.light); + var resolve = resolveTokens; + return __toCommonJS(global_exports); +})(); diff --git a/luci-app-aurora-config/.dev/src/resource/view/aurora/marketplace.js b/luci-app-aurora-config/.dev/src/resource/view/aurora/marketplace.js new file mode 100644 index 00000000..49c58c7e --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/view/aurora/marketplace.js @@ -0,0 +1,4367 @@ +"use strict"; +"require view"; +"require ui"; +"require uci"; +"require rpc"; +"require utils.hub-api as hubApi"; +"require utils.notices as notices"; +"require utils.markdown as markdown"; +"require utils.feed-check as feedCheck"; +"require preload.aurora-notices as inboxIndicator"; +"require utils.asset-upload as assetUpload"; +"require utils.theme-preview as themePreview"; + +// Marketplace: card grid + detail drawer + share panel + "my shares" +// management. Built-in presets render from a local JSON copy +// (htdocs/.../aurora/presets.json, generated by scripts/gen-presets.mjs) so +// the store keeps working offline; they apply through the local +// apply_theme_preset rpcd path, never the hub. +// +// Every piece of hub-sourced text (name, author, palette hex values, +// layout/typography strings, toolbar urls) is untrusted: it is rendered only +// via E()'s textContent-safe children, document.createTextNode, or explicit +// style property assignment -- never through innerHTML. Palette hex values +// are additionally sanitised before being interpolated into color-mix() +// strings (see safeHex). +// +// UX zero-burden rule: all user-facing copy describes results only +// ("Applied", "Restore previous configuration", "This configuration can't +// be applied", "Published", "Updated", "Deleted"). Mechanism words (job, +// schema, token, pending, bad_payload) never surface -- see +// applyErrorMessage/restoreErrorMessage/shareErrorMessage/ +// updateErrorMessage/deleteErrorMessage below, which map every backend +// error code to friendly copy instead of echoing it. + +const SWATCH_KEYS = ["bg", "surface", "text", "brand"]; + +// Content hash of aurora/presets.json -- stamped by scripts/gen-presets.mjs, +// checked by tests/builtin-presets.test.mjs. Appended as ?v= below. +// +// Without it a browser never refetches this file. uhttpd reports every static +// resource with Last-Modified at the epoch and sends no Cache-Control, so the +// heuristic freshness a browser computes from that is on the order of years: +// it serves its copy without even revalidating. The shape of this file has +// already changed once (it used to be {light,dark}, not +// {colors,layout,typography,toolbar}), and a browser holding the old shape +// renders every built-in card with the fallback palette and a top nav bar -- +// no colours, no sidebar, no font chip, and nothing on screen saying why. +// +// LuCI cache-busts the resources it `require`s itself; this fetch is ours, so +// it has to carry its own stamp. Same reason theme.js appends +// ?v=TOKENS_ENGINE_VERSION to the token engine. +const PRESETS_VERSION = "97884ace"; + +// Filenames the theme package itself ships into +// /www/luci-static/aurora/images. Nothing under one of these names is ever +// uploaded: the receiving router already has those bytes, and an unmodified +// slot means the user never customised it in the first place. +// +// The first five are the asset slots' factory defaults; the last four are the +// icons header.ut's default floating toolbar points at, which matters now that +// shortcut icons travel with a config -- a shortcut pointing at overview.svg +// needs no upload at all, and letting it consume a toolbar_icon_ slot +// number would shift every later icon by one on the receiving end. +// +// Duplicated from root/usr/libexec/rpcd/luci.aurora (is_theme_shipped_image). +// tests/marketplace-view.test.mjs asserts the two stay identical. +const THEME_SHIPPED_NAMES = [ + "logo.svg", + "favicon.ico", + "app-icon-192x192.png", + "app-icon-512x512.png", + "apple-touch-icon.png", + "network.svg", + "overview.svg", + "software.svg", + "system.svg", +]; + +const loginBgFilename = (value) => { + const match = /^url\('\/luci-static\/aurora\/images\/([^']+)'\)$/.exec(value || ""); + return match ? match[1] : ""; +}; + +// Mirrors build_share_payload's own guard: a name containing "/" or ".." never +// makes it into a share, so it must not show up in the manifest either. +const isSharedAssetName = (name) => + !!name && + name.indexOf("/") === -1 && + name.indexOf("..") === -1 && + THEME_SHIPPED_NAMES.indexOf(name) === -1; + +// build_share_payload validates every toolbar item against the hub's own +// limits and silently skips the ones that fail, then stops after 12 accepted +// items. The local toolbar editor (theme.js) only requires each field to be +// non-empty, so a config built entirely through this package's own UI can hold +// items the share leaves behind -- a title over 30 characters or carrying an +// apostrophe, a url with neither a scheme nor a leading slash, an icon name +// outside [A-Za-z0-9._-]. Counting the raw sections would promise shortcuts +// that never go out, so the checks below mirror the shell's exactly. +// +// Duplicated from root/usr/libexec/rpcd/luci.aurora, build_share_payload: +// the cap at line 631, the per-item checks at lines 637-660. +const MAX_SHARED_TOOLBAR_ITEMS = 12; + +// 两条尺寸上限,只为了在发布面板上把"为什么这张没发出去"说清楚。真正的闸门在 +// rpcd(MAX_SHARED_IMAGE / MAX_SHARED_TOOLBAR_ICON)和 hub 的 ASSET_SIZE_LIMITS +// 里,这里只是复述 —— tests/marketplace-view.test.mjs 盯着三处不许漂移。 +const MAX_SHARED_IMAGE = 2 * 1024 * 1024; +const MAX_SHARED_TOOLBAR_ICON = 256 * 1024; + +// Mirrors has_control_char and the `tr -d '[:cntrl:]'` title pass: the title is +// stripped and then measured, every other field is rejected outright. +const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/; +const stripControlChars = (value) => + String(value == null ? "" : value).replace(/[\u0000-\u001f\u007f]/g, ""); + +// The shell measures every length with `${#var}`, which under BusyBox ash (no +// UTF-8 locale) counts octets, while a JavaScript string's .length counts +// UTF-16 code units. An 11-character Chinese label is 33 octets: the shell +// drops that item while the browser would still count it, so the manifest +// would promise a shortcut that never goes out -- exactly the drift mirroring +// the shell is meant to prevent. Both limits below measure the encoded form, +// the way the shell sees it. +const utf8Length = (value) => + new TextEncoder().encode(String(value == null ? "" : value)).length; + +const isSharedToolbarItem = (item) => { + const title = stripControlChars(item && item.title); + const titleLength = utf8Length(title); + if (!titleLength || titleLength > 30 || title.indexOf("'") !== -1) + return false; + + const url = String((item && item.url) || ""); + if (CONTROL_CHAR_RE.test(url) || utf8Length(url) > 200) return false; + if (url.indexOf("//") === 0) return false; + if ( + url.indexOf("http://") !== 0 && + url.indexOf("https://") !== 0 && + url.indexOf("/") !== 0 + ) + return false; + + const icon = String((item && item.icon) || ""); + if (icon && (icon.length > 64 || !/^[A-Za-z0-9._-]+$/.test(icon))) + return false; + + const enabled = item && item.enabled; + return enabled === "0" || enabled === "1"; +}; + +const NAV_TYPE_LABELS = { + "mega-menu": _("Mega Menu"), + dropdown: _("Dropdown"), + sidebar: _("Sidebar"), +}; + +// Built-in presets: ids must match root/usr/share/aurora/*.template and the +// labels reuse the exact msgids the old preset dropdown used, so existing +// translations keep working. +const BUILTIN_PRESETS = [ + { id: "default", label: _("Default") }, + { id: "monochrome", label: _("Monochrome") }, + { id: "sage-green", label: _("Sage Green") }, + { id: "amber-sand", label: _("Amber Sand") }, + { id: "sky-blue", label: _("Sky Blue") }, +]; + +// Official hub seeds mirror the built-in presets ("Default · 默认" by +// "Aurora", ...). Until the hub exposes a builtin_id field, hide them by the +// only stable signal they carry so the same theme never shows up twice. +const BUILTIN_SEED_RE = /^(Default|Sage Green|Amber Sand|Monochrome|Sky Blue)\b/; +const isBuiltinSeed = (item) => + (item && item.author) === "Aurora" && BUILTIN_SEED_RE.test(item.name || ""); + +const callApplyThemePreset = rpc.declare({ + object: "luci.aurora", + method: "apply_theme_preset", + params: ["name"], +}); + +const INBOX_NARROW_QUERY = "(max-width:760px)"; + +const maskIcon = (paths, strokeWidth) => + "url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' " + + "fill='none' stroke='black' stroke-width='" + + strokeWidth + + "' stroke-linecap='round' stroke-linejoin='round'%3e" + + paths.map((d) => "%3cpath d='" + d + "'/%3e").join("") + + "%3c/svg%3e\")"; + +const ICON_RING = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"; +const LEVEL_ICONS = { + critical: maskIcon( + [ + "M12 9v4M12 17h.01", + "M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z", + ], + 2, + ), + warning: maskIcon([ICON_RING, "M12 8v4M12 16h.01"], 2), +}; +const SOURCE_ICONS = { + router: maskIcon(["m12 3 8 4.5v9L12 21l-8-4.5v-9z", "M12 12 4 7.5M12 12l8-4.5M12 12v9"], 1.8), + store: maskIcon(["M4 9h16l-1-5H5zM5 9v11h14V9M9 20v-6h6v6"], 1.8), + shares: maskIcon(["M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M4 21a8 8 0 0 1 16 0"], 1.8), +}; +const ICON_CHECK = maskIcon(["M20 6 9 17l-5-5"], 2.4); + +// --------------------------------------------------------------------------- +// Palette helpers + +// A list row carries `preview`: a structural subset of the stored payload +// that reuses payload's own key names and types. So one accessor can serve +// both a card (preview) and the drawer (full payload), and neither can +// describe the same configuration differently. +// +// Two shapes have to be handled: +// 1. `item.preview` -- a list row, from browse or from /api/v1/me +// 2. `item.payload` -- a detail row (always complete) +// +// There used to be a third, `item.palette`, in a nested shape of its own. The +// hub no longer emits it anywhere: /api/v1/me was its last producer and now +// carries the same `preview` the browse list does. One set of colors, one +// shape -- while there were two, a card and the hub's own site could draw the +// same configuration differently depending on which one they read. +const previewOf = (item) => { + const preview = item && item.preview; + return preview && typeof preview === "object" && !Array.isArray(preview) + ? preview + : null; +}; + +const paletteOf = (item) => { + const preview = previewOf(item); + const flat = + (preview && preview.colors) || + (item && item.payload && item.payload.colors) || + null; + if (flat) { + const pick = (mode) => + SWATCH_KEYS.reduce((acc, key) => { + acc[key] = flat[mode + "_" + key] || ""; + return acc; + }, {}); + return { light: pick("light"), dark: pick("dark") }; + } + return { light: {}, dark: {} }; +}; + +// Hub-sourced hex values are untrusted free text; the shared preview module +// sanitises every value before it can reach a color-mix() expression. +const safeHex = (value, fallback) => themePreview.safeHex(value, fallback); +// Forwarding opts is what makes nav_type visible at all -- an earlier version +// dropped the second parameter, which is why buildMini's sidebar branch never +// rendered. +const buildDuo = (palette, opts) => themePreview.buildDuo(palette, opts); + +// nav_type used to exist only on the detail payload, so every community card +// drew a top bar regardless of what the configuration actually did. It is on +// the list row now (preview.layout), so cards draw the real shape. +const navOf = (item) => { + const preview = previewOf(item); + const layout = + (preview && preview.layout) || + (item && item.payload && item.payload.layout) || + {}; + return layout.nav_type || ""; +}; + +// The approved asset entries, whichever shape the item came in. A detail row +// already carries `assets` with urls; `payload.assets` is the author's +// manifest and may still name unapproved kinds, so it is the last resort and +// carries no url. +const assetsOf = (item) => { + const preview = previewOf(item); + const list = + (preview && preview.assets) || + (item && item.assets) || + (item && item.payload && item.payload.assets); + return Array.isArray(list) ? list : []; +}; + +const assetKindsOf = (item) => + assetsOf(item) + .map((asset) => (asset && asset.kind) || "") + .filter(Boolean); + +// 这些 kind 加起来有多大,没有体积信息就返回 0。列表行的 preview 投影刻意不带 +// size(一页 24 张卡,体积是决策信息不是浏览信息,见 hub 的 configs.js),所以 +// 卡片上自然拿到 0 并且不显示 —— 详情抽屉才有。 +const assetBytesOf = (item, kinds) => + assetsOf(item).reduce( + (sum, asset) => + asset && kinds.indexOf(asset.kind) !== -1 + ? sum + (Number(asset.size) || 0) + : sum, + 0, + ); + +const sizeLabel = (bytes) => (bytes > 0 ? assetUpload.formatSize(bytes) : ""); + +// Only logo_svg is ever fetched for display. login_bg stays a label: +// /assets/:id/:kind streams the original image (capped at 2 MiB) and a full +// page of 24 cards would pull one each. +// +// hubAssetUrl validates the hub-supplied relative path and prepends the base +// -- this file must not know the hub's hostname, and must not concatenate an +// untrusted path itself. +const logoUrlOf = (item) => { + const entry = assetsOf(item).find( + (asset) => asset && asset.kind === "logo_svg" && asset.url, + ); + return entry ? hubApi.hubAssetUrl(entry.url) : ""; +}; + +// What a mini preview needs to draw a specific configuration. +const previewOpts = (item) => ({ nav: navOf(item), logo: logoUrlOf(item) }); + +const buildDotRow = (palette) => + E( + "span", + { style: "display:inline-flex;gap:3px;" }, + [ + (palette.light || {}).brand, + (palette.light || {}).bg, + (palette.dark || {}).brand, + (palette.dark || {}).bg, + ].map((hex) => { + const dotEl = E("span", { + style: + "width:11px;height:11px;border-radius:50%;" + + "border:1px solid var(--hairline, rgba(0,0,0,0.15));", + }); + dotEl.style.backgroundColor = safeHex(hex, "transparent"); + return dotEl; + }), + ); + +// The 460px drawer is wide enough to show both modes whole; the diagonal +// duo belongs on cards, where there is no room for two panes. +const buildPane = (label, palette, opts) => + E("figure", { class: "aurora-store-pane" }, [ + E("div", { class: "aurora-store-pane-box" }, [ + themePreview.buildMini(palette || {}, opts), + ]), + E("figcaption", {}, label), + ]); + +const buildPanes = (palette, opts) => + E("div", { class: "aurora-store-panes" }, [ + buildPane(_("Light"), (palette || {}).light, opts), + buildPane(_("Dark"), (palette || {}).dark, opts), + ]); + +// --------------------------------------------------------------------------- +// "What this config carries beyond the preview" +// +// Colors, nav_type and the logo are drawn; everything else is listed here as +// glyph+text. Cards and the drawer share one builder: the list row now +// carries the same layout/typography/toolbar/assets shapes the detail +// payload does, and two builders had already drifted into cards saying +// "Includes custom content" while the drawer itemised the pieces. +// +// Still deliberately no login-background thumbnail: that endpoint streams the +// original image, and a page of cards would each pull one. + +const SITE_ICON_KINDS = ["favicon_png", "favicon_ico"]; +const APP_ICON_KINDS = ["pwa_icon_192", "pwa_icon_512"]; + +// One set of names for the four image slots a configuration can carry. The +// drawer (what a shared config brings) and the share manifest (what your own +// config sends) describe the same payload, so they must not invent separate +// words for it -- two literal lists had already drifted into "Background" in +// one place and "Login Background" in the other, on the same page. +const ASSET_LABELS = { + logo: _("Logo"), + siteIcon: _("Site Icon"), + appIcon: _("App Icon"), + loginBg: _("Login Background"), + mainBg: _("Main Background"), +}; + +// typography.font_sans is a preset id ("geist-sans"); struct_font_sans is the +// stack the browser actually uses. Show the family, never the id. +const firstFontFamily = (stack) => + String(stack || "") + .split(",")[0] + .trim() + .replace(/^["']|["']$/g, ""); + +// struct_radius_base is a continuous 0-1.5rem slider, so there are no existing +// tier names to reuse -- these are the store's own, with the exact value kept +// in the tooltip and the detail table. +const RADIUS_TIERS = [ + { below: 0.001, label: _("Square corners") }, + { below: 0.376, label: _("Small corners") }, + { below: 0.751, label: _("Rounded corners") }, + { below: Infinity, label: _("Large corners") }, +]; + +const radiusLabel = (value) => { + const rem = parseFloat(value); + if (!isFinite(rem) || rem < 0) return ""; + const tier = RADIUS_TIERS.find((entry) => rem < entry.below); + return tier ? tier.label : ""; +}; + +const buildTile = (glyph, label, title, meta) => { + const glyphEl = E("span", { class: "g" }); + glyphEl.appendChild( + typeof glyph === "string" ? document.createTextNode(glyph) : glyph, + ); + const children = [glyphEl, E("span", { class: "nm" }, [document.createTextNode(label)])]; + // 体积跟在名字后面,弱化一档。这一格的 tile 说的是"这份配置带了一个 logo", + // 而体积说的是"下它要花多少" —— 后者才是站在小 flash 设备前要做的判断。 + if (meta) + children.push( + E("span", { class: "mt" }, [document.createTextNode(meta)]), + ); + const el = E("span", { class: "aurora-store-tile" }, children); + if (title) el.setAttribute("title", title); + return el; +}; + +// The Logo tile draws the real logo in the same 20x20 glyph box the ◆ would +// occupy, and falls straight back to that ◆ if the image cannot be loaded -- +// same builder as the preview's brand square, so the two states can never +// diverge. SECURITY: only; see theme-preview.js. +const logoGlyph = (url) => + themePreview.logoImage( + url, + document.createTextNode("◆"), + "width:15px;height:15px;object-fit:contain;display:block;", + ); + +const buildTiles = (entries) => + E( + "div", + { class: "aurora-store-tiles" }, + entries.map((entry) => + buildTile(entry.glyph, entry.label, entry.title, entry.meta), + ), + ); + +// Also the fallback for a configuration whose payload carries nothing beyond +// its colors -- the same tile, so the two can never say it differently. +const buildBuiltinTiles = () => + buildTiles([ + { + glyph: "◑", + label: _("Colors only"), + title: _("Replaces the light and dark colors; everything else stays as it is."), + }, + ]); + +// A hub that predates the preview projection sends a list row with no +// layout, typography, toolbar or assets at all -- assets_status is the only +// signal it carries. Saying "Colors only" there would be a lie, so that case +// keeps the coarse tile it always had. +const buildLegacyCardTiles = (item) => { + const status = item && item.assets_status; + if (status !== "approved") return null; + return buildTiles([ + { + glyph: "◆", + label: _("Includes custom content"), + title: _("Open this configuration to see everything it carries."), + }, + ]); +}; + +// 一份配置在颜色之外还带了什么,只在这里数一次。卡片只画 glyph、抽屉画完整 +// tiles 并且滤掉已经在"布局与排版"和"快捷方式"里说过的那几种 —— 两个消费者, +// 一张表。`kind` 就是给消费者过滤用的。 +// +// 返回 null 表示这行来自没有 preview 投影的老 hub,调用方退回 +// buildLegacyCardTiles;返回空数组表示这份配置确实只带了颜色。 +// 背景 tile 的 meta:体积 + 随包发出的观感参数(不透明度/磨砂/遮罩,存的 +// 就是带单位的 CSS 值,原样展示)。参数缺席 = 接收端走主题默认,不占版面。 +const bgTileMeta = (item, kindList, layout, prefix) => { + const parts = [sizeLabel(assetBytesOf(item, kindList))]; + const tune = [ + layout[prefix + "_alpha"], + layout[prefix + "_blur"], + layout[prefix + "_scrim"], + ].filter(Boolean); + if (tune.length) parts.push(tune.join(" / ")); + return parts.filter(Boolean).join(" · "); +}; + +const tileEntriesFor = (item) => { + const preview = previewOf(item); + const source = preview || (item && item.payload); + if (!source) return null; + + const typography = source.typography || {}; + const layout = source.layout || {}; + // Both toolbar and assets are hub-sourced and therefore untrusted shapes: a + // hub (or anything sitting between it and the router) answering with + // assets: "logo_svg" would otherwise throw inside the promise that renders + // the drawer, leaving it stuck on "Loading theme details…" forever. + const toolbar = Array.isArray(source.toolbar) ? source.toolbar : []; + const kinds = assetKindsOf(item); + + const entries = []; + + const family = firstFontFamily(typography.struct_font_sans); + if (family) + entries.push({ + kind: "font", + glyph: "Aa", + label: family, + title: + family + + (firstFontFamily(typography.struct_font_mono) + ? " · " + firstFontFamily(typography.struct_font_mono) + : ""), + // 名册字体不随附文件 —— 体积为 0,meta 空串;buildBundledTiles 靠它把 + // "随附了字体文件"和"应用后路由器自己下载"分开。卡片不画 meta,零影响。 + meta: sizeLabel(assetBytesOf(item, ["font_sans", "font_mono"])), + }); + + const radius = radiusLabel(layout.struct_radius_base); + if (radius) + entries.push({ + kind: "radius", + glyph: "◜", + label: radius, + title: layout.struct_radius_base, + }); + + if (kinds.indexOf("logo_svg") !== -1) + entries.push({ + kind: "logo", + glyph: logoGlyph(logoUrlOf(item)), + label: ASSET_LABELS.logo, + title: _("Custom logo"), + meta: sizeLabel(assetBytesOf(item, ["logo_svg"])), + }); + + if (kinds.indexOf("login_bg") !== -1) + entries.push({ + kind: "loginBg", + glyph: "▣", + label: ASSET_LABELS.loginBg, + title: _("Custom login page background"), + meta: bgTileMeta(item, ["login_bg"], layout, "struct_login_bg"), + }); + + if (kinds.indexOf("main_bg") !== -1) + entries.push({ + kind: "mainBg", + glyph: "▣", + label: ASSET_LABELS.mainBg, + title: _("Custom admin interface background"), + meta: bgTileMeta(item, ["main_bg"], layout, "struct_main_bg"), + }); + + if (kinds.some((kind) => SITE_ICON_KINDS.indexOf(kind) !== -1)) + entries.push({ + kind: "siteIcon", + glyph: "◐", + label: ASSET_LABELS.siteIcon, + title: _("Custom site icons"), + meta: sizeLabel(assetBytesOf(item, SITE_ICON_KINDS)), + }); + + if (kinds.some((kind) => APP_ICON_KINDS.indexOf(kind) !== -1)) + entries.push({ + kind: "appIcon", + glyph: "◐", + label: ASSET_LABELS.appIcon, + title: _("Custom app icons"), + meta: sizeLabel(assetBytesOf(item, APP_ICON_KINDS)), + }); + + if (toolbar.length) + entries.push({ + kind: "toolbar", + glyph: "⌘", + label: _("Toolbar %d").format(toolbar.length), + title: _("Floating toolbar shortcuts"), + meta: sizeLabel(assetBytesOf(item, TOOLBAR_ICON_KINDS)), + }); + + return entries; +}; + +// 卡片上只放前几个 glyph:一行写着"仅颜色"的标签在 252px 宽的卡上占掉整整 +// 一行,而它说的东西在抽屉里有完整版本。title 属性留着完整文字。 +const CARD_GLYPH_LIMIT = 3; + +const buildCardGlyphs = (item) => { + const entries = tileEntriesFor(item); + if (!entries || !entries.length) return null; + return E( + "span", + { class: "aurora-store-glyphs" }, + entries.slice(0, CARD_GLYPH_LIMIT).map((entry) => { + const box = E("span", { title: entry.title || entry.label }); + box.appendChild( + typeof entry.glyph === "string" + ? document.createTextNode(entry.glyph) + : entry.glyph, + ); + return box; + }), + ); +}; + +// 抽屉的"随附内容"。圆角在"布局与排版"那张表里已经写了值,快捷方式自己占 +// 一整节 —— 在这里再画一遍就是同一句话说三次。字体的名字也在那张表里,但 +// 随附的字体文件有多大只有这块 tile 说:名册字体不带文件(应用后路由器自己 +// 下载,由"需下载字体"脚注负责),所以只有 meta 里真有体积的字体才算随附。 +const BUNDLED_KINDS = ["font", "logo", "loginBg", "mainBg", "siteIcon", "appIcon"]; + +// 自定义工具栏图标随配置走(toolbar_icon_),字节要用户实打实下载,却一直 +// 只被并进"图片共 X"那句合计。这块 tile 是抽屉专属的,在这里合成而不进 +// tileEntriesFor:卡片 glyph 用的仍是共享的 toolbar entry(它说"有几条快捷 +// 方式"),这里说的是"随附了几张图标、共多大" —— 两个不同的问题。 +const toolbarIconsTile = (item) => { + const count = assetsOf(item).filter( + (asset) => asset && TOOLBAR_ICON_KINDS.indexOf(asset.kind) !== -1, + ).length; + if (!count) return null; + return { + kind: "toolbarIcons", + glyph: "⌘", + label: _("Toolbar icons ×%d").format(count), + title: _("Custom toolbar icons"), + meta: sizeLabel(assetBytesOf(item, TOOLBAR_ICON_KINDS)), + }; +}; + +const buildBundledTiles = (item) => { + const entries = tileEntriesFor(item); + if (!entries) return buildLegacyCardTiles(item); + if (!entries.length) return buildBuiltinTiles(); + const bundled = entries.filter( + (entry) => + BUNDLED_KINDS.indexOf(entry.kind) !== -1 && + (entry.kind !== "font" || entry.meta), + ); + const iconsTile = toolbarIconsTile(item); + if (iconsTile) bundled.push(iconsTile); + return bundled.length ? buildTiles(bundled) : null; +}; + +// --------------------------------------------------------------------------- +// Shortcuts +// +// 详情以前只写 "Toolbar 4",可 payload.toolbar[] 里 title、url、enabled 一直 +// 都在 —— 一个准备把别人的配置装到自己路由器上的人,最该看清楚的就是这四条 +// 到底指向哪里。列表行来自 hub 的投影(preview.toolbar)则不带 url:那是详情 +// 路径的活,卡片不需要。 +// +// SECURITY: toolbar 条目的 icon 存的是**作者路由器上**的文件名。为它拼一个 +// /luci-static/aurora/images/ 路径,画出来的是本机碰巧有的那张同名图,或者 +// 什么都没有 —— 两种都是在用 hub 给的名字读本机文件系统。所以那个字段一个 +// 字都不进 src。 +// +// 图标本身现在跟着配置走了(toolbar_icon_),而那条路径是安全的:字节由 +// hub 托管、过了审、按 sha256 校验,url 由 hubAssetUrl 按 hub 唯一可能产出的 +// 形状匹配后才拼 base —— 与 logo 走的是同一条。编号 k 在这里和 rpcd 的 +// nth_custom_toolbar_icon 里各算一次:按 payload.toolbar 顺序取「非空、非主题 +// 自带」的 icon 名去重,第 k 个不同的名字对应 toolbar_icon_。 +// +// 没有对应资产的条目(作者用的是主题自带图标,或者那张图没过审)退回中性 +// 占位符 —— 它一直就是这么画的。 +const SHORTCUT_GLYPH = "▫"; + +const TOOLBAR_ICON_KINDS = []; +for (let i = 0; i < MAX_SHARED_TOOLBAR_ITEMS; i++) + TOOLBAR_ICON_KINDS.push("toolbar_icon_" + i); + +// A hub field is JSON of whatever type the sender chose to send, not +// whatever the schema promises: a bare number, an array, or an object whose +// toString and valueOf are themselves string-valued keys ({"toString":"x"} +// parses fine and is not callable). String(value) attempts to coerce a value +// to a primitive and throws when neither conversion is callable; treating +// anything that is not already a string as absent sidesteps that coercion +// entirely instead of trying and catching it. Same idiom +// externalToolbarUrls already uses below (typeof entry.url === "string"). +const shortcutText = (value) => (typeof value === "string" ? value : ""); + +// 正则先绑常量,绝不能让字面量紧跟在箭头后面 —— jsmin 会把它当成除号,正则里 +// 相邻的两个 "/" 随即被当作行注释吃掉,整个 view 报 "Invalid regular +// expression: missing /"。商店页面真机上就是这样挂的。 +// 原委见 docs/DEVELOPMENT.md §11;tests/jsmin-safety.test.mjs 守着这条规则。 +const EXTERNAL_URL_RE = /^https?:\/\//; + +const isExternalShortcut = (url) => EXTERNAL_URL_RE.test(shortcutText(url)); + +// 剥掉 scheme,让站外链接读成它的主机名,本地 LuCI 路径原样留着。刻意是字符串 +// 手术而不是交给一个 URL 解析构造函数:这是不可信自由文本,只为了显示它没有 +// 理由喂给解析器。 +// +// 与 isExternalShortcut 共用同一个正则对象是安全的:它没有 g/y 标志,test() 和 +// replace() 都不会推进 lastIndex。 +const shortcutTarget = (url) => shortcutText(url).replace(EXTERNAL_URL_RE, ""); + +// 快捷方式图标名 -> 它随配置发出来的那份资产的 url。编号规则与 rpcd 的 +// nth_custom_toolbar_icon 逐字对应:按 toolbar 顺序取「非空、非主题自带」的 +// icon 名,首次出现即去重编号,第 k 个不同的名字就是 toolbar_icon_。 +// +// 两端各算一次而不是把编号写进线格式,是因为 toolbar 条目的形状因此一个字段 +// 都不用加 —— 老 hub 存的配置、老客户端读的配置,全都照旧。 +const shortcutIconUrls = (item, toolbar) => { + const urls = {}; + const order = []; + (Array.isArray(toolbar) ? toolbar : []).forEach((entry) => { + const icon = shortcutText(entry && entry.icon); + if (!icon || THEME_SHIPPED_NAMES.indexOf(icon) !== -1) return; + if (order.indexOf(icon) === -1) order.push(icon); + }); + order.slice(0, MAX_SHARED_TOOLBAR_ITEMS).forEach((icon, index) => { + const entry = assetsOf(item).filter( + (asset) => asset && asset.kind === "toolbar_icon_" + index && asset.url, + )[0]; + // hubAssetUrl 只认 hub 唯一可能产出的那种相对路径,别的一律给空串 —— + // 这个 url 来自 hub,和其它任何一个字段一样不可信。 + const url = entry ? hubApi.hubAssetUrl(entry.url) : ""; + if (url) urls[icon] = url; + }); + return urls; +}; + +// SECURITY: only —— 与 themePreview.logoImage 同一条约束。图标画不 +// 出来就退回中性占位符,而不是留一个坏掉的图片框。 +const shortcutGlyph = (url) => + url + ? themePreview.logoImage( + url, + document.createTextNode(SHORTCUT_GLYPH), + "width:14px;height:14px;object-fit:contain;display:block;", + ) + : document.createTextNode(SHORTCUT_GLYPH); + +const buildShortcutRow = (entry, iconUrls) => { + // Hub storage is JSON, and {"toolbar": [null]} is ordinary valid JSON -- + // Array.isArray passes, .map() still calls this with entry === null, and + // every entry.x below would throw on that shape. Normalize once, up front, + // so no later property read needs its own guard. Whatever the schema + // promises, this is untrusted wire content, not a guarantee. + entry = entry || {}; + const disabled = entry.enabled === "0"; + const external = isExternalShortcut(entry.url); + const title = shortcutText(entry.title) + (disabled ? " " + _("(disabled)") : ""); + const iconEl = E("span", { class: "ic" }); + iconEl.appendChild(shortcutGlyph((iconUrls || {})[shortcutText(entry.icon)])); + const children = [ + iconEl, + E("span", { class: "nm" }, [document.createTextNode(title)]), + E("span", { class: "to" }, [ + document.createTextNode(shortcutTarget(entry.url)), + ]), + ]; + if (external) + children.push(E("span", { class: "ext", title: _("Opens an external site") }, "↗")); + return E("li", { class: disabled ? "off" : "" }, children); +}; + +// 返回 [标题, 列表, ...注脚] 一整节,或者在这份配置没带快捷方式时返回 null。 +const buildShortcutList = (item, toolbar, layout) => { + const items = Array.isArray(toolbar) ? toolbar : []; + if (!items.length) return null; + + const iconUrls = shortcutIconUrls(item, items); + const nodes = [ + buildDetailHeading(_("Shortcuts %d").format(items.length)), + E( + "ul", + { class: "aurora-store-sc" }, + items.map((entry) => buildShortcutRow(entry, iconUrls)), + ), + ]; + + // 带了快捷方式却把工具栏整个关掉是合法的。不说的话这一节就是在承诺 + // 一批不会出现的按钮。 + if (layout && layout.toolbar_enabled === "0") + nodes.push( + E( + "p", + { class: "aurora-store-sc-note" }, + _("The toolbar itself is turned off in this configuration."), + ), + ); + + if (items.some((entry) => isExternalShortcut(entry && entry.url))) + nodes.push( + E( + "p", + { class: "aurora-store-sc-note" }, + _( + "Links marked ↗ open sites outside your router. You'll be asked to confirm before applying.", + ), + ), + ); + + return nodes; +}; + +const formatDownloads = (n) => { + const count = Number(n) || 0; + return count === 1 ? _("1 download") : _("%d downloads").format(count); +}; + +// 分区标题带一句副标题。它接管了被删掉的那句页头总说明 —— +// "内置" 和 "社区" 各自的性质不一样,一句笼统的话说不到点上,而说明放在 +// 它描述的那一组上方才有用。 +const buildSectionTitle = (title, hint) => + E("div", { class: "aurora-store-section-title" }, [ + E("h3", {}, title), + E("span", {}, hint), + ]); + +// "正在使用"是一个标记,不是一个词。写着字的药丸在 252px 的卡上占掉一整行 +// 的宽度,去说一件描边已经说清楚的事。 +// +// 两个形态:压在缩略图角上的实心圆片(缩略图底色是任意的,所以它自带一圈 +// surface 描边),和跟在标题文字后面的裸对号。两个都挂 title 属性 —— 一个 +// 光秃秃的对号对读屏软件是没有意义的。 +const buildCurrentPin = () => + E("span", { class: "aurora-store-pin", title: _("In use") }, "✓"); + +const buildCurrentTick = () => + E("span", { class: "aurora-store-tick", title: _("In use") }, "✓"); + +// --------------------------------------------------------------------------- +// Detail body pieces (drawer) + +// LuCI 的 cbi-value 把标签列右对齐。在一个 460px 的抽屉里那就是每个标签和 +// 它的值之间隔着一道峡谷 —— 这一页上最难读的东西。一张
,标签靠左,值 +// 靠右,中间没有空档。 +// +// 值为空的行整行丢掉:一行写着"间距 -"的东西什么也没说。 +const buildKvList = (rows) => + E( + "dl", + { class: "aurora-store-kv" }, + rows + .filter((row) => row[1]) + .reduce((nodes, [label, value]) => { + nodes.push(E("dt", {}, [document.createTextNode(label)])); + nodes.push(E("dd", {}, [document.createTextNode(value)])); + return nodes; + }, []), + ); + +const buildDetailHeading = (text) => E("h4", { class: "aurora-store-dt-h" }, text); + +// "圆角 · 0.5rem":档位名是读的人想要的,原值是想复现的人需要的。 +const radiusJoin = (value) => { + const label = radiusLabel(value); + if (!label) return ""; + return value ? label + " · " + value : label; +}; + +const buildPaletteChips = (item) => { + const palette = paletteOf(item); + const chip = (hex) => { + const swatch = E("span", { + style: + "display:inline-block;width:14px;height:14px;border-radius:4px;" + + "border:1px solid var(--hairline, rgba(0,0,0,0.15));vertical-align:-2px;", + }); + swatch.style.backgroundColor = safeHex(hex, "transparent"); + return E( + "span", + { + style: + "display:inline-flex;align-items:center;gap:5px;font-size:0.8em;" + + "padding:2px 8px;border-radius:6px;background:var(--surface-sunken, rgba(0,0,0,0.04));", + }, + [swatch, document.createTextNode(safeHex(hex, "-"))], + ); + }; + const chipRow = (label, mode) => + E("div", { class: "aurora-store-pal-row" }, [ + E("span", { class: "l" }, label), + ...SWATCH_KEYS.map((key) => chip((palette[mode] || {})[key])), + ]); + return E("div", { class: "aurora-store-pal" }, [ + chipRow(_("Light"), "light"), + chipRow(_("Dark"), "dark"), + ]); +}; + +// 工具栏开关是布局的一部分,不是"快捷方式"那一节的脚注:一份配置可以一条快捷 +// 方式都不带,却仍然决定了工具栏出不出现 —— 内置预设正是这种情况。 +// +// 两个值逐一比对,不做查表:layout 来自 hub 是任意 JSON,而 `{1:…,0:…}[值]` +// 会把 "constructor" 这类键读成 Object 原型上的东西,然后被当成一行文案画出来。 +const toolbarLabel = (value) => + value === "1" ? _("Shown") : value === "0" ? _("Hidden") : ""; + +// 内置预设和社区配置在这张表里说的是同一件事,所以只有一个构造器。内置抽屉 +// 曾经只画一行"导航(此预设不改动)",而那句话现在不成立了 —— 预设带的是一整 +// 套外观。 +const buildLayoutRows = (layout, typography) => + buildKvList([ + [_("Navigation"), NAV_TYPE_LABELS[layout.nav_type] || layout.nav_type], + [_("Spacing"), layout.struct_spacing], + [_("Corner Radius"), radiusJoin(layout.struct_radius_base)], + // 侧边栏外壳是全宽的,主题根本不读这个上限(设置页在 sidebar 下也把这个 + // 控件藏起来)。把它当成这份配置的一个特征写出来就是在描述一个死旋钮。 + [ + _("Content Width"), + layout.nav_type === "sidebar" ? "" : layout.struct_content_width_centered, + ], + [_("Sans Font"), firstFontFamily(typography.struct_font_sans)], + [_("Mono Font"), firstFontFamily(typography.struct_font_mono)], + [_("Toolbar"), toolbarLabel(layout.toolbar_enabled)], + ]); + +// typography.font_sans/font_mono 是字体名册里的 id。"default" 和 "system" 解析 +// 到路由器上已经有的字形,别的 id 一律是 Fontsource 网络字体:要先下载,下载 +// 到之前浏览器渲染的是字体栈里的后备字族。内置卡片写着"离线可用",所以这件事 +// 必须说出来,否则一台没有外网的路由器会觉得预设只应用了一半。 +const BUNDLED_FONT_IDS = ["default", "system"]; + +const needsFontDownload = (typography) => + ["font_sans", "font_mono"].some( + (key) => + typeof typography[key] === "string" && + typography[key] && + BUNDLED_FONT_IDS.indexOf(typography[key]) === -1, + ); + +const buildDetailBody = (item) => { + const payload = item.payload || {}; + const layout = payload.layout || {}; + const typography = payload.typography || {}; + + const children = [ + E("h3", { class: "aurora-store-dt-title" }, [ + document.createTextNode(item.name || _("Untitled theme")), + ]), + E("div", { class: "aurora-store-dt-sub" }, [ + document.createTextNode( + (item.author || _("Anonymous")) + " · " + formatDownloads(item.downloads), + ), + ]), + ]; + + if (item.description) + children.push( + E("p", { class: "aurora-store-dt-desc" }, [ + document.createTextNode(item.description), + ]), + ); + + children.push( + buildDetailHeading(_("Colors")), + buildPaletteChips(item), + buildDetailHeading(_("Layout & Typography")), + buildLayoutRows(layout, typography), + ); + + const shortcuts = buildShortcutList(item, payload.toolbar, layout); + if (shortcuts) children.push(...shortcuts); + + const bundled = buildBundledTiles(item); + if (bundled) children.push(buildDetailHeading(_("Bundled content")), bundled); + + // 每个 tile 上已经有自己的体积,这一句回答的是另一个问题:应用这套主题总共 + // 要下多少。字体单独有一句(见下),因为它落在别的地方、也不随升级保留 —— + // 这一句只算图片。 + const imageBytes = assetBytesOf( + item, + ["logo_svg", "login_bg", "main_bg"].concat(SITE_ICON_KINDS, APP_ICON_KINDS, TOOLBAR_ICON_KINDS), + ); + if (imageBytes > 0) + children.push( + E( + "p", + { class: "aurora-store-dt-foot" }, + _("Images: %s in total.").format(assetUpload.formatSize(imageBytes)), + ), + ); + + // 字体是唯一一种应用后会长期占着路由器可写空间的资产。图片按槽位覆盖、 + // 几十上百 KB;而一份覆盖中文的字体动辄好几 MB,在小 flash 设备上这就是 + // "要不要应用"的决定性信息。所以它不混在 tiles 里,单独说,带确切体积、 + // 存放位置,以及固件升级后不保留这件事。 + const fontAssets = (Array.isArray(item.assets) ? item.assets : []).filter( + (asset) => + asset && (asset.kind === "font_sans" || asset.kind === "font_mono"), + ); + + if (fontAssets.length) + children.push( + E( + "p", + { class: "aurora-store-dt-foot" }, + _("Includes %s of font files. They are stored in the router's writable partition, which is shared with installed packages — on devices with little flash, check the free space first. Fonts are not kept across a firmware upgrade and have to be applied again.").format( + assetUpload.formatSize( + fontAssets.reduce((sum, asset) => sum + (Number(asset.size) || 0), 0), + ), + ), + ), + // 版权那句在这里换了个方向:发布面板问的是「你有权发吗」,而站在使用者 + // 一边,能说的只有「这份字体不是商店的,授权状态没人核实过」。说得比这 + // 更满就是替上传者担保。 + E( + "p", + { class: "aurora-store-dt-foot" }, + _("The font file comes from whoever shared this theme; the store does not verify its licence."), + ), + ); + + // 名册字体走的是另一条路:字节不随配置走,路由器应用后自己去下载。一份配置 + // 完全可能一个槽位带着字体文件、另一个槽位用名册字体,所以这两句各判各的 —— + // 只是那个已经带了文件的槽位不该再被算进"待下载"。 + const fontAssetKinds = fontAssets.map((asset) => asset.kind); + if ( + ["font_sans", "font_mono"].some( + (key) => + fontAssetKinds.indexOf(key) === -1 && + typeof typography[key] === "string" && + typography[key] && + BUNDLED_FONT_IDS.indexOf(typography[key]) === -1, + ) + ) + children.push( + E( + "p", + { class: "aurora-store-dt-foot" }, + _( + "This theme uses a downloaded typeface. The router fetches it once after applying; until then the text falls back to the built-in font.", + ), + ), + ); + + children.push( + E( + "p", + { class: "aurora-store-dt-foot" }, + _( + "Your current settings are backed up before applying, and you can roll back with one click afterwards.", + ), + ), + ); + + return E("div", {}, children); +}; + +// Toolbar entries that point off-box are the one part of a config that can +// surprise a user after the fact (an admin panel bookmark, a vendor link). +// Anything else in the payload is inert data, so only these need a plaintext +// callout before applying. +const externalToolbarUrls = (payload) => { + const toolbar = (payload && Array.isArray(payload.toolbar)) ? payload.toolbar : []; + return toolbar + .filter( + (entry) => + entry && + typeof entry.url === "string" && + (entry.url.startsWith("http://") || entry.url.startsWith("https://")), + ) + .map((entry) => entry.url); +}; + +// --------------------------------------------------------------------------- +// Result-only error copy + +const APPLY_ERROR_COPY = { + invalid_id: _("This theme is no longer available."), + hub_unreachable: _("Couldn't reach the theme store. Please try again."), + bad_payload: _("This configuration can't be applied."), + job_not_found: _("Applying timed out. Please try again."), +}; + +const applyErrorMessage = (code) => + APPLY_ERROR_COPY[code] || _("This configuration can't be applied."); + +const RESTORE_ERROR_COPY = { + no_backup: _("There's nothing to restore yet."), +}; + +const restoreErrorMessage = (code) => + RESTORE_ERROR_COPY[code] || + _("Unable to restore the previous configuration."); + +const SHARE_ERROR_COPY = { + invalid_name: _("Please enter a name."), + invalid_description: _("That description is too long."), + nickname_taken: _("That name is already taken. Try another."), + invalid_nickname: _("Pick a name between 1 and 40 characters."), + config_unavailable: _("Your current configuration can't be shared right now."), + payload_build_failed: _("Your current configuration can't be shared right now."), + hub_unreachable: _("Couldn't reach the theme store. Please try again."), + // 这两条以前都被 hub_http_post 压成了 hub_unreachable ——「配额用完」和 + // 「路由器没网」显示同一句话,是发布失败长期看起来像商店挂了的一半原因。 + quota_exceeded: _("You've published as much as one router may publish today. Try again tomorrow."), + hub_rejected: _("The theme store refused this configuration. Nothing was published."), + // 上限就写在挑图的地方,所以这里只要指回那儿,不必再报一遍数字。 + // 不写死数字:图片和字体是 8 MB,工具栏图标是 256 KB,一句话说不准三个。 + // 确切的上限就写在挑文件的地方,那才是他能照着改的位置。 + asset_too_large: _("One of your assets is too large to share. Replace it, then share again."), + // 浏览器直传独有的两种失败。第二条特意说"这台电脑"而不是"网络":路由器 + // 能上网、你的电脑不能,是这条链路改到浏览器之后才可能出现的新情况。 + asset_unreadable: _("Couldn't read one of the assets from this router. Try reloading the page."), + asset_upload_failed: _("Uploading the assets failed. Check this computer's internet connection and try again."), + not_owner: _("This share belongs to another identity."), +}; + +const shareErrorMessage = (code) => + SHARE_ERROR_COPY[code] || _("Unable to share this configuration right now."); + +const nicknameErrorMessage = (code) => + SHARE_ERROR_COPY[code] || _("Couldn't save that name. Try again."); + +const UPDATE_ERROR_COPY = { + invalid_id: _("This share is no longer available."), + config_unavailable: _("Your current configuration can't be shared right now."), + payload_build_failed: _("Your current configuration can't be shared right now."), + hub_unreachable: _("Couldn't reach the theme store. Please try again."), +}; + +const updateErrorMessage = (code) => + UPDATE_ERROR_COPY[code] || _("Unable to update this share right now."); + +const DELETE_ERROR_COPY = { + invalid_id: _("This share is no longer available."), + hub_unreachable: _("Couldn't reach the theme store. Please try again."), +}; + +const deleteErrorMessage = (code) => + DELETE_ERROR_COPY[code] || _("Unable to delete this share right now."); + +// 等待反馈只有这一种:LuCI 自带的 .spinning(文字前一个转圈)加上 disabled。 +// 没有第二套 loading 组件 —— 页面上已经在用 .spinning 的地方(应用、抽屉、 +// 社区列表)读起来就该和这里一模一样。 +// +// disabled 不只是观感:hub_set_nickname / hub_import_key / hub_delete 都是一次 +// 跨太平洋往返,期间按钮活着就等于允许发第二遍。 +const withBusy = (btn, promise) => { + btn.disabled = true; + btn.classList.add("spinning"); + const done = () => { + // 成功路径经常在这之前就把模态关了,此时按钮已脱离文档 —— 还原它无害。 + btn.disabled = false; + btn.classList.remove("spinning"); + }; + return promise.then( + (value) => { + done(); + return value; + }, + (err) => { + done(); + throw err; + }, + ); +}; + +// onConfirm 返回 thenable 时确认键自己进入 busy;返回 undefined 的同步路径 +// (应用预设、应用配置 —— 它们当场换成一个 spinner 模态)行为一个字不变。 +const buildConfirmActions = (onConfirm, confirmLabel) => { + const cancelBtn = E("button", { class: "btn", click: ui.hideModal }, _("Cancel")); + const confirmBtn = E( + "button", + { class: "btn cbi-button-action important" }, + confirmLabel, + ); + confirmBtn.addEventListener("click", () => { + const pending = onConfirm(); + if (!pending || typeof pending.then !== "function") return; + cancelBtn.disabled = true; + withBusy(confirmBtn, pending).catch(() => {}).then(() => { + cancelBtn.disabled = false; + }); + }); + return E("div", { class: "right", style: "margin-top:1em;" }, [ + cancelBtn, + " ", + confirmBtn, + ]); +}; + +const buildSimpleApplyConfirm = (onConfirm) => [ + E( + "p", + {}, + _( + "Apply this configuration now? Your current settings are backed up first.", + ), + ), + buildConfirmActions(onConfirm, _("Apply")), +]; + +const buildExternalUrlConfirm = (urls, onConfirm) => [ + E("p", {}, _("This configuration links to these external sites:")), + E( + "ul", + { style: "margin:0.25em 0 0.75em;padding-left:1.2em;word-break:break-all;" }, + urls.map((url) => E("li", {}, [document.createTextNode(url)])), + ), + E( + "p", + {}, + _("Apply it anyway? Your current settings are backed up first."), + ), + buildConfirmActions(onConfirm, _("Apply")), +]; + +// --------------------------------------------------------------------------- +// Injected stylesheet: self-contained so the page degrades acceptably under +// non-Aurora themes (every var() carries a fallback). Static string only. + +const STORE_CSS = + // One row: tab strip on the left, search box against the right edge, the + // spacer eating everything between. It used to be two rows -- a row that + // held nothing but the spacer and the search box, then the tabs beneath -- + // which spent a whole line of vertical space on blank pixels. + // align-items:center rather than flex-end: the pill group and the input are + // different heights, and their baselines are not what should line up. + ".aurora-store-head{display:flex;gap:0.8em;align-items:center;flex-wrap:wrap;" + + "margin:0.9em 0 0;}" + + ".aurora-store-head input{flex:0 1 280px;min-width:0;}" + + ".aurora-store-head .sp{flex:1;}" + + ".aurora-store-tick{color:var(--brand,#0086bf);font-size:0.85em;}" + + // No margin-top here: the row above owns the spacing now, and a margin on + // one flex item would push it off-center from the search box. + ".aurora-store-filters{display:flex;gap:2px;flex-wrap:wrap;" + + "padding:3px;width:max-content;max-width:100%;border-radius:10px;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border:1px solid var(--hairline,rgba(0,0,0,0.1));}" + + ".aurora-store-filters button{border:0;background:transparent;font:inherit;" + + "font-size:0.88em;color:var(--text-muted,#666);padding:0.35em 0.85em;" + + "border-radius:7px;cursor:pointer;white-space:nowrap;}" + + ".aurora-store-filters button:hover{color:var(--text,#111);}" + + ".aurora-store-filters button.active{background:var(--surface,#fff);" + + "color:var(--text,#111);font-weight:600;" + + "box-shadow:var(--app-shadow-sm,0 1px 2px rgba(0,0,0,0.08));}" + + ".aurora-store-filters .n{font-size:0.8em;opacity:0.6;margin-left:0.4em;}" + + ".aurora-store-section-title{display:flex;align-items:baseline;gap:0.6em;" + + "margin:1.6em 0 0.6em;}" + + ".aurora-store-section-title h3{margin:0;font-size:0.95em;font-weight:650;}" + + ".aurora-store-section-title span{font-size:0.8em;color:var(--text-subtle,#888);}" + + // min(252px,100%) rather than a bare 252px: auto-fill still lays a 252px + // track when the container is narrower than that, and the grid then runs off + // the side of a small phone. The min() clamps the track to whatever room + // there actually is. + ".aurora-store-grid{display:grid;" + + "grid-template-columns:repeat(auto-fill,minmax(min(252px,100%),1fr));" + + "gap:16px;margin-top:0.4em;}" + + ".aurora-store-card{background:var(--surface,#fff);border:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "border-radius:13px;overflow:hidden;cursor:pointer;transition:transform .15s,box-shadow .15s,border-color .15s;}" + + ".aurora-store-card:hover{transform:translateY(-3px);box-shadow:0 2px 6px rgba(0,0,0,0.12)," + + "0 10px 28px rgba(0,0,0,0.10);border-color:color-mix(in srgb,var(--brand,#0086bf) 40%," + + "var(--hairline,rgba(0,0,0,0.12)));}" + + ".aurora-store-prev{aspect-ratio:16/10;position:relative;" + + "border-bottom:1px solid var(--hairline,rgba(0,0,0,0.08));}" + + ".aurora-store-dots{position:absolute;right:8px;bottom:8px;z-index:2;display:flex;" + + "padding:4px 6px;border-radius:999px;background:var(--surface,#fff);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-acts{position:absolute;left:8px;bottom:8px;opacity:0;transition:opacity .15s;z-index:2;}" + + ".aurora-store-card:hover .aurora-store-acts{opacity:1;}" + + ".aurora-store-meta{padding:10px 12px 11px;}" + + ".aurora-store-nm{font-weight:600;display:flex;align-items:center;gap:6px;word-break:break-word;}" + + ".aurora-store-glyphs{margin-left:auto;display:flex;gap:3px;flex:none;}" + + ".aurora-store-glyphs span{width:19px;height:19px;display:grid;place-items:center;" + + "border-radius:5px;background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border:1px solid var(--hairline,rgba(0,0,0,0.08));font-size:0.62em;" + + "line-height:1;color:var(--text-muted,#777);}" + + ".aurora-store-ft{display:flex;align-items:center;gap:6px;margin-top:5px;font-size:0.8em;" + + "color:var(--text-muted,#777);}" + + ".aurora-store-ft .dl{margin-left:auto;white-space:nowrap;}" + + ".aurora-store-badge{display:inline-flex;font-size:0.72em;font-weight:600;padding:1px 8px;" + + "border-radius:999px;background:var(--surface-sunken,rgba(0,0,0,0.05));color:var(--text-muted,#666);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.1));}" + + ".aurora-store-badge.builtin{background:var(--brand-subtle,rgba(0,134,191,0.12));" + + "color:var(--brand,#0086bf);border-color:transparent;}" + + ".aurora-store-card.current{border-color:var(--brand,#0086bf);}" + + ".aurora-store-pin{position:absolute;left:9px;top:9px;z-index:3;width:20px;height:20px;" + + "border-radius:50%;display:grid;place-items:center;background:var(--brand,#0086bf);" + + "color:var(--on-brand,#fff);font-size:0.7em;line-height:1;" + + "box-shadow:0 0 0 2px var(--surface,#fff);}" + + ".aurora-store-mask{position:fixed;inset:0;background:rgba(0,0,0,0.4);opacity:0;" + + "pointer-events:none;transition:opacity .2s;z-index:1090;}" + + ".aurora-store-drawer{position:fixed;top:0;right:-480px;width:460px;max-width:92vw;bottom:0;" + + "z-index:1091;background:var(--surface,#fff);border-left:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "transition:right .22s ease;display:flex;flex-direction:column;}" + + ".aurora-store-open .aurora-store-mask{opacity:1;pointer-events:auto;}" + + ".aurora-store-open .aurora-store-drawer{right:0;}" + + ".aurora-store-panes{display:grid;grid-template-columns:1fr 1fr;gap:10px;" + + "padding:1em 1.3em 0;flex:none;}" + + ".aurora-store-pane{margin:0;}" + + ".aurora-store-pane-box{position:relative;aspect-ratio:16/10;border-radius:8px;" + + "overflow:hidden;border:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-pane figcaption{font-size:0.75em;color:var(--text-muted,#777);" + + "text-align:center;margin-top:5px;}" + + ".aurora-store-drawer-body{padding:1em 1.3em;overflow:auto;flex:1;}" + + ".aurora-store-dt-title{margin:0 0 0.25em;word-break:break-word;}" + + ".aurora-store-dt-sub{color:var(--text-muted,#777);font-size:0.85em;}" + + ".aurora-store-dt-desc{color:var(--text-muted,#777);font-size:0.9em;" + + "word-break:break-word;margin:0.8em 0 0;}" + + ".aurora-store-dt-h{margin:1.4em 0 0.6em;font-size:0.72em;font-weight:700;" + + "letter-spacing:0.09em;text-transform:uppercase;color:var(--text-subtle,#888);}" + + ".aurora-store-kv{display:grid;grid-template-columns:auto 1fr;gap:5px 18px;" + + "margin:0;font-size:0.88em;}" + + ".aurora-store-kv dt{color:var(--text-muted,#777);white-space:nowrap;}" + + ".aurora-store-kv dd{margin:0;text-align:right;word-break:break-word;}" + + ".aurora-store-pal{display:grid;gap:6px;}" + + ".aurora-store-pal-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}" + + ".aurora-store-pal-row .l{font-size:0.72em;color:var(--text-subtle,#888);" + + "width:2.6em;flex:none;}" + + ".aurora-store-dt-foot{color:var(--text-muted,#777);font-size:0.8em;margin-top:1.4em;}" + + ".aurora-store-license-note{margin-top:0.8em;padding:0.7em 0.9em;border-radius:6px;" + + "background:var(--warning-surface,rgba(255,180,0,0.08));" + + "color:var(--text-muted,#777);font-size:0.8em;line-height:1.6;}" + + ".aurora-store-drawer-foot{flex:none;padding:0.9em 1.3em;display:flex;gap:0.6em;" + + "border-top:1px solid var(--hairline,rgba(0,0,0,0.1));}" + + ".aurora-store-drawer-foot .apply{flex:1;}" + + ".aurora-store-drawer-foot .apply[disabled]{opacity:0.45;cursor:default;}" + + ".aurora-store-tiles{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px;}" + + ".aurora-store-tile{display:inline-flex;align-items:center;gap:5px;font-size:0.75em;" + + "color:var(--text-muted,#777);background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border:1px solid var(--hairline,rgba(0,0,0,0.08));border-radius:7px;" + + "padding:2px 8px 2px 3px;white-space:nowrap;max-width:100%;}" + + ".aurora-store-tile .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;}" + + ".aurora-store-tile .mt{flex:none;opacity:0.7;font-variant-numeric:tabular-nums;}" + + ".aurora-store-tile .g{flex:none;display:grid;place-items:center;width:20px;height:20px;" + + "border-radius:5px;background:var(--surface,#fff);color:var(--text,#111);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.08));font-size:0.9em;line-height:1;}" + + ".aurora-store-sc{list-style:none;margin:0;padding:0;display:grid;gap:1px;" + + "border-radius:10px;overflow:hidden;border:1px solid var(--hairline,rgba(0,0,0,0.1));}" + + ".aurora-store-sc li{display:flex;align-items:center;gap:10px;padding:8px 10px;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));font-size:0.85em;}" + + ".aurora-store-sc li.off{opacity:0.45;}" + + ".aurora-store-sc .ic img{width:14px;height:14px;object-fit:contain;display:block;}" + + ".aurora-store-sc .ic{width:24px;height:24px;flex:none;display:grid;place-items:center;" + + "border-radius:6px;background:var(--surface,#fff);color:var(--text-muted,#777);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.08));font-size:0.8em;line-height:1;}" + + ".aurora-store-sc .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;" + + "white-space:nowrap;}" + + ".aurora-store-sc .to{font-size:0.85em;color:var(--text-subtle,#888);" + + "font-family:ui-monospace,Menlo,monospace;overflow:hidden;text-overflow:ellipsis;" + + "white-space:nowrap;max-width:44%;}" + + ".aurora-store-sc .ext{flex:none;color:var(--text-muted,#777);font-size:0.85em;}" + + ".aurora-store-sc-note{color:var(--text-subtle,#888);font-size:0.78em;" + + "margin:0.5em 0 0;line-height:1.6;}" + + ".aurora-store-share{background:var(--surface,#fff);border:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "border-radius:12px;padding:1em 1.3em 1.3em;margin-top:1em;}" + + ".aurora-store-share-head{display:flex;align-items:center;justify-content:space-between;" + + "gap:1em;margin-bottom:1em;}" + + ".aurora-store-share-cols{display:grid;grid-template-columns:240px 1fr;gap:1.6em;}" + + "@media (max-width:700px){.aurora-store-share-cols{grid-template-columns:1fr;}}" + + ".aurora-store-share-prev .aurora-store-prev{border:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "border-radius:10px;overflow:hidden;}" + + ".aurora-store-target{border:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "border-radius:11px;overflow:hidden;margin-bottom:1em;}" + + ".aurora-store-target .hd{padding:8px 12px;font-size:0.82em;font-weight:600;" + + "color:var(--text-muted,#666);background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border-bottom:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-target-row{display:flex;align-items:center;gap:10px;padding:9px 12px;" + + "cursor:pointer;font-size:0.89em;" + + "border-bottom:1px solid var(--hairline,rgba(0,0,0,0.08));}" + + ".aurora-store-target-row:last-child{border-bottom:0;}" + + ".aurora-store-target-row .nm{font-weight:600;min-width:0;overflow:hidden;" + + "text-overflow:ellipsis;}" + + ".aurora-store-target-row .mt{color:var(--text-muted,#777);font-size:0.88em;" + + "margin-left:auto;white-space:nowrap;}" + + ".aurora-store-field{margin-bottom:0.9em;}" + + ".aurora-store-field label{display:block;font-size:0.8em;font-weight:600;" + + "color:var(--text-muted,#666);margin-bottom:4px;}" + + ".aurora-store-field input,.aurora-store-field textarea{width:100%;}" + + ".aurora-store-idcard{display:flex;align-items:center;gap:0.9em;flex-wrap:wrap;" + + "padding:0.85em 1em;margin:0.6em 0 0;border-radius:12px;" + + "background:var(--surface,#fff);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-idcard .av{flex:none;width:42px;height:42px;border-radius:11px;" + + "display:grid;place-items:center;font-weight:700;font-size:1.05em;" + + "background:var(--brand-subtle,rgba(0,0,0,0.05));color:var(--brand,#333);" + + "border:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-idcard .who{flex:1;min-width:0;}" + + ".aurora-store-idcard .nm{display:flex;align-items:center;gap:0.5em;" + + "flex-wrap:wrap;font-weight:600;}" + + ".aurora-store-idcard .hid{color:var(--text-subtle,#888);font-weight:400;" + + "font-size:0.8em;font-family:ui-monospace,Menlo,monospace;}" + + ".aurora-store-idcard .meta{color:var(--text-muted,#777);font-size:0.85em;" + + "margin-top:2px;}" + + ".aurora-store-idcard .acts{display:flex;gap:0.5em;flex-wrap:wrap;}" + + ".aurora-store-pill{display:inline-flex;align-items:center;font-size:0.72em;" + + "font-weight:600;padding:1px 8px;border-radius:99px;" + + "border:1px solid currentColor;}" + + ".aurora-store-pill.ok{color:var(--brand,#333);}" + + ".aurora-store-pill.risk{color:var(--warning,#a86a00);}" + + ".aurora-store-why{margin:0.6em 0 0;padding:0.75em 0.9em;border-radius:10px;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border:1px solid var(--hairline,rgba(0,0,0,0.08));" + + "font-size:0.85em;color:var(--text-muted,#777);line-height:1.7;}" + + ".aurora-store-why summary{cursor:pointer;color:var(--brand,#333);" + + "font-weight:600;list-style:none;}" + + ".aurora-store-why summary::-webkit-details-marker{display:none;}" + + // 「还没发过」曾经是一张虚线卡:标题、一段说明、两个按钮。它整块删掉了 —— + // 那张卡劝人去别处发布,而发布入口现在就在它上面那条横幅上。空态回到商店 + // 别处一直在用的写法(见 buildOnlineGrid 的 "No themes match your search."): + // 一行灰字,说完就完。 + ".aurora-store-none{color:var(--text-muted,#777);font-size:0.9em;padding:1.2em 0.2em;}" + + // 身份卡上的"改名"和"恢复"。以前只有折叠说明里那一条文字链,而"恢复"同时 + // 还在空态卡里出现了第二次 —— 两条都指向同一个对话框。现在只剩这里一处。 + ".aurora-store-why .lnk,.aurora-store-idcard .lnk{" + + "background:none;border:0;padding:0;cursor:pointer;font:inherit;" + + "color:var(--brand,#333);text-decoration:underline;}" + + // 「我的配置」那条横幅。外框直接用 .aurora-store-share(发布面板的容器), + // 这里只多一个三列栅格:缩略图 / 说明 / 动作。窄屏塌成一列,动作排回一行。 + ".aurora-store-mine{display:grid;grid-template-columns:200px 1fr auto;" + + "gap:1.2em;align-items:center;}" + + ".aurora-store-mine .sum{margin:0.35em 0 0;color:var(--text-muted,#777);" + + "font-size:0.88em;}" + + ".aurora-store-mine .acts{display:flex;flex-direction:column;gap:0.5em;}" + + "@media (max-width:700px){.aurora-store-mine{grid-template-columns:1fr;}" + + ".aurora-store-mine .acts{flex-direction:row;}}" + + ".aurora-store-keybox{display:flex;align-items:center;gap:0.7em;margin:0.9em 0;" + + "padding:0.7em 0.8em;border-radius:9px;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "border:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-filebtn{display:inline-block;cursor:pointer;}" + + ".aurora-store-count{display:inline-grid;place-items:center;min-width:1.5em;height:1.5em;" + + "padding:0 .4em;margin-left:.45em;border-radius:99px;font-size:.72em;font-weight:700;" + + "background:var(--brand,#0086bf);color:var(--on-brand,#fff);" + + "font-variant-numeric:tabular-nums;}" + + ".aurora-store-inbox{display:grid;grid-template-columns:minmax(300px,400px) 1fr;" + + "margin-top:1em;border:1px solid var(--hairline,rgba(0,0,0,0.12));border-radius:14px;" + + "overflow:hidden;background:var(--surface,#fff);min-height:520px;}" + + ".aurora-store-inbox .list{border-right:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "display:flex;flex-direction:column;min-width:0;}" + + ".aurora-store-inbox .bar{display:flex;align-items:center;gap:6px;padding:12px 12px 8px;}" + + ".aurora-store-inbox .sp{flex:1;}" + + ".aurora-store-inbox .pill{border:0;font:inherit;font-size:.82em;padding:.3em .85em;" + + "border-radius:99px;cursor:pointer;background:transparent;color:var(--text-muted,#777);}" + + ".aurora-store-inbox .pill:hover{color:var(--text,#111);}" + + ".aurora-store-inbox .pill.on{background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "color:var(--text,#111);font-weight:600;}" + + ".aurora-store-inbox .ib{width:30px;height:30px;padding:0;border-radius:8px;border:0;" + + "background:transparent;display:grid;place-items:center;cursor:pointer;" + + "color:var(--text-subtle,#888);}" + + ".aurora-store-inbox .ib:hover,.aurora-store-inbox .ib:focus-visible{" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));color:var(--text,#111);}" + + ".aurora-store-inbox .ib::before{content:'';width:15px;height:15px;background:currentColor;" + + "-webkit-mask:" + ICON_CHECK + " center/contain no-repeat;" + + "mask:" + ICON_CHECK + " center/contain no-repeat;}" + + ".aurora-store-inbox .ib.unread::before{width:8px;height:8px;border-radius:50%;" + + "-webkit-mask:none;mask:none;}" + + ".aurora-store-inbox .rows{flex:1;overflow:auto;padding:0 8px 8px;}" + + ".aurora-store-inbox .grp{padding:14px 10px 6px;font-size:.78em;" + + "color:var(--text-subtle,#888);}" + + ".aurora-store-inbox .row{display:flex;align-items:center;gap:12px;padding:10px;" + + "border-radius:10px;cursor:pointer;}" + + ".aurora-store-inbox .row:hover{background:var(--hover-faint,rgba(0,0,0,0.03));}" + + ".aurora-store-inbox .row.sel{background:var(--surface-sunken,rgba(0,0,0,0.04));}" + + ".aurora-store-inbox .av{flex:none;width:32px;height:32px;border-radius:50%;display:grid;" + + "place-items:center;background:var(--surface-sunken,rgba(0,0,0,0.04));" + + "color:var(--text-muted,#777);}" + + ".aurora-store-inbox .row.sel .av{background:var(--surface,#fff);}" + + ".aurora-store-inbox .av::before{content:'';width:16px;height:16px;background:currentColor;" + + "-webkit-mask:var(--aurora-store-glyph) center/contain no-repeat;" + + "mask:var(--aurora-store-glyph) center/contain no-repeat;}" + + ".aurora-store-inbox .av.router{--aurora-store-glyph:" + SOURCE_ICONS.router + ";}" + + ".aurora-store-inbox .av.store{--aurora-store-glyph:" + SOURCE_ICONS.store + ";}" + + ".aurora-store-inbox .av.shares{--aurora-store-glyph:" + SOURCE_ICONS.shares + ";}" + + ".aurora-store-inbox .tx{flex:1;min-width:0;}" + + ".aurora-store-inbox .tt{display:flex;align-items:center;gap:7px;}" + + ".aurora-store-inbox .tt b{min-width:0;overflow:hidden;text-overflow:ellipsis;" + + "white-space:nowrap;font-weight:500;color:var(--text-muted,#777);}" + + ".aurora-store-inbox .row.unread .tt b{color:var(--text,#111);font-weight:600;}" + + ".aurora-store-inbox .dot{flex:none;width:7px;height:7px;border-radius:50%;" + + "background:var(--brand,#0086bf);}" + + ".aurora-store-inbox .sn{font-size:.86em;color:var(--text-subtle,#888);overflow:hidden;" + + "text-overflow:ellipsis;white-space:nowrap;margin-top:2px;}" + + ".aurora-store-inbox .rt{flex:none;display:grid;justify-items:end;gap:3px;min-width:28px;}" + + ".aurora-store-inbox .lv{width:15px;height:15px;display:block;}" + + ".aurora-store-inbox .lv.critical,.aurora-store-inbox .lv.warning{background:currentColor;" + + "-webkit-mask:var(--aurora-store-lvl) center/contain no-repeat;" + + "mask:var(--aurora-store-lvl) center/contain no-repeat;}" + + ".aurora-store-inbox .lv.critical{--aurora-store-lvl:" + LEVEL_ICONS.critical + ";" + + "color:var(--danger,#c0392b);}" + + ".aurora-store-inbox .lv.warning{--aurora-store-lvl:" + LEVEL_ICONS.warning + ";" + + "color:var(--warning,#a86a00);}" + + ".aurora-store-inbox .tm{font-size:.76em;color:var(--text-subtle,#888);" + + "font-variant-numeric:tabular-nums;}" + + ".aurora-store-inbox .detail{min-width:0;display:flex;flex-direction:column;}" + + ".aurora-store-inbox .top{display:flex;align-items:center;gap:4px;" + + "padding:10px 14px 10px 28px;border-bottom:1px solid var(--hairline,rgba(0,0,0,0.12));}" + + ".aurora-store-inbox .meta{font-size:.82em;color:var(--text-subtle,#888);}" + + ".aurora-store-inbox .body{padding:28px 40px 36px;max-width:46em;}" + + ".aurora-store-inbox .body h3{margin:0 0 .6em;font-size:1.45em;font-weight:650;" + + "letter-spacing:-.01em;word-break:break-word;}" + + ".aurora-store-inbox .acts{display:flex;gap:.5em;flex-wrap:wrap;margin-top:1.2em;}" + + ".aurora-store-inbox .empty{padding:2.4em 1em;text-align:center;" + + "color:var(--text-subtle,#888);font-size:.9em;}" + + ".aurora-store-inbox .lnk{background:none;border:0;padding:0;font:inherit;font-size:.82em;" + + "color:var(--brand,#0086bf);cursor:pointer;}" + + ".aurora-store-inbox .lnk:hover{text-decoration:underline;}" + + ".aurora-store-inbox .back{display:none;margin-right:.8em;}" + + ".aurora-store-md{color:var(--text-muted,#777);line-height:1.7;word-break:break-word;}" + + ".aurora-store-md p{margin:0 0 .9em;}" + + ".aurora-store-md ul,.aurora-store-md ol{margin:0 0 .9em;padding-left:1.3em;}" + + ".aurora-store-md ul{list-style:disc;}.aurora-store-md ol{list-style:decimal;}" + + ".aurora-store-md li{margin:.2em 0;}" + + ".aurora-store-md p,.aurora-store-md li{color:var(--text-muted,#777);}" + + ".aurora-store-md strong{color:var(--text,#111);font-weight:600;}" + + ".aurora-store-md code{font-family:var(--font-mono,ui-monospace,Menlo,monospace);" + + "font-size:.88em;padding:.1em .4em;border-radius:5px;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));}" + + ".aurora-store-md pre{margin:0 0 .9em;padding:.7em .9em;border-radius:8px;overflow:auto;" + + "background:var(--surface-sunken,rgba(0,0,0,0.04));}" + + ".aurora-store-md pre code{padding:0;background:none;}" + + ".aurora-store-md blockquote{margin:0 0 .9em;padding:.1em 0 .1em 1em;" + + "border-left:2px solid var(--hairline,rgba(0,0,0,0.12));color:var(--text-subtle,#888);}" + + ".aurora-store-md hr{border:0;border-top:1px solid var(--hairline,rgba(0,0,0,0.12));" + + "margin:1.2em 0;}" + + ".aurora-store-md a{color:var(--brand,#0086bf);}" + + ".aurora-store-md h4,.aurora-store-md h5,.aurora-store-md h6{margin:1.2em 0 .4em;" + + "color:var(--text,#111);font-size:1em;}" + + ".aurora-store-compat{margin:.5em 0 0;font-size:.8em;line-height:1.5;" + + "color:var(--text-muted,#777);}" + + ".aurora-store-dim{filter:grayscale(.8);opacity:.7;}" + + "@media " + INBOX_NARROW_QUERY + "{.aurora-store-inbox{grid-template-columns:1fr;}" + + ".aurora-store-inbox .list{border-right:0;}" + + ".aurora-store-inbox.has-sel .list{display:none;}" + + ".aurora-store-inbox:not(.has-sel) .detail{display:none;}" + + ".aurora-store-inbox .body{padding:20px;}" + + ".aurora-store-inbox .top{padding-left:14px;}" + + ".aurora-store-inbox .back{display:inline-block;}}" + + // A card's quick-apply button is revealed by hovering the card. A touch + // screen never hovers, so on a phone that button was simply unreachable -- + // the only way to apply from the grid was to open the drawer first. Keyed on + // the pointer, not the width: a narrow desktop window still has a mouse. + "@media (hover:none){.aurora-store-acts{opacity:1;}}" + + // Without this a flick that runs past the end of the drawer's own scroll + // keeps going in the page underneath, which then sits at a different offset + // when the drawer closes. + ".aurora-store-drawer-body{overscroll-behavior:contain;}" + + "@media (max-width:600px){" + + // The spacer exists to push the search box to the right edge of a wide + // row. Once the row wraps it only adds a blank line. + ".aurora-store-head .sp{display:none;}" + + // Tabs and a 280px input do not share a phone-width line. Dropping the + // spacer and letting the input claim a full basis wraps it onto its own + // row under the tabs -- the same two-row shape as before, minus the row + // that held nothing. + ".aurora-store-head input{flex:1 1 100%;}" + + // Five tabs do not fit on a phone. Scrolling them sideways is what + // LuCI's own .cbi-tabmenu does; wrapping turns a one-line segmented + // control into a two-row block that reads like two separate groups. + // width:max-content from the base rule still hugs the pills when they do + // fit -- max-width:100% is what makes the overflow scroll instead. + ".aurora-store-filters{flex-wrap:nowrap;overflow-x:auto;" + + "scrollbar-width:none;-ms-overflow-style:none;}" + + ".aurora-store-filters::-webkit-scrollbar{display:none;}" + + ".aurora-store-idcard{align-items:flex-start;padding:0.7em 0.8em;}" + + ".aurora-store-share{padding:0.9em 1em 1em;}" + + ".aurora-store-grid{gap:12px;}" + + // The drawer is 92vw here, so its 1.3em gutters cost real reading width. + ".aurora-store-panes{padding:0.8em 1em 0;gap:8px;}" + + ".aurora-store-drawer-body{padding:0.9em 1em;}" + + ".aurora-store-drawer-foot{padding:0.8em 1em;}" + + // Still two columns -- the labels are short and stacking would double the + // table's height -- but the 18px gutter is room the values need here. + ".aurora-store-kv{gap:5px 10px;}" + + // 44% of a 300px drawer ellipsised most targets down to nothing, so a + // shortcut becomes two lines here: glyph + title + the ↗ marker, then the + // target underneath, indented to line up with the title. `order` is what + // keeps ↗ next to the title it marks -- it sits after the target in the + // DOM, and without it the marker gets pushed onto a third line of its own. + ".aurora-store-sc li{flex-wrap:wrap;gap:4px 10px;}" + + ".aurora-store-sc .nm{flex:1 1 auto;}" + + ".aurora-store-sc .to{order:1;flex:1 1 100%;max-width:100%;" + + "padding-left:34px;}" + + "}"; + +return view.extend({ + handleSave: null, + handleSaveApply: null, + handleReset: null, + + load() { + // 刻意只留本地来源。presets.json 随包安装,uci.load 走本机 ubus(实测 + // ~90ms)。到 hub 的两个调用移到 render() 之后 —— LuCI 在 load() resolve + // 前不会进 render(),把一次 205ms RTT / 首连 800ms 的往返放在这里,等于 + // 让首屏白等它。 + return Promise.all([ + L.resolveDefault( + fetch(L.resource("aurora/presets.json") + "?v=" + PRESETS_VERSION).then( + (res) => (res.ok ? res.json() : null), + ), + null, + ), + // 纯本地的一次 ubus(约 0.09s,见 docs/specs/2026-08-05-store-first-paint.md)。 + // 它回答的是整页最要紧的那件事:钩子该落在哪张卡上。 + L.resolveDefault(hubApi.callHubLocalState(), null), + uci.load("aurora"), + ]).then(([presetsRes, localState]) => ({ + presets: (presetsRes && presetsRes.presets) || null, + // 单一真相。active_preset 与 hub_applied 都还在 uci 里给各自的老消费者 + // 用(studio 的表单、get_init_data),但商店的匹配只认这一个。 + activeSource: (localState && localState.active_source) || "", + // 当前外观还等于上次从商店里选的那张卡吗。为假 = 这套外观是用户自己的。 + modified: Boolean(localState && localState.modified), + // 被商店主题盖掉之前的样子,投影成 presets.json 行的形状。 + backupPreview: (localState && localState.backup) || null, + // Set by rpcd whenever the key leaves the router (export or import). + // Lives in uci rather than localStorage so it follows a keep-settings + // upgrade, survives a browser change, and resets to zero on the clean + // reflash that is exactly when the reminder should come back. + keySaved: uci.get("aurora", "theme", "hub_key_saved") === "1", + // This router's own navigation shape. It used to stand in for the + // built-in presets' too, back when a preset changed only colours; now + // every preview draws the configuration's own nav_type and this is read + // by the publish panel alone -- the preview and manifest of what YOUR + // configuration would look like in the store. + navType: uci.get("aurora", "theme", "nav_type") || "mega-menu", + // 会随这份配置一起上传的自定义字体([{slot,family,size}])。rpcd 用的是 + // build_share_payload 打包时的同一个判断,所以发布面板上写着要发什么, + // 线路上发的就是什么。名册预设不在其中 —— 接收端自己去下载。 + sharedFonts: (localState && localState.shared_fonts) || [], + // 同理,会随这份配置发出去的图片和快捷方式图标,带体积。发布面板过去 + // 对图片只写一句 "Included" —— 而体积才是收到这份主题的人真正要拿来 + // 做决定的东西,尤其是 flash 只剩一两 MB 的设备。 + sharedImages: (localState && localState.shared_images) || [], + sharedToolbarIcons: (localState && localState.shared_toolbar_icons) || [], + })); + }, + + render(loadData) { + // 钩子落在「等于当前外观」的那张卡上。一张都不等于 —— 也就是 modified —— + // 就说明当前外观是用户自己的,「我的配置」那张卡代表它。整页的选中态只由 + // 这两个值决定,没有第三处真相。 + const activeSource = loadData.activeSource; + const modified = loadData.modified; + const backupPreview = loadData.backupPreview; + const currentNav = loadData.navType; + const sharedFonts = loadData.sharedFonts; + const sharedImages = loadData.sharedImages; + const sharedToolbarIcons = loadData.sharedToolbarIcons; + + const isCurrentBuiltin = (preset) => + !modified && activeSource === "builtin:" + preset.id; + const isCurrentOnline = (item) => + !modified && activeSource === "hub:" + item.id; + + // Built-in preset card models: local data, offline-safe. When presets.json + // is missing (broken install) the group renders nothing. + // + // `preview` is the projection a hub list row carries (colors / layout / + // typography / toolbar), written by scripts/gen-presets.mjs from the same + // templates rpcd applies. That is the whole point of the shape: paletteOf, + // navOf and tileEntriesFor then read a built-in preset with the accessors + // they already use for a shared configuration, so the two card kinds + // cannot describe a look differently. + const builtinItems = loadData.presets + ? BUILTIN_PRESETS.filter((preset) => loadData.presets[preset.id]).map( + (preset) => ({ + id: preset.id, + label: preset.label, + preview: loadData.presets[preset.id], + }), + ) + : []; + + const state = { + tab: "all", + query: "", + online: { hot: null, new: null }, + onlineFailed: false, + showingStale: false, + }; + + const styleEl = E("style", { type: "text/css" }); + styleEl.textContent = STORE_CSS; + + const rootEl = E("div", { class: "cbi-map aurora-store" }); + const contentEl = E("div", {}); + const drawerMask = E("div", { class: "aurora-store-mask" }); + const drawerEl = E("div", { class: "aurora-store-drawer" }); + + const closeDrawer = () => rootEl.classList.remove("aurora-store-open"); + drawerMask.addEventListener("click", closeDrawer); + + // ------------------------------------------------------------------ + // Rolling back to your own configuration + + // 「我的配置」那张卡的 apply。措辞和应用任何一张卡一致 —— 对用户来说这 + // 就是同一件事,只是这一张碰巧是他自己的。 + const confirmRestore = () => { + ui.showModal(_("Apply My Configuration"), [ + E( + "p", + {}, + _( + "This puts back the look you had before applying a theme from the store.", + ), + ), + buildConfirmActions(() => { + ui.showModal(_("Applying"), [ + E("p", { class: "spinning" }, _("Applying…")), + ]); + L.resolveDefault(hubApi.callHubRestore(), null).then((res) => { + if (res && res.result === 0) { + window.location.reload(); + return; + } + ui.hideModal(); + ui.addNotification( + null, + E("p", {}, restoreErrorMessage(res && res.error)), + "warning", + ); + }); + }, _("Apply")), + ]); + }; + + // ------------------------------------------------------------------ + // Online apply flow (backup -> job -> poll -> reload) + + const pollApplyStatus = (jobId, remaining) => { + if (remaining <= 0) { + ui.hideModal(); + ui.addNotification( + null, + E("p", {}, _("Applying timed out. Please try again.")), + "warning", + ); + return; + } + + window.setTimeout(() => { + L.resolveDefault(hubApi.callGetHubStatus(jobId), {}).then((status) => { + if (status && status.state === "done") { + ui.hideModal(); + ui.addNotification(null, E("p", {}, _("Applied.")), "info"); + // 应用完必须重新加载,否则画面和应用前一模一样 —— 颜色、字体、 + // 图片全是 header.ut 在服务端渲染进文档的(字体 CSS 用 readfile + // 内联,图片靠 icon_cache_version 打戳),都只在文档加载时求值一 + // 次。内置预设和回滚两条路径一直都这么收尾。 + // + // 重新加载也是钩子挪位的唯一途径:active_source 与指纹是 rpcd 在 + // 收尾时写的,只有再读一次 load() 才知道该落在哪张卡上。 + window.location.reload(); + } else if (status && status.state === "error") { + ui.hideModal(); + ui.addNotification( + null, + E("p", {}, applyErrorMessage(status.error)), + "warning", + ); + } else { + pollApplyStatus(jobId, remaining - 1); + } + }); + }, 1500); + }; + + const startApply = (id) => { + ui.showModal(_("Applying"), [ + E("p", { class: "spinning" }, _("Applying this configuration…")), + ]); + L.resolveDefault(hubApi.callHubApply(id), null).then((res) => { + if (!res || res.result !== 0 || !res.job_id) { + ui.hideModal(); + ui.addNotification( + null, + E("p", {}, applyErrorMessage(res && res.error)), + "warning", + ); + return; + } + pollApplyStatus(res.job_id, 20); + }); + }; + + const confirmOnlineApply = (item) => { + closeDrawer(); + const urls = externalToolbarUrls(item.payload || {}); + const onConfirm = () => startApply(item.id); + ui.showModal( + _("Apply Configuration"), + urls.length + ? buildExternalUrlConfirm(urls, onConfirm) + : buildSimpleApplyConfirm(onConfirm), + ); + }; + + // Quick apply from a card: the external-URL callout needs the full + // payload, so fetch the detail first, then reuse the drawer's confirm. + const quickApplyOnline = (id) => { + ui.showModal(_("Applying"), [ + E("p", { class: "spinning" }, _("Loading theme details…")), + ]); + L.resolveDefault(hubApi.callHubGet(id), null).then((res) => { + ui.hideModal(); + if (!res || res.result !== 0 || !res.data) { + ui.addNotification( + null, + E("p", {}, applyErrorMessage(res && res.error)), + "warning", + ); + return; + } + confirmOnlineApply(res.data); + }); + }; + + // ------------------------------------------------------------------ + // Built-in preset apply: local rpcd path, identical semantics (and + // msgids) to the old preset dropdown -- colors only, immediate, reload. + + const confirmBuiltinApply = (preset) => { + closeDrawer(); + ui.showModal(_("Apply Preset"), [ + E( + "p", + {}, + _( + "Apply the '%s' preset now? It is saved immediately and the page reloads. A preset carries a whole look — colors, navigation, spacing, corner radius, content width, fonts and shortcuts. Whatever the theme you are wearing now brought with it is replaced; images you uploaded yourself stay as they are.", + ).format(preset.label), + ), + buildConfirmActions(() => { + ui.showModal(_("Applying..."), [ + E("p", { class: "spinning" }, _("Applying preset...")), + ]); + L.resolveDefault(callApplyThemePreset(preset.id), {}).then((ret) => { + ui.hideModal(); + if (ret && ret.result === 0) { + ui.addNotification( + null, + E("p", _("Preset applied successfully.")), + "info", + ); + window.location.reload(); + } else { + ui.addNotification( + null, + E( + "p", + _("Apply failed: %s").format( + (ret && ret.error) || _("Unknown"), + ), + ), + "error", + ); + } + }); + }, _("Apply")), + ]); + }; + + // ------------------------------------------------------------------ + // Detail drawer + + const renderDrawer = (children) => { + while (drawerEl.firstChild) drawerEl.removeChild(drawerEl.firstChild); + children.forEach((child) => drawerEl.appendChild(child)); + rootEl.classList.add("aurora-store-open"); + }; + + const drawerFoot = (applyLabel, onApply, current) => + E("div", { class: "aurora-store-drawer-foot" }, [ + E("button", { type: "button", class: "btn", click: closeDrawer }, _("Close")), + current + ? E( + "button", + { type: "button", class: "btn apply", disabled: "disabled" }, + "✓ " + _("In use"), + ) + : E( + "button", + { + type: "button", + class: "btn cbi-button-action important apply", + click: onApply, + }, + applyLabel, + ), + ]); + + const openBuiltinDrawer = (preset) => { + const current = isCurrentBuiltin(preset); + const typography = preset.preview.typography || {}; + const title = E("h3", { style: "margin:0 0 0.3em;" }, [ + document.createTextNode(preset.label), + " ", + E("span", { class: "aurora-store-badge builtin" }, _("Built-in")), + ]); + if (current) title.appendChild(buildCurrentTick()); + + const body = [ + title, + E( + "p", + { class: "aurora-store-dt-desc" }, + _("Ships with the theme and works offline."), + ), + buildDetailHeading(_("Colors")), + buildPaletteChips(preset), + buildDetailHeading(_("Layout & Typography")), + buildLayoutRows(preset.preview.layout || {}, typography), + ]; + + const presetShortcuts = buildShortcutList( + preset, + preset.preview.toolbar, + preset.preview.layout || {}, + ); + if (presetShortcuts) body.push(...presetShortcuts); + + if (needsFontDownload(typography)) + body.push( + E( + "p", + { class: "aurora-store-sc-note" }, + _( + "This preset uses a downloaded typeface. The router fetches it once after applying; until then the text falls back to the built-in font.", + ), + ), + ); + + body.push( + E( + "p", + { class: "aurora-store-dt-foot" }, + _( + "Applying replaces the shortcuts above and removes the logo, icons and backgrounds the current theme brought with it. Images you uploaded yourself stay as they are.", + ), + ), + ); + + renderDrawer([ + buildPanes(paletteOf(preset), previewOpts(preset)), + E("div", { class: "aurora-store-drawer-body" }, body), + drawerFoot(_("Apply"), () => confirmBuiltinApply(preset), current), + ]); + }; + + const openMineDrawer = (item) => { + const preview = item.preview || {}; + renderDrawer([ + buildPanes(paletteOf(item), previewOpts(item)), + E("div", { class: "aurora-store-drawer-body" }, [ + E("h3", { style: "margin:0 0 0.3em;" }, [ + document.createTextNode(_("My configuration")), + ]), + E( + "p", + { class: "aurora-store-dt-desc" }, + modified + ? _("The look this router is wearing right now.") + : _( + "How this router looked before you applied a theme from the store.", + ), + ), + buildDetailHeading(_("Colors")), + buildPaletteChips(item), + buildDetailHeading(_("Layout & Typography")), + buildLayoutRows(preview.layout || {}, preview.typography || {}), + ]), + drawerFoot(_("Apply"), () => confirmRestore(), modified), + ]); + }; + + const renderDrawerLoadError = () => { + if (!rootEl.classList.contains("aurora-store-open")) return; + renderDrawer([ + E("div", { class: "aurora-store-drawer-body" }, [ + E("p", {}, _("Unable to load this theme right now. Please try again.")), + ]), + E("div", { class: "aurora-store-drawer-foot" }, [ + E("button", { type: "button", class: "btn", click: closeDrawer }, _("Close")), + ]), + ]); + }; + + const openOnlineDrawer = (id) => { + renderDrawer([ + E("div", { class: "aurora-store-drawer-body" }, [ + E("p", { class: "spinning" }, _("Loading theme details…")), + ]), + ]); + L.resolveDefault(hubApi.callHubGet(id), null) + .then((res) => { + if (!rootEl.classList.contains("aurora-store-open")) return; + if (!res || res.result !== 0 || !res.data) { + renderDrawerLoadError(); + return; + } + const item = res.data; + renderDrawer([ + buildPanes(paletteOf(item), previewOpts(item)), + E("div", { class: "aurora-store-drawer-body" }, [buildDetailBody(item)]), + drawerFoot(_("Apply"), () => confirmOnlineApply(item), isCurrentOnline(item)), + ]); + }) + .catch(renderDrawerLoadError); + }; + + // ------------------------------------------------------------------ + // Cards + + const buildBadge = (label, extraClass) => + E("span", { class: "aurora-store-badge " + (extraClass || "") }, label); + + // 内置卡和社区卡画的是同一个东西,只是数据来源不同。两个各自为政的构造器 + // 已经漂移过一次 —— 社区卡长出了徽章而内置卡没有 —— 所以只留一个。 + const buildCard = (model) => { + const prev = E("div", { class: "aurora-store-prev" }, [ + buildDuo(model.palette, model.opts), + E("span", { class: "aurora-store-dots" }, [buildDotRow(model.palette)]), + E("div", { class: "aurora-store-acts" }, [ + E( + "button", + { + type: "button", + class: "btn cbi-button-action", + click: (ev) => { + ev.stopPropagation(); + model.apply(); + }, + }, + model.current ? _("Re-apply") : _("Apply"), + ), + ]), + ]); + if (model.current) prev.appendChild(buildCurrentPin()); + + const nm = E("div", { class: "aurora-store-nm" }, [ + document.createTextNode(model.name), + ]); + if (model.badge) nm.appendChild(model.badge); + if (model.glyphs) nm.appendChild(model.glyphs); + + return E( + "div", + { + class: "aurora-store-card" + (model.current ? " current" : ""), + click: model.open, + }, + [ + prev, + E("div", { class: "aurora-store-meta" }, [ + nm, + E("div", { class: "aurora-store-ft" }, [ + E("span", { style: "word-break:break-all;" }, [ + document.createTextNode(model.author), + ]), + E("span", { class: "dl" }, [document.createTextNode(model.right)]), + ]), + ]), + ], + ); + }; + + // 内置卡不带 "Built-in" 徽章:它上方的分区标题已经写着"内置",而每张卡 + // 再重复一遍就是拿掉一行密度换零信息。徽章那个样式类留着,抽屉还在用。 + // + // 缩略图画的是预设自己的导航形态,glyph 行出的是它自己的字体和圆角 —— + // 内置预设现在带的是一整套外观,而不是一组色值,卡片必须让人在点进去之前 + // 就看得出这一点。 + const buildBuiltinCard = (preset) => buildCard({ + name: preset.label, + author: "Aurora", + right: _("Works offline"), + palette: paletteOf(preset), + opts: previewOpts(preset), + badge: null, + glyphs: buildCardGlyphs(preset), + current: isCurrentBuiltin(preset), + open: () => openBuiltinDrawer(preset), + apply: () => confirmBuiltinApply(preset), + }); + + const buildOnlineCard = (item) => buildCard({ + name: item.name || _("Untitled theme"), + author: item.author || _("Anonymous"), + right: formatDownloads(item.downloads), + palette: paletteOf(item), + opts: previewOpts(item), + // 只有 approved 才是「真的带着素材」。pending 已经被商店列表挡在外面, + // 而 rejected 的图片已经被删干净了 —— 这两种状态下的徽章都是空头支票。 + badge: + item.assets_status === "approved" ? buildBadge(_("Includes assets")) : null, + glyphs: buildCardGlyphs(item), + current: isCurrentOnline(item), + open: () => openOnlineDrawer(item.id), + apply: () => quickApplyOnline(item.id), + }); + + // ------------------------------------------------------------------ + // My configuration + // + // 一个槽位,两个时刻。改过 -> 这张卡就是屏幕上正在发生的样子;没改过 -> + // 它是被商店主题盖掉之前你自己那份。「备份」和「我的配置」本来就是同一个 + // 东西 —— 你被别人的主题盖掉之前的样子 —— 所以它们共用这一张卡,回滚也 + // 就不再是一套要理解的机制,只是把它点回来。 + + // 当前 uci 投影成一行 preview,与 presets.json 的行、与 hub 列表行同一个 + // 形状,于是 paletteOf/navOf/tileEntriesFor 读它和读那两种用的是同一套访问 + // 器。三种卡,一套访问器,同一个外观不可能被画成两个样子。 + const currentPreview = () => ({ + colors: SWATCH_KEYS.reduce((acc, key) => { + ["light", "dark"].forEach((mode) => { + acc[mode + "_" + key] = + uci.get("aurora", "theme", mode + "_" + key) || ""; + }); + return acc; + }, {}), + layout: { + nav_type: currentNav, + struct_radius_base: + uci.get("aurora", "theme", "struct_radius_base") || "", + }, + typography: { + struct_font_sans: uci.get("aurora", "theme", "struct_font_sans") || "", + struct_font_mono: uci.get("aurora", "theme", "struct_font_mono") || "", + }, + }); + + const mineItem = () => { + if (!modified && !backupPreview) return null; + return { preview: modified ? currentPreview() : backupPreview }; + }; + + const buildMineModel = () => { + const item = mineItem(); + if (!item) return null; + return { + name: _("My configuration"), + author: _("On this router only"), + right: modified ? _("In use") : _("Not in use"), + palette: paletteOf(item), + opts: previewOpts(item), + badge: null, + glyphs: buildCardGlyphs(item), + current: modified, + open: () => openMineDrawer(item), + apply: () => confirmRestore(), + }; + }; + + const buildMineGrid = () => { + const model = buildMineModel(); + if (!model) return null; + return E("div", { class: "aurora-store-grid" }, [buildCard(model)]); + }; + + // 同一份配置,两种画法,按它在哪一页出场。 + // + // 「全部」页上它是可浏览的一组里的一张卡(buildMineGrid),和内置、社区 + // 那两组同一种形状。「我的」页上它是主角,所以摊成一条横幅:一张 252px + // 的卡孤零零待在一个为 N 件作品准备的 auto-fill 网格里,后面拖着几格空白 + // —— 而这一格永远只有一件,因为它就是当前 uci 的投影。 + // + // 横幅换来的宽度不是留白,是清单:发出去到底带什么,连体积一起。那份清单 + // 直接调 shareManifestRows(),发布面板用的是同一个函数 —— 不复制,否则以 + // 后加一项配置要改两处,两处必然会长歪。 + const buildMineBanner = () => { + const model = buildMineModel(); + if (!model) return null; + + const prev = E("div", { class: "aurora-store-prev" }, [ + buildDuo(model.palette, model.opts), + E("span", { class: "aurora-store-dots" }, [buildDotRow(model.palette)]), + ]); + if (model.current) prev.appendChild(buildCurrentPin()); + + const nm = E("div", { class: "aurora-store-nm" }, [ + document.createTextNode(_("My configuration")), + E( + "span", + { class: "aurora-store-pill " + (modified ? "ok" : "") }, + modified ? _("In use") : _("Not in use"), + ), + ]); + if (model.glyphs) nm.appendChild(model.glyphs); + + // 主动作跟着这条横幅代表的东西走,不是跟着按钮的可用性走。改过 -> + // 它就是屏幕上正在发生的样子,发布发的就是它。没改过 -> 它是被商店 + // 主题盖掉之前那份备份,而 build_share_payload 打包的是当前 uci,不是 + // 这张预览 —— 那时"发布"会发出一份和画面上不符的东西,所以这里根本 + // 不给这个按钮,而不是把它置灰了事。 + const acts = modified + ? [ + E( + "button", + { + type: "button", + class: "btn cbi-button-action important", + click: () => openSharePanel(), + }, + _("Share to the store"), + ), + E( + "button", + { type: "button", class: "cbi-button", click: () => model.open() }, + _("Details"), + ), + ] + : [ + E( + "button", + { + type: "button", + class: "btn cbi-button-action important", + click: () => confirmRestore(), + }, + _("Apply"), + ), + E( + "button", + { type: "button", class: "cbi-button", click: () => model.open() }, + _("Details"), + ), + ]; + + return E("div", { class: "aurora-store-share aurora-store-mine" }, [ + E("div", { class: "aurora-store-share-prev" }, [prev]), + E("div", {}, [ + nm, + E( + "p", + { class: "sum" }, + modified + ? _("The look this router is wearing right now.") + : _("How this router looked before you applied a theme from the store."), + ), + // 抽屉里那排「随附内容」用的同一个 buildTiles。清单本身来自 + // shareManifestRows() —— 发布面板的那一份,一字不差,所以横幅上 + // 写着要发什么,点下去发的就是什么。 + buildTiles( + shareManifestRows().map((row) => ({ + glyph: "·", + label: row.label, + meta: row.detail, + title: row.label + " — " + row.detail, + })), + ), + ]), + E("div", { class: "acts" }, acts), + ]); + }; + + // ------------------------------------------------------------------ + // Inbox + + let inboxSnapshot = hubApi.noticesCache.getStale(); + let meData = null; + let inboxFilter = "all"; + let inboxSel = null; + + const uiLang = (document.documentElement.lang || "en").replace(/_/g, "-"); + + const formatDay = (isoDay) => { + try { + return new Intl.DateTimeFormat(uiLang, { dateStyle: "medium", timeZone: "UTC" }).format( + new Date(isoDay + "T00:00:00Z"), + ); + } catch (e) { + return isoDay; + } + }; + + const formatAge = (stamp) => { + const age = notices.ageOf(notices.stampMs(stamp), Date.now()); + if (!age) return ""; + try { + return new Intl.RelativeTimeFormat(uiLang, { style: "narrow", numeric: "auto" }).format( + age.value, + age.unit, + ); + } catch (e) { + return ""; + } + }; + + const quoted = (name) => "“" + (name || _("Untitled theme")) + "”"; + + const inboxItems = () => notices.inbox(inboxSnapshot, meData, hubApi.inboxState()); + + const SOURCE_LABELS = { + [notices.SOURCE_ROUTER]: _("This router"), + [notices.SOURCE_STORE]: _("Marketplace"), + [notices.SOURCE_SHARES]: _("Your shares"), + }; + + const markdownLink = (url) => { + const safe = notices.safeUrl(url); + return safe && !notices.isExternalUrl(safe) ? L.url(safe) : safe; + }; + + const inboxShown = () => + inboxItems().filter((item) => inboxFilter === "all" || item.unread); + + // 派生条目由现状得出,notices.js 只给事实;这里负责把事实写成话。 + // 广播通知的正文是 Markdown;派生条目和审核员原话一律纯文本。 + const describeItem = (item) => { + const source = SOURCE_LABELS[item.source]; + if (item.kind === "notice") { + const shown = notices.present(item.notice, document.documentElement.lang); + return { + title: shown.title, + source: source, + time: formatAge(item.at), + snippet: markdown.toText(shown.body), + markdown: shown.body, + link: shown.url ? shown : null, + }; + } + + if (item.kind === "feed") + return { + title: _("Add the Aurora update source"), + source: source, + body: [ + _("Upgrading Aurora needs its update source. Once added, upgrades happen in System → Software like any other OpenWrt package."), + _("From openwrt.eamonxg.fun; the signing key ships with this package."), + ].concat( + item.theme && item.app + ? _("Installed now: theme %s, configuration app %s.").format(item.theme, item.app) + : [], + ), + act: { label: _("Add update source"), run: confirmAddFeed }, + }; + + const review = { label: _("Review my shares"), run: () => selectTab("mine") }; + if (item.kind === "compat") { + const n = item.names.length; + const named = _("Published with an older configuration format: %s.").format( + item.names.map(quoted).join(", "), + ); + if (item.state === "unsupported") + return { + title: + n === 1 + ? _("1 of your shared themes is not listed for updated routers") + : _("%d of your shared themes are not listed for updated routers").format(n), + source: source, + body: [ + named, + _("Updated routers no longer see them. Update each one from this router to relist it — the link, the downloads and the name are kept."), + ], + act: review, + }; + return { + title: + n === 1 + ? _("1 of your shared themes needs an update") + : _("%d of your shared themes need an update").format(n), + source: source, + body: [ + named, + item.sunset + ? _("They stay listed for routers that have not upgraded. From %s, updated routers stop seeing them until you publish them again from this router — the link, the downloads and the name are kept.").format(formatDay(item.sunset)) + : _("They stay listed for routers that have not upgraded. Later, updated routers stop seeing them until you publish them again from this router — the link, the downloads and the name are kept."), + ], + act: review, + }; + } + + if (item.kind === "rejected") + return { + title: _("Assets not approved: %s").format(quoted(item.name)), + source: source, + body: (item.reason ? [_("Reviewer’s note: %s").format(item.reason)] : []).concat( + _("The colours and layout of this theme are live. Only the assets are held back."), + ), + act: { label: _("Open %s").format(quoted(item.name)), run: () => selectTab("mine") }, + }; + + return { + title: _("Taken down: %s").format(quoted(item.name)), + source: source, + body: [_("Taken down — no longer in the store. Nothing you can do from here.")], + }; + }; + + const inboxChanged = () => { + renderTabLabel(INBOX_TAB); + inboxIndicator.update(); + if (state.tab === "inbox") renderContent(); + }; + + const updateInboxState = (next) => { + hubApi.setInboxState(next(hubApi.inboxState())); + inboxChanged(); + }; + + const markRead = (key) => + updateInboxState((st) => ({ read: notices.withKey(st.read, key), done: st.done })); + + const markUnread = (key) => + updateInboxState((st) => ({ read: notices.withoutKey(st.read, key), done: st.done })); + + const markAllRead = () => + updateInboxState((st) => ({ + read: notices.withKeys(st.read, inboxItems()), + done: st.done, + })); + + const markDone = (key) => { + if (inboxSel === key) inboxSel = null; + updateInboxState((st) => ({ read: st.read, done: notices.withKey(st.done, key) })); + }; + + const pickItem = (key) => { + inboxSel = key; + markRead(key); + }; + + // 现有的"添加更新源"流程,原样从工作台搬来:同一个确认框、同一个 rpcd 方法、 + // 同样的三种结局。源一旦写进去,条目自己消失,顶栏计数跟着变。 + const runAddFeed = () => { + ui.showModal(_("Add the Aurora update source"), [ + E("p", { class: "spinning" }, _("Adding the update source…")), + ]); + return Promise.all([ + L.resolveDefault(hubApi.callAddFeed(), { result: 1 }), + L.resolveDefault(ui.menu.load(), null), + ]).then(([ret, menuTree]) => { + ui.hideModal(); + if (!ret || ret.result !== 0) { + ui.addNotification( + null, + E("p", {}, [ + document.createTextNode( + _("Could not add the update source: %s").format( + (ret && ret.error) || _("Unknown error"), + ), + ), + ]), + "warning", + ); + return; + } + const softwarePage = feedCheck.pickPackageManagerPath(menuTree); + ui.addNotification( + null, + E( + "p", + {}, + [ + ret.index_refreshed + ? _("Update source added") + : _("The update source was written, but refreshing the index failed. Hit Refresh on the Software page later."), + ].concat( + softwarePage + ? [" ", E("a", { href: L.url(softwarePage) }, _("Go to Software"))] + : [], + ), + ), + ret.index_refreshed ? "info" : "warning", + ); + inboxSnapshot = hubApi.setNoticesLocal( + Object.assign({}, inboxSnapshot && inboxSnapshot.local, { feedMissing: false }), + ); + inboxChanged(); + }); + }; + + const confirmAddFeed = () => + ui.showModal(_("Add the Aurora update source"), [ + E( + "p", + {}, + _("Afterwards you can upgrade Aurora in System → Software, like any other OpenWrt package."), + ), + E( + "p", + { class: "cbi-value-description" }, + _("From openwrt.eamonxg.fun; the signing key ships with this package."), + ), + buildConfirmActions(runAddFeed, _("Add")), + ]); + + const buildInboxRow = (item) => { + const copy = describeItem(item); + const title = [E("b", {}, [document.createTextNode(copy.title)])]; + if (item.unread) title.unshift(E("span", { class: "dot" })); + return E( + "div", + { + class: "row" + (item.unread ? " unread" : "") + (item.key === inboxSel ? " sel" : ""), + click: () => pickItem(item.key), + }, + [ + E("span", { class: "av " + item.source }), + E("div", { class: "tx" }, [ + E("div", { class: "tt" }, title), + E("div", { class: "sn" }, [ + document.createTextNode(copy.markdown === undefined ? copy.body[0] : copy.snippet), + ]), + ]), + E("div", { class: "rt" }, [ + E("span", { class: "lv" + (item.level === "info" ? "" : " " + item.level) }), + E("span", { class: "tm" }, [document.createTextNode(copy.time || "")]), + ]), + ], + ); + }; + + const iconButton = (extraClass, label, onClick) => + E("button", { + type: "button", + class: "ib" + extraClass, + title: label, + "aria-label": label, + click: onClick, + }); + + const buildInboxDetail = (item) => { + const copy = describeItem(item); + const body = E("div", { class: "aurora-store-md" }); + if (copy.markdown === undefined) + copy.body.forEach((paragraph) => + body.appendChild(E("p", {}, [document.createTextNode(paragraph)])), + ); + else body.appendChild(markdown.render(copy.markdown, document, markdownLink)); + + const content = [E("h3", {}, [document.createTextNode(copy.title)]), body]; + if (copy.link) + content.push( + E("div", { class: "acts" }, [ + E( + "a", + Object.assign( + { class: "cbi-button cbi-button-action" }, + copy.link.external + ? { href: copy.link.url, target: "_blank", rel: "noreferrer" } + : { href: L.url(copy.link.url) }, + ), + _("Learn more"), + ), + ]), + ); + else if (copy.act) + content.push( + E("div", { class: "acts" }, [ + E( + "button", + { type: "button", class: "cbi-button cbi-button-action", click: copy.act.run }, + [document.createTextNode(copy.act.label)], + ), + ]), + ); + + return [ + E("div", { class: "top" }, [ + E( + "button", + { + type: "button", + class: "lnk back", + click: () => { + inboxSel = null; + renderContent(); + }, + }, + "← " + _("Inbox"), + ), + E("span", { class: "meta" }, [ + document.createTextNode(copy.time ? copy.source + " · " + copy.time : copy.source), + ]), + E("span", { class: "sp" }), + iconButton(" unread", _("Mark unread"), () => markUnread(item.key)), + iconButton("", _("Done"), () => markDone(item.key)), + ]), + E("div", { class: "body" }, content), + ]; + }; + + const buildInbox = () => { + const items = inboxItems(); + const narrow = window.matchMedia(INBOX_NARROW_QUERY).matches; + // 窄屏上列表和详情二选一:替用户选中第一条,等于一进来就把列表藏了。 + if (!items.some((item) => item.key === inboxSel)) + inboxSel = narrow || !items.length ? null : items[0].key; + const current = items.filter((item) => item.key === inboxSel)[0]; + const shown = inboxShown(); + + const rows = []; + [ + [notices.GROUP_ACTION, _("Needs action")], + [notices.GROUP_UPDATES, _("Updates")], + ].forEach(([group, label]) => { + const part = shown.filter((item) => item.group === group); + if (!part.length) return; + rows.push(E("div", { class: "grp" }, label)); + part.forEach((item) => rows.push(buildInboxRow(item))); + }); + + return E("div", { class: "aurora-store-inbox" + (current ? " has-sel" : "") }, [ + E("div", { class: "list" }, [ + E( + "div", + { class: "bar" }, + [ + ["all", _("All")], + ["unread", _("Unread")], + ] + .map(([key, label]) => + E( + "button", + { + type: "button", + class: "pill" + (inboxFilter === key ? " on" : ""), + click: () => { + inboxFilter = key; + renderContent(); + }, + }, + label, + ), + ) + .concat(E("span", { class: "sp" }), iconButton("", _("Mark all read"), markAllRead)), + ), + E( + "div", + { class: "rows" }, + rows.length ? rows : [E("div", { class: "empty" }, _("You’re all caught up."))], + ), + ]), + E( + "div", + { class: "detail" }, + current + ? buildInboxDetail(current) + : [E("div", { class: "empty" }, _("Select a notification"))], + ), + ]); + }; + + // J/K/E/U 只属于 Inbox tab;带修饰键的组合(⌘K 是主题的命令面板)不归这里管。 + let inboxMounted = false; + const onInboxKey = (ev) => { + if (rootEl.isConnected) inboxMounted = true; + else if (inboxMounted) return document.removeEventListener("keydown", onInboxKey); + if (state.tab !== "inbox" || ev.ctrlKey || ev.metaKey || ev.altKey) return; + if (ev.target.closest && ev.target.closest("input,textarea,select,[contenteditable]")) + return; + const shown = inboxShown(); + const at = shown.findIndex((item) => item.key === inboxSel); + const key = String(ev.key).toLowerCase(); + if (key === "j" && shown[at + 1]) pickItem(shown[at + 1].key); + else if (key === "k" && at > 0) pickItem(shown[at - 1].key); + else if (key === "e" && inboxSel) markDone(inboxSel); + else if (key === "u" && inboxSel) markUnread(inboxSel); + }; + document.addEventListener("keydown", onInboxKey); + + const onInboxHash = () => { + if (rootEl.isConnected) inboxMounted = true; + else if (inboxMounted) return window.removeEventListener("hashchange", onInboxHash); + if (window.location.hash === inboxIndicator.INBOX_HASH && state.tab !== "inbox") + selectTab("inbox"); + }; + window.addEventListener("hashchange", onInboxHash); + + // ------------------------------------------------------------------ + // My shares + + const mySharesEl = E("div", { id: "aurora-hub-my-shares" }); + + // One request carries both halves of the answer: who this router + // publishes as, and what it has published. + // hub_me 是本页唯一还走 ubus 的网络调用 —— device_token 是 0600 的写凭证 + // (hub_share/hub_update/hub_delete 都靠它认证),不下发到浏览器。整条链路 + // 实测约 0.9s,其中 ubus 只占 0.09s,其余全是路由器到 hub 那次不可复用的 + // TLS 握手加 205ms 跨太平洋 RTT。所以结果要落缓存:下次打开页面由缓存先 + // 画,这一次往返就不再挡在首屏前面。 + // + // 失败时刻意什么都不做。原先是 applyMe(null),而 applyMe 会把 myShares + // 置空 —— 断网的用户会眼看着自己已发布的作品从界面上消失。保留上一帧才 + // 是诚实的:我们只是没拿到新数据,不是作品没了。 + const refreshMyShares = () => + L.resolveDefault(hubApi.callHubMe(), null).then((res) => { + if (res && res.result === 0) { + // 失败时刻意不置 myLoaded:一台冷启动就断网的路由器,我们并不知道 + // 它有没有身份 —— 画那张"还没有创作者身份"的卡就是替 hub 编答案。 + myLoaded = true; + myFailed = false; + hubApi.meCache.set(res.data); + applyMe(res.data); + } else { + myFailed = true; + // 保留上一帧的作品,但这一帧要重画:没有上一帧时它是"连不上 + 重试", + // 不是空态。 + renderMyShares(myShares); + } + // 身份卡是 renderContent 画的,而这次请求正是它等的那份数据 —— 加载态 + // 与空态的切换只有在这里重画才看得见。 + if (state.tab === "mine") renderContent(); + return myFailed ? null : res.data; + }); + + const applyMe = (data) => { + // 缓存那一帧同样算"已加载":它是真数据,只是旧的。首屏靠它立刻成型, + // 不该被自己的 spinner 盖住。 + if (data) myLoaded = true; + profile = { + id: (data && data.id) || null, + nickname: (data && data.nickname) || null, + }; + meData = data || null; + renderMyShares((data && data.configs) || []); + inboxChanged(); + }; + + // An update rebuilds and re-sends the whole configuration -- logo, login + // background, site and app icons, shortcuts included -- so it shows the + // same manifest the publish panel does instead of a bare one-liner. + // "更新"的意思是用工作台当前这套配置替换掉商店里的它 —— 它和发布是同一 + // 件事,只差一个目标。所以这里不再弹一个跟当前配置八竿子打不着的确认框, + // 而是把人送回同一张表单,那条分享已经选中、名字和描述已经填好。一套 UI, + // 两个入口。 + const openUpdateForm = (item) => { + shareTarget = item.id; + shareOpen = true; + nameInput.value = item.name || ""; + descInput.value = item.description || ""; + renderContent(); + }; + + // 同一张面板的另一个入口,来自「我的配置」横幅。openUpdateForm 进来时 + // 选中了一条已有分享("用当前配置替换它"),这里什么都不选("发一份新的")。 + // + // 这一页此前一个发布入口都没有 —— studio.js 的注释记着为什么:页头按钮、 + // 面板标题、空态卡按钮三个入口同时在场,说的都是同一句话,于是一起删掉了。 + // 这次只加回来一个,而且挂在被发布的那个对象上。工作台那个入口保留,两处 + // 落到同一张表单。 + const openSharePanel = () => { + shareTarget = ""; + shareOpen = true; + nameInput.value = ""; + descInput.value = ""; + renderContent(); + }; + + // btn 是发起这次删除的那一行的 Delete 键:确认框关掉之后,hub_delete 加上 + // 随后的 hub_me 还要走两趟网络,而这两趟里屏幕上原本什么都不会变。 + const confirmDeleteShare = (item, btn) => { + // Static message, deliberately not interpolating item.name: asset-upload's + // confirmDelete renders opts.message as a bare E() child, which LuCI can + // route through innerHTML, and the hub config name is attacker-controlled + // free text (the hub only strips control characters). + assetUpload + .confirmDelete({ + title: _("Delete Share"), + message: _("Delete this shared configuration from the theme store?"), + }) + .then((confirmed) => { + if (!confirmed) return; + return withBusy( + btn, + L.resolveDefault(hubApi.callHubDelete(item.id), null).then((res) => { + if (res && res.result === 0) { + ui.addNotification(null, E("p", {}, _("Deleted.")), "info"); + } else { + ui.addNotification( + null, + E("p", {}, deleteErrorMessage(res && res.error)), + "warning", + ); + } + // 失败也要重读一次。invalid_id 说的是"商店那边已经没有这件 + // 作品了",此时留着那一行就是把用户再送回同一个 404。 + return refreshMyShares(); + }), + ); + }); + }; + + // 只在需要作者做点什么、或者需要解释一个反常结果时才出声。none 和 + // approved 是正常态 —— 给正常态也配一行字,列表就变成一片噪音,真正 + // 要紧的那两行反而沉进去了。 + const reviewNoteFor = (item) => { + const status = item.assets_status; + if (status === "pending") + return { + text: _("In review — appears in the store once its assets are approved."), + color: "var(--text-muted)", + }; + if (status === "rejected") + return { + // 只报状态,不替审核员说话。说「素材」不说「图片」:字体也在 + // 待审之列,一份被驳回的中文字体配上「图片未通过」,作者会去 + // 翻他根本没问题的那几张图。 + text: _("Assets not approved"), + color: "var(--warning)", + // 审核员写的原话,只有英文一种。它不进翻译表:那句话是人现写的, + // 具体到「哪张图、哪里不对」,没有哪个语种的译文能提前准备好。 + // 老 hub 不带这个字段,那时就只剩上面那个状态标签。 + detail: item.assets_reject_reason || "", + }; + return null; + }; + + const compatNoteFor = (item, compat) => { + if (compat === "unsupported") + return _("Updated routers no longer see this theme. Update it to relist — link and downloads are kept."); + const sunset = notices.sunsetDate(item); + return sunset + ? _("Listed until %s. Publish it again from this router to keep it listed.").format(formatDay(sunset)) + : _("Publish it again from this router to keep it listed."); + }; + + const buildMyShareRow = (item) => { + const compat = item.status === "removed" ? null : notices.compatBadge(item); + const updateBtn = E( + "button", + { + type: "button", + class: compat ? "cbi-button cbi-button-action" : "cbi-button", + click: () => openUpdateForm(item), + }, + _("Update with current configuration"), + ); + const deleteBtn = E( + "button", + { + type: "button", + class: "cbi-button cbi-button-remove", + click: () => confirmDeleteShare(item, deleteBtn), + }, + _("Delete"), + ); + + // The action cell is `td.cbi-section-actions` wrapping a single
, + // which is the shape LuCI's own TableSection emits. Under the mobile + // breakpoint every theme gives that cell the whole row width and lets + // the buttons inside it wrap. It used to be `td center` with + // white-space:nowrap, which pinned "Update with current configuration" + // and "Delete" onto one unbreakable line: on a 390px phone the row ran + // to 748px and Delete sat off the right edge of the screen. + // + // Deliberately no data-title on the two data cells. Themes hide + // `.tr.table-titles` on a phone and reprint the column name from that + // attribute, which is worth it when a bare cell would be ambiguous -- + // here "Nord Midnight" and "128 downloads" already say what they are, + // and a "Name"/"Downloads" line above each would only stutter. + // 状态挂在名字底下,不占一列:这张表刚在 390px 上修过溢出,第四列会让 + // 它重演。而状态本来就是在说这一件作品,贴着它的名字最省一次目光移动。 + // 一行色点走在名字前面。/api/v1/me 现在带回和商店列表同一份 preview, + // 所以这几个色值和别人在商店里看到的这件作品是同一批 —— 作者不必靠 + // 名字回忆哪件是哪件。 + const nameCell = E("td", { class: "td", style: "word-break:break-word;" }, [ + E( + "span", + { + class: compat === "unsupported" ? "aurora-store-dim" : "", + style: "display:inline-flex;vertical-align:middle;margin-right:8px;", + }, + [buildDotRow(paletteOf(item))], + ), + document.createTextNode(item.name || _("Untitled theme")), + ]); + if (compat) + nameCell.appendChild( + E( + "span", + { class: "aurora-store-pill risk", style: "margin-left:8px;" }, + compat === "unsupported" + ? _("Not listed for updated routers") + : _("Needs update"), + ), + ); + if (compat) + nameCell.appendChild( + E("div", { class: "aurora-store-compat" }, compatNoteFor(item, compat)), + ); + + // 被下架的那一行:它不是错误,是这件作品此刻唯一的真相,而这里是作者 + // 唯一会被告知的地方 —— 公开的浏览接口按设计不会再列出它。 + // + // 不给按钮,两个都不给。更新一件已经不在商店里的作品没有意义;而删除 + // 走的 hub_delete 要求 status='active'(见 hub 的 requireOwnedConfig), + // 对着已下架的调过去只会拿到 404,而 404 在 shell 那边只能报成"连不上 + // 商店" —— 那正是这张表以前那个"删了还在"的老毛病的后半段。 + const takenDown = item.status === "removed"; + if (takenDown) { + nameCell.appendChild( + E( + "div", + { style: "margin-top:2px;font-size:0.84em;color:var(--warning);" }, + _("Taken down — no longer in the store. Nothing you can do from here."), + ), + ); + } else { + const note = reviewNoteFor(item); + if (note) { + nameCell.appendChild( + E( + "div", + { style: "margin-top:2px;font-size:0.84em;color:" + note.color + ";" }, + note.text, + ), + ); + // 引号里是审核员的原话。E() 走 textContent,不是 innerHTML —— + // 这段文本来自服务端,不该有第二种渲染方式。 + if (note.detail) { + nameCell.appendChild( + E( + "div", + { + style: + "margin-top:2px;font-size:0.84em;color:var(--text-muted);" + + "border-left:2px solid var(--hairline);padding-left:0.6em;", + }, + [document.createTextNode(note.detail)], + ), + ); + } + } + } + + return E("tr", { class: "tr" }, [ + nameCell, + E("td", { class: "td", style: "color:var(--text-muted);" }, [ + document.createTextNode(formatDownloads(item.downloads)), + ]), + E("td", { class: "td cbi-section-actions" }, [ + E("div", {}, takenDown ? [] : [updateBtn, " ", deleteBtn]), + ]), + ]); + }; + + let myShares = []; + + // hub_me 回来过没有,以及上一次是不是失败的。首屏之所以要分这两个状态: + // 没有它们,一台还在等 hub_me 的路由器画的是"还没有分享过" —— 一句在 + // 数据到达前就说出口的、可能是假的话。 + let myLoaded = false; + let myFailed = false; + + // Whoever this router publishes as. A null nickname means the creator + // account exists but has never been named. + let profile = { id: null, nickname: null }; + + let keySaved = loadData.keySaved; + + // A Blob and an object URL rather than a data: URI -- the key never + // becomes part of a URL that could be kept in history. + const downloadKey = (key) => { + const blob = new Blob([key + "\n"], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = E("a", { href: url, download: "aurora-creator-key.txt" }); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + // LuCI 只从 http://192.168.1.1 提供,那不是 secure context,所以 + // navigator.clipboard 在这个页面上恒为 undefined —— 真正会跑的是下面 + // execCommand 那条。现代调用留在前面,只为极少数套了 https 反代的装法。 + const copyText = (text) => { + if (navigator.clipboard && navigator.clipboard.writeText) + return navigator.clipboard.writeText(text); + + return new Promise((resolve, reject) => { + const scratch = E("textarea", { + style: "position:fixed;top:-1000px;left:-1000px;opacity:0;", + }); + scratch.value = text; + document.body.appendChild(scratch); + scratch.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + document.body.removeChild(scratch); + if (ok) resolve(); + else reject(); + }); + }; + + const KEY_MASK = "•".repeat(64); + + // 返回 promise:调用方(身份卡上那个按钮)拿它做 busy —— 取密钥要过一次 + // ubus,弹窗在那之后才出现,中间这段静默正是用户会再点一次的地方。 + const backupKeyPrompt = () => + L.resolveDefault(hubApi.callHubExportKey(), null).then((res) => { + if (!res || res.result !== 0 || !res.key) { + ui.addNotification( + null, + E("p", {}, _("Couldn't read your identity. Please try again.")), + "warning", + ); + return; + } + + // rpcd already recorded the flag when it handed the key over; this + // view is browse-only and has no uci write path of its own. + keySaved = true; + + // 默认遮住。这串东西等同于账号密码,不该因为"我只是想看看这个弹窗长 + // 什么样"就明晃晃地摊在旁边的人也看得见的屏幕上。 + const keyEl = E( + "code", + { + style: + "flex:1;min-width:0;word-break:break-all;font-size:0.85em;line-height:1.5;", + }, + [document.createTextNode(KEY_MASK)], + ); + let shown = false; + + const toggleBtn = E( + "button", + { type: "button", class: "cbi-button" }, + _("Show"), + ); + const reveal = () => { + shown = true; + keyEl.textContent = res.key; + toggleBtn.textContent = _("Hide"); + }; + toggleBtn.addEventListener("click", () => { + if (shown) { + shown = false; + keyEl.textContent = KEY_MASK; + toggleBtn.textContent = _("Show"); + } else { + reveal(); + } + }); + + const copyBtn = E( + "button", + { type: "button", class: "cbi-button" }, + _("Copy"), + ); + copyBtn.addEventListener("click", () => { + copyText(res.key).then( + () => { + copyBtn.textContent = _("Copied"); + window.setTimeout(() => { + copyBtn.textContent = _("Copy"); + }, 1500); + }, + () => { + // 复制不成不能是死路 —— 把身份亮出来,至少还能手动选中。 + reveal(); + ui.addNotification( + null, + E( + "p", + {}, + _("Couldn't copy. The identity is shown above — select it and copy it manually."), + ), + "warning", + ); + }, + ); + }); + + ui.showModal(_("Back up your creator identity"), [ + E( + "p", + {}, + _("This text is your identity. Anyone who has it can publish under your name and delete what you have shared — don't post it anywhere public."), + ), + E("div", { class: "aurora-store-keybox" }, [keyEl, toggleBtn]), + E( + "p", + { style: "color:var(--text-muted);font-size:0.85em;margin:0;" }, + _("It exists only on this router. Reflashing without keeping settings erases it."), + ), + E("div", { class: "right", style: "margin-top:1em;" }, [ + copyBtn, + " ", + E( + "button", + { + type: "button", + class: "btn cbi-button-action important", + click: () => { + downloadKey(res.key); + ui.hideModal(); + renderContent(); + }, + }, + _("Download backup file"), + ), + " ", + E( + "button", + { type: "button", class: "cbi-button", click: () => ui.hideModal() }, + _("Close"), + ), + ]), + ]); + }); + + // "我的分享" 顶上的常驻卡片:这台路由器以谁的名义发布,身份备没备份,以及 + // 身份的几个动作都在这里。 + // + // 它取代了原来那条 keybar。keybar 的毛病不在样式,在于 profile.id 为空时 + // 整条不渲染 —— 于是一台全新路由器看不到"备份"那一半。 + // + // 一张卡两个状态,而不是两张卡:没有身份时它照画,只是名字换成"还没有创作 + // 者身份",动作只剩「恢复…」。以前这里 return null,于是一台刚刷好的路由器 + // 在「我的」页上只有一句"还没有分享过" —— 而带着备份文件从旧路由器过来的 + // 人,恰恰要的就是这一屏上的恢复入口,却一个入口都没有。 + // + // 加载未回来之前仍然返回 null:那时 profile 是空的,画空态就是先撒一次谎 + // 再纠正。这一小段等待由下面 mySharesEl 里的 spinner 一并说了。 + // + // 改名也搬到了这里。它以前只挂在发布面板内部那个 "Publish as" 字段的 + // Change 按钮上 —— 不点开发布面板就找不到,等于"想改个名得先假装去发个布"。 + const buildIdentityCard = () => { + if (!myLoaded) return null; + + const nameRow = []; + const acts = []; + let meta; + // 首字母做头像。昵称可以是任意 Unicode,取第一个 code point 而不是第一个 + // UTF-16 码元 —— 否则一个 emoji 昵称会被切成半个代理对,画出一个方块。 + let initial = "#"; + + if (!profile.id) { + nameRow.push( + E("strong", { style: "color:var(--text-muted);" }, [ + document.createTextNode(_("No creator identity yet")), + ]), + ); + meta = _("Created the first time you publish — or restore one you backed up."); + // 唯一的动作,所以是实心的那个:这张卡此刻只剩一件事可做。 + acts.push( + E( + "button", + { + type: "button", + class: "btn cbi-button-action", + click: () => restoreIdentityPrompt(), + }, + _("Restore…"), + ), + ); + } else { + if (profile.nickname) { + nameRow.push( + E("strong", {}, [document.createTextNode(profile.nickname)]), + ); + initial = Array.from(profile.nickname)[0]; + } else { + nameRow.push( + E("strong", { style: "color:var(--text-muted);" }, [ + document.createTextNode(_("Not named yet")), + ]), + ); + } + nameRow.push( + E("span", { class: "hid" }, [document.createTextNode("#" + profile.id)]), + E( + "span", + { class: "aurora-store-pill " + (keySaved ? "ok" : "risk") }, + keySaved ? _("Backed up") : _("Not backed up"), + ), + ); + const counts = notices.compatCounts(meData); + const day = counts.sunset ? formatDay(counts.sunset) : ""; + meta = [_("Signs everything you share. It lives only on this router.")] + .concat( + !counts.deprecated + ? [] + : day + ? counts.deprecated === 1 + ? _("1 needs an update before %s").format(day) + : _("%d need an update before %s").format(counts.deprecated, day) + : counts.deprecated === 1 + ? _("1 needs an update") + : _("%d need an update").format(counts.deprecated), + !counts.unsupported + ? [] + : counts.unsupported === 1 + ? _("1 not listed for updated routers") + : _("%d not listed for updated routers").format(counts.unsupported), + ) + .join(" · "); + // 身份的三个动作全在这一处。备份是实心的那个(没备份时),改名和 + // 恢复是文字链 —— 它们不是这张卡要人做的事,只是要人找得到的事。 + // + // 「恢复」以前有两个入口:折叠说明里一条,空态引导卡里又一条,同屏 + // 可见,指向同一个对话框。空态卡整张删掉了,说明里那条也删了,只剩 + // 这一个。 + acts.push( + E( + "button", + { + type: "button", + class: keySaved ? "cbi-button" : "btn cbi-button-action", + click: (ev) => withBusy(ev.currentTarget, backupKeyPrompt()), + }, + _("Back up identity"), + ), + " ", + E( + "button", + { type: "button", class: "cbi-button", click: () => promptRename() }, + _("Rename"), + ), + " ", + E( + "button", + { type: "button", class: "lnk", click: () => restoreIdentityPrompt() }, + _("Restore…"), + ), + ); + } + + return E("div", {}, [ + E("div", { class: "aurora-store-idcard" }, [ + E("div", { class: "av" }, [document.createTextNode(initial)]), + E("div", { class: "who" }, [ + E("div", { class: "nm" }, nameRow), + E("div", { class: "meta" }, [document.createTextNode(meta)]), + ]), + E("div", { class: "acts" }, acts), + ]), + E("details", { class: "aurora-store-why" }, [ + E("summary", {}, _("What is this? Where did it come from?")), + E("p", { style: "margin:0;" }, [ + document.createTextNode( + _("Created automatically the first time you publish — no sign-up, no password. It decides which configurations in the store are yours, and who can change or remove them. Backing it up saves an identity backup file; import that file after a reflash, or on a new router, and your work comes back to you."), + ), + ]), + ]), + ]); + }; + + const KEY_RE = /^[a-f0-9]{64}$/; + + const restoreIdentityPrompt = () => { + // The share panel's fields get their width from .aurora-store-field; this + // one lives in a modal, where a default-sized textarea leaves a 64-hex + // key wrapping inside a box narrower than the dialog holding it. + const input = E("textarea", { + class: "cbi-input-textarea", + rows: 2, + style: "width:100%;box-sizing:border-box;", + }); + const err = E("p", { + style: "color:var(--danger);font-weight:600;display:none;margin:0.6em 0 0;", + }); + + // One parse path: the file button only fills the box. The input itself is + // hidden behind a label -- the browser's own "no file selected" chrome + // says nothing a user of this dialog needs. + const picker = E("input", { type: "file", accept: ".txt,text/plain" }); + picker.style.display = "none"; + picker.addEventListener("change", () => { + const file = picker.files && picker.files[0]; + if (!file) return; + file.text().then((text) => { + input.value = text.trim(); + }); + }); + const fileBtn = E( + "label", + { class: "cbi-button aurora-store-filebtn" }, + [document.createTextNode(_("Choose backup file…")), picker], + ); + + // 返回 promise:导入加上随后那次 hub_me 是两趟往返,确认键要 busy 到 + // 两趟都回来为止 —— 见 buildConfirmActions。 + const commit = () => { + const key = input.value.trim().toLowerCase(); + if (!KEY_RE.test(key)) { + err.textContent = _("That doesn't look like a creator identity."); + err.style.display = "block"; + return; + } + return L.resolveDefault(hubApi.callHubImportKey(key), null).then((res) => { + if (!res || res.result !== 0) { + err.textContent = _("Couldn't restore that identity. Check it and try again."); + err.style.display = "block"; + return; + } + ui.hideModal(); + // 换账号了 —— 缓存里那份档案属于上一个 key。先清掉再刷新,否则 + // "导入成功、随后 hub_me 失败"会把上一个账号的作品留在界面上, + // 而 Task 2 之后失败路径不再自行清空。 + hubApi.meCache.clear(); + myLoaded = false; + applyMe(null); + return refreshMyShares().then(() => { + keySaved = true; + ui.addNotification( + null, + E( + "p", + {}, + myShares.length + ? _("Restored. What you shared before is back under this identity.") + : _("Restored."), + ), + "info", + ); + renderContent(); + }); + }); + }; + + const body = [ + E( + "p", + {}, + _("Paste the identity you backed up, or pick the backup file you downloaded. What you shared before comes back under this router."), + ), + input, + E("div", { style: "margin-top:0.6em;" }, [fileBtn]), + err, + ]; + + // Replacing an identity that still owns published work is unrecoverable, + // so it gets said out loud before the button that does it. + // + // 这里曾经还挂着一个「先备份当前身份」按钮。备份入口在身份卡上就有一个, + // 这个框是恢复框 —— 一个对话框只做一件事,警告负责讲后果,不负责再开一 + // 条通往别处的岔路。 + if (myShares.length) { + body.splice( + 1, + 0, + E( + "p", + { style: "color:var(--danger);" }, + _("This router already has shared configurations of its own. Switching to another identity gives them up for good — back up the current identity first if you still want them."), + ), + ); + } + + body.push(buildConfirmActions(commit, _("Restore"))); + ui.showModal(_("Restore your creator identity"), body); + }; + + // The tab keeps the count of what is already published, so the number is + // readable without opening the tab first. + const renderMyShares = (items) => { + // 这里曾经有一句 filter(status !== "removed")。当时商店的软删除让作者 + // 自己删掉的作品继续出现在 /api/v1/me 里,只是 status 变成 removed —— + // 删成功了、刷新一遍那一行还在,用户再点一次就撞 404。滤掉是对的,代价 + // 是被管理员下架的作品也一起被藏了,作者永远不知道自己被下架过。 + // + // 现在 hub 那边分得清了(migration 0007 的 removed_by):作者自己删的根本 + // 不再下发,而 status === "removed" 只剩一个意思 —— 被下架。所以这里不再 + // 滤,那一行改由 buildMyShareRow 画成「已下架」。 + myShares = (items || []).filter((item) => item); + TABS.forEach(renderTabLabel); + while (mySharesEl.firstChild) mySharesEl.removeChild(mySharesEl.firstChild); + + // 三个"没有表格"的理由,措辞各不相同,因为它们是三件事:问不到、还不 + // 知道、确实没有。文案和转圈都跟社区网格用同一套 —— 同一页上同一种等待 + // 不该长出第二种样子。 + if (myFailed && !myShares.length) { + mySharesEl.appendChild( + E("div", { class: "aurora-store-none" }, [ + E("p", { style: "margin:0 0 0.6em;" }, _("Unable to reach the theme store right now.")), + E( + "button", + { + type: "button", + class: "cbi-button cbi-button-apply", + click: (ev) => withBusy(ev.currentTarget, refreshMyShares()), + }, + _("Retry"), + ), + ]), + ); + return; + } + if (!myLoaded) { + mySharesEl.appendChild( + E("p", { class: "spinning", style: "padding:1.2em 0.2em;" }, _("Loading…")), + ); + return; + } + + if (!myShares.length) { + // 曾经是一张虚线卡:标题、一段说明、一个"去工作台"按钮,外加第二条 + // "恢复身份"链接。三样东西现在都没有理由存在了 —— 发布入口就在这一 + // 屏上方那条横幅上,身份的恢复入口在身份卡里。剩下要说的只有一句: + // 这里以后会长出什么。 + mySharesEl.appendChild( + E( + "p", + { class: "aurora-store-none" }, + _("Nothing shared yet. Publish the configuration above and it shows up here."), + ), + ); + return; + } + mySharesEl.appendChild( + E("table", { class: "table" }, [ + E("tr", { class: "tr table-titles" }, [ + E("th", { class: "th" }, _("Name")), + E("th", { class: "th" }, _("Downloads")), + E("th", { class: "th center" }, ""), + ]), + ...myShares.map((item) => buildMyShareRow(item)), + ]), + ); + // 这里曾经跟着一句"想再发一套?去工作台发"。它存在的理由是这一页当时 + // 没有发布入口 —— 现在这一屏上方就是那条横幅,再指一次路只是多一行字。 + }; + + // ------------------------------------------------------------------ + // Publishing: an inline panel inside "My Shares", not a modal, so the + // manifest of what actually goes out has room to sit next to the fields. + + // Everything the manifest needs is already in uci -- no extra rpc call. + const shareManifestRows = () => { + const rows = [ + { label: _("Colors"), detail: _("Light and dark, 62 values") }, + { + label: _("Navigation"), + detail: NAV_TYPE_LABELS[currentNav] || currentNav, + }, + ]; + + // build_share_payload sends spacing and content width alongside the + // navigation shape, and the detail drawer lists both -- leaving them out + // here would under-promise right above "Anyone who applies this gets all + // of it." + const spacing = uci.get("aurora", "theme", "struct_spacing"); + if (spacing) rows.push({ label: _("Spacing"), detail: spacing }); + + const radius = radiusLabel(uci.get("aurora", "theme", "struct_radius_base")); + if (radius) rows.push({ label: _("Shape"), detail: radius }); + + const contentWidth = uci.get( + "aurora", + "theme", + "struct_content_width_centered", + ); + if (contentWidth) + rows.push({ label: _("Content Width"), detail: contentWidth }); + + const family = firstFontFamily(uci.get("aurora", "theme", "struct_font_sans")); + const mono = firstFontFamily(uci.get("aurora", "theme", "struct_font_mono")); + if (family) + rows.push({ + label: _("Fonts"), + detail: mono ? family + " · " + mono : family, + }); + + // 上传的字体文件本身也要单独列一行。上面那行只是字体的名字 —— 而这 + // 一行说的是"这份 woff2 会被传上去",带着体积,因为它决定了别人要不要 + // 在自己的路由器上腾出这么多空间。 + sharedFonts.forEach((font) => + rows.push({ + label: + font.slot === "mono" ? _("Mono Font File") : _("Sans Font File"), + detail: _("Uploaded with the theme, %s").format( + assetUpload.formatSize(font.size), + ), + }), + ); + + // 图片这几行以前只写 "Included"。而对着这张清单要做的决定是「把这套 + // 主题发出去」,对着商店详情页要做的决定是「把它装到我的路由器上」—— + // 两个决定都要看体积,一张 1.8MB 的登录背景和一个 3KB 的 logo 不是一 + // 回事。体积来自 rpcd 的 shared_images:问的是 build_share_payload 打包 + // 时问的同一个函数,所以屏幕上说要发什么,线路上发的就是什么。 + // + // 两个槽位共用 "Site Icon" 标签、两个共用 "App Icon",所以按标签合并 —— + // 同一个名字出现两次是错的。合并后的体积是这几个槽位的和。 + const IMAGE_ROW_LABELS = { + logo_svg: ASSET_LABELS.logo, + favicon_png: ASSET_LABELS.siteIcon, + favicon_ico: ASSET_LABELS.siteIcon, + pwa_icon_192: ASSET_LABELS.appIcon, + pwa_icon_512: ASSET_LABELS.appIcon, + login_bg: ASSET_LABELS.loginBg, + main_bg: ASSET_LABELS.mainBg, + }; + const imageRows = []; + sharedImages.forEach((image) => { + const label = IMAGE_ROW_LABELS[image && image.kind]; + if (!label) return; + const size = Number(image.size) || 0; + const existing = imageRows.filter((row) => row.label === label)[0]; + // 超限的不与正常的合并:一行同时说"共 2.1 MB"和"太大发不出去"就没法读 + // 了,而"哪一张太大"正是用户此刻唯一需要知道的事。 + const oversized = image.oversized === true; + if (existing && existing.oversized === oversized) { + existing.size += size; + return; + } + imageRows.push({ label: label, size: size, oversized: oversized }); + }); + imageRows.forEach((row) => + rows.push({ + label: row.label, + detail: row.oversized + ? _("Too large to share (%s; the store's limit is %s)").format( + assetUpload.formatSize(row.size), + assetUpload.formatSize(MAX_SHARED_IMAGE), + ) + : _("Included, %s").format(assetUpload.formatSize(row.size)), + }), + ); + + // Anonymous toolbar_item sections have no stable index, so the array is + // filtered and counted rather than iterated by position. + const shortcuts = Math.min( + (uci.sections("aurora", "toolbar_item") || []).filter(isSharedToolbarItem) + .length, + MAX_SHARED_TOOLBAR_ITEMS, + ); + if (shortcuts) + rows.push({ + label: _("Toolbar"), + detail: + shortcuts === 1 + ? _("1 shortcut") + : _("%d shortcuts").format(shortcuts), + }); + + // 快捷方式的图标以前只是个文件名跟着走,而那个文件只存在于这台路由器 + // 上 —— 别人应用完,工具栏上就是一排空框。现在字节也跟着走,所以这里 + // 要把它当成一份真的资产来交代:发几个、多大。 + // + // 发不出去的那些单独说。图标只收 PNG 和 SVG(审核台只有这两条清洗 + // 路径),超过 256 KiB 的也不发 —— 12 个槽位按图片的上限放行,光图标 + // 就能把审核请求撑爆。这两种情况都不是错误,但沉默地少发一个图标, + // 用户只会在别人的截图里发现它没了。 + const shippedIcons = sharedToolbarIcons.filter((icon) => icon && icon.shipped); + const droppedIcons = sharedToolbarIcons.filter((icon) => icon && !icon.shipped); + if (shippedIcons.length) + rows.push({ + label: _("Shortcut Icons"), + detail: _("%d included, %s").format( + shippedIcons.length, + assetUpload.formatSize( + shippedIcons.reduce((sum, icon) => sum + (Number(icon.size) || 0), 0), + ), + ), + }); + if (droppedIcons.length) + rows.push({ + label: _("Shortcut Icons"), + detail: _("%d not shared — only PNG and SVG under %s travel with a theme").format( + droppedIcons.length, + assetUpload.formatSize(MAX_SHARED_TOOLBAR_ICON), + ), + }); + + return rows; + }; + + // Shared by the publish panel and the update confirmation: an update runs + // the very same payload build, so it has to say the same thing about what + // leaves the router. + const buildManifestTable = () => + E("table", { class: "table" }, [ + E("tr", { class: "tr table-titles" }, [ + E("th", { class: "th" }, _("Content")), + E("th", { class: "th" }, _("Details")), + ]), + // Same call as buildMyShareRow: no data-title. Every row here is + // already a label facing its own value ("Colors" -> "Light and dark, + // 62 values"), so reprinting "Content"/"Details" above each of the + // eleven rows on a phone is eleven stutters and no information. + ...shareManifestRows().map((row) => + E("tr", { class: "tr" }, [ + E("td", { class: "td" }, [document.createTextNode(row.label)]), + E("td", { class: "td", style: "color:var(--text-muted);" }, [ + document.createTextNode(row.detail), + ]), + ]), + ), + ]); + + // 意图优先,状态兜底。用户上一秒刚在工作台点了"分享到商店",这句话是他 + // 亲口说的;跳过来给他一张列表,等于把它丢掉,还得让他自己再找一次入口。 + // "有没有作品"是历史状态,不该盖掉"他现在要干什么" —— 所以带意图进来 + // 永远是表单,不判断。没有意图才看状态:没作品给路标,有作品给列表。 + // + // 用 URL 参数而不是 sessionStorage:刷新、回退、收藏行为都可预测。 + const shareIntent = + new URLSearchParams(window.location.search).get("share") === "1"; + let shareOpen = shareIntent; + + // 空串 = 发布为新的一条;否则是要被这套配置覆盖掉的那条分享的 id。 + // + // 带意图进来又已经有作品时,只给一张空表单会让人发出第二条几乎一样的 + // 东西 —— 他八成想更新已经发过的那条。而"更新"的语义本来就是"用当前 + // 配置替换商店里的它",起点同样在工作台,所以它和发布共用同一张表单, + // 只差一个目标。 + let shareTarget = ""; + + // The panel is rebuilt on every renderContent() -- a keystroke in the + // search box, or a list fetch landing late -- so the nodes that hold user + // state (the three fields, the error line, the button's disabled flag) are + // created ONCE here and re-parented by buildSharePanel. Constructing them + // inside the builder silently threw away a half-typed name and description, + // and handed a mid-flight publish a second, freshly enabled button. + const nameInput = E("input", { + type: "text", + class: "cbi-input-text", + maxlength: 60, + placeholder: _("Give it a name"), + }); + const descInput = E("textarea", { + class: "cbi-input-textarea", + rows: 3, + maxlength: 500, + placeholder: _("One line about where this configuration fits."), + }); + // Only ever shown while the creator account has no name yet. Not seeded + // from anywhere local: the hub owns the name, and a cached copy would go + // stale the moment it is changed from another browser. + const nicknameInput = E("input", { + type: "text", + class: "cbi-input-text", + maxlength: 40, + placeholder: _("The name your shares are signed with"), + }); + + const errEl = E("p", { + style: "color:var(--danger);font-weight:600;display:none;margin:0 0 0.6em;", + }); + + // 与 errEl 同理:创建一次,由 buildSharePanel 重新挂载。发布过程中面板会 + // 被 renderContent 重建,把节点建在构造函数里会让进度在第一次重绘时归零。 + const progressEl = E("p", { + style: "color:var(--text-muted);font-size:0.85em;display:none;margin:0 0 0.6em;", + }); + + const showError = (message) => { + errEl.textContent = message; + errEl.style.display = "block"; + progressEl.style.display = "none"; + }; + + // 复用 ASSET_LABELS 的措辞,免得同一张图在清单里叫"登录背景"、在进度条上 + // 叫另一个名字。 + const ASSET_PROGRESS_LABELS = { + logo_svg: ASSET_LABELS.logo, + favicon_png: ASSET_LABELS.siteIcon, + favicon_ico: ASSET_LABELS.siteIcon, + pwa_icon_192: ASSET_LABELS.appIcon, + pwa_icon_512: ASSET_LABELS.appIcon, + login_bg: ASSET_LABELS.loginBg, + }; + // 图标的 kind 带着槽位号(toolbar_icon_3),那是个内部编号,不是一个地方。 + // 让它掉进下面那个 `|| info.kind` 的兜底,进度条上就会写着 "正在上传 + // toolbar_icon_3…" —— 这个文件开头那条"机制词永不出现"的规矩说的正是它。 + TOOLBAR_ICON_KINDS.forEach((kind) => { + ASSET_PROGRESS_LABELS[kind] = _("Shortcut Icons"); + }); + + // 一张 1.2MB 的图在慢上行的线路上要传十几秒,而那段时间里页面上唯一会动 + // 的东西就是这一行。没有它,等待读起来就是卡死。 + const renderShareProgress = (info) => { + progressEl.style.display = "block"; + if (info.phase === "begin") { + progressEl.textContent = _("Preparing…"); + return; + } + if (info.phase === "commit") { + progressEl.textContent = _("Finishing…"); + return; + } + const label = ASSET_PROGRESS_LABELS[info.kind] || info.kind; + const pct = info.total ? Math.round((info.loaded / info.total) * 100) : 0; + progressEl.textContent = + info.count > 1 + ? _("Uploading %s… %d%% (%d/%d)").format(label, pct, info.index, info.count) + : _("Uploading %s… %d%%").format(label, pct); + }; + + const submitBtn = E( + "button", + { type: "button", class: "btn cbi-button-action important" }, + _("Publish"), + ); + + const doSubmit = () => { + const name = nameInput.value.trim(); + if (!name) { + showError(_("Please enter a name.")); + return; + } + + const description = descInput.value.trim(); + const wantedNickname = profile.nickname ? "" : nicknameInput.value.trim(); + if (!profile.nickname && !wantedNickname) { + showError(_("Choose the name to publish under.")); + return; + } + + errEl.style.display = "none"; + submitBtn.disabled = true; + submitBtn.classList.add("spinning"); + // 进度行原先要等第一个资源开始上传才出现,而在那之前还有一次 claim 昵称 + // 的往返 —— 那几秒里按钮已经按不动了,却没有一个字解释为什么。 + renderShareProgress({ phase: "begin" }); + + // 三条收尾路径(昵称被拒、发布失败、发布成功刷新完)共用同一个还原。 + const endSubmit = () => { + submitBtn.disabled = false; + submitBtn.classList.remove("spinning"); + }; + + // Two calls on purpose. The signature is account state, so it is + // claimed first and the publish itself carries no author at all. If the + // name turns out to be taken, nothing has been published yet and the + // user simply picks another. + const claim = wantedNickname + ? L.resolveDefault(hubApi.callHubSetNickname(wantedNickname), null).then((res) => { + const data = (res && res.result === 0 && res.data) || null; + if (data && !data.error) { + profile = { id: data.id, nickname: data.nickname }; + return true; + } + showError( + data ? nicknameErrorMessage(data.error) : shareErrorMessage(res && res.error), + ); + return false; + }) + : Promise.resolve(true); + + claim.then((claimed) => { + if (!claimed) { + endSubmit(); + return; + } + hubApi + .publishCurrentConfig({ + name: name, + description: description, + targetId: shareTarget, + onProgress: renderShareProgress, + }) + .then((res) => { + if (res && res.result === 0) { + // 两句话,两个维度:第一句讲这次发布的结果,第二句讲身份该备份。 + // 它们互不嵌套 —— 合成一条 msgid 的话,任一边多一个分支就要把 + // 另一边的每种说法都重写一遍。 + // + // 身份那句仍然只在没备份时出现:这是它唯一值得一读的时刻,用户 + // 刚刚获得了一件丢得掉的东西。 + const notice = [ + res.assets + ? _("Published. Its assets are queued for review — it appears in the store once they are approved.") + : _("Published."), + ]; + if (!keySaved) + notice.push( + _("Your creator identity lives only on this router — back it up so a reflash can't take it."), + ); + ui.addNotification( + null, + notice.map((line) => E("p", {}, line)), + "info", + ); + shareOpen = false; + shareTarget = ""; + // 意图参数留在地址栏里的话,用户刷新一下又撞回一张空表单 —— 而他 + // 刚刚才发布完,这一页现在该给他看的是那件作品。 + if (shareIntent && window.history && window.history.replaceState) { + window.history.replaceState({}, "", window.location.pathname); + } + // The button stays disabled until the panel is off screen -- + // re-enabling it here would leave a live Publish button up for the + // whole my-shares round trip, and a second click would post the + // same configuration twice. The draft is cleared at the same time + // so reopening the panel starts fresh rather than re-offering what + // was just published. + refreshMyShares().then(() => { + nameInput.value = ""; + descInput.value = ""; + nicknameInput.value = ""; + endSubmit(); + renderContent(); + }); + } else { + endSubmit(); + showError(shareErrorMessage(res && res.error)); + } + }); + }); + }; + + // Identity is asked for once. After that it is shown, not re-typed: + // re-offering a filled-in box on every publish suggests the signature is + // per-config, which is exactly what this stopped being. + // + // Reached from the identity card only -- see buildIdentityCard. + const promptRename = () => { + const input = E("input", { + type: "text", + class: "cbi-input-text", + maxlength: 40, + value: profile.nickname || "", + }); + const err = E("p", { + style: "color:var(--danger);font-weight:600;display:none;margin:0.6em 0 0;", + }); + + ui.showModal(_("Change your name"), [ + E( + "p", + {}, + _("This name signs everything you have published, including what is already in the store."), + ), + input, + err, + buildConfirmActions(() => { + const next = input.value.trim(); + if (!next) { + err.textContent = _("Pick a name between 1 and 40 characters."); + err.style.display = "block"; + return; + } + // 返回给 buildConfirmActions 做 busy —— 改名要过 hub,不是本地操作。 + return L.resolveDefault(hubApi.callHubSetNickname(next), null).then((res) => { + const data = (res && res.result === 0 && res.data) || null; + if (data && !data.error) { + ui.hideModal(); + return refreshMyShares().then(renderContent); + } + err.textContent = data + ? nicknameErrorMessage(data.error) + : shareErrorMessage(res && res.error); + err.style.display = "block"; + }); + }, _("Save")), + ]); + }; + + submitBtn.addEventListener("click", doSubmit); + + // Before the account has a name: an input. After: the signature it will + // carry -- read-only. The short id is shown here (and in the detail + // drawer) rather than on cards -- names are unique, so a number on every + // card would be noise, but it is what lets someone prove which "Eamon" + // they are. + // + // Renaming used to hang off a Change button right here, which made this + // panel the only route to it. It lives on the identity card now, so this + // field states the signature and nothing else. + const buildIdentityField = (field) => { + if (!profile.nickname) return field(_("Publish as"), nicknameInput); + + return E("div", { class: "aurora-store-field" }, [ + E("label", {}, _("Publish as")), + E("div", {}, [ + E("strong", {}, [document.createTextNode(profile.nickname)]), + E("span", { style: "color:var(--text-muted);margin-left:0.5em;" }, [ + document.createTextNode("#" + (profile.id || "")), + ]), + ]), + ]); + }; + + // 一件作品都没有时不渲染:零选项的单选组是噪音,而"你还没有作品"这句话 + // 表单标题已经说了。 + const buildTargetPicker = () => { + if (!myShares.length) return null; + + const row = (value, label, meta) => { + const input = E("input", { + type: "radio", + name: "aurora-share-target", + value: value, + }); + input.checked = shareTarget === value; + input.addEventListener("change", () => { + shareTarget = value; + const target = myShares.filter((item) => item.id === value)[0]; + if (target) { + // 选了更新就把那条的现值填回去 —— 否则用户得凭记忆重打一遍 + // 名字,打错一个字就变成"改名"而不是"更新"。 + nameInput.value = target.name || ""; + descInput.value = target.description || ""; + } else { + nameInput.value = ""; + descInput.value = ""; + } + renderContent(); + }); + return E("label", { class: "aurora-store-target-row" }, [ + input, + E("span", { class: "nm" }, [document.createTextNode(label)]), + E("span", { class: "mt" }, [document.createTextNode(meta)]), + ]); + }; + + const rows = [ + row("", _("Publish as a new one"), _("One more piece in the store")), + ]; + myShares.forEach((item) => { + rows.push( + row( + item.id, + // 名字来自 hub,是不可信自由文本 —— 走 createTextNode(见 row)。 + _("Replace “%s”").format(item.name || ""), + item.downloads === 1 + ? _("1 download") + : _("%d downloads").format(item.downloads || 0), + ), + ); + }); + + return E("div", { class: "aurora-store-target" }, [ + E("div", { class: "hd" }, _("Where this configuration goes")), + ...rows, + ]); + }; + + const buildSharePanel = () => { + const field = (label, control) => + E("div", { class: "aurora-store-field" }, [ + E("label", {}, label), + control, + ]); + + const manifestTable = buildManifestTable(); + + // 同一颗按钮,两种承诺。文案必须跟着目标走:"发布"和"覆盖掉商店里那条" + // 是两件事,用同一个词会让人误以为只是多发一条,结果盖掉了自己的作品。 + const targetItem = myShares.filter((item) => item.id === shareTarget)[0]; + while (submitBtn.firstChild) submitBtn.removeChild(submitBtn.firstChild); + submitBtn.appendChild( + document.createTextNode( + targetItem ? _("Replace “%s”").format(targetItem.name || "") : _("Publish"), + ), + ); + + // 攒数组再返回:一件作品都没有时 buildTargetPicker 返回 null,而 E() 的 + // 子节点数组里混进 null 会抛错。 + const children = []; + const picker = buildTargetPicker(); + + children.push( + E("div", { class: "aurora-store-share-head" }, [ + // Not "Publish current configuration" any more: this is no longer an + // entry point that has to name itself, it is the form you already + // opened. So the title spends its words on what happens next. + E("div", {}, [ + E("h4", { style: "margin:0;" }, _("Share to the store")), + E( + "p", + { style: "margin:2px 0 0;color:var(--text-muted);font-size:0.84em;" }, + _("Once published, anyone can find it and apply it in one click. You can update or remove it later."), + ), + ]), + E( + "button", + { + type: "button", + class: "cbi-button", + click: () => { + shareOpen = false; + renderContent(); + }, + }, + _("Cancel"), + ), + ]), + ); + + if (picker) children.push(picker); + + children.push( + E("div", { class: "aurora-store-share-cols" }, [ + E("div", { class: "aurora-store-share-prev" }, [ + E("div", { class: "aurora-store-prev" }, [ + buildDuo( + { + light: { + bg: uci.get("aurora", "theme", "light_bg"), + surface: uci.get("aurora", "theme", "light_surface"), + text: uci.get("aurora", "theme", "light_text"), + brand: uci.get("aurora", "theme", "light_brand"), + }, + dark: { + bg: uci.get("aurora", "theme", "dark_bg"), + surface: uci.get("aurora", "theme", "dark_surface"), + text: uci.get("aurora", "theme", "dark_text"), + brand: uci.get("aurora", "theme", "dark_brand"), + }, + }, + { nav: currentNav }, + ), + ]), + E( + "p", + { style: "font-size:0.75em;color:var(--text-muted);text-align:center;margin:6px 0 0;" }, + _("How it looks in the store"), + ), + ]), + E("div", {}, [ + field(_("Name"), nameInput), + field(_("Description"), descInput), + buildIdentityField(field), + progressEl, + errEl, + ]), + ]), + ); + + children.push( + E("h4", { style: "margin:1.2em 0 0.4em;" }, _("What gets shared")), + manifestTable, + E( + "p", + { style: "color:var(--text-muted);font-size:0.8em;margin-top:0.8em;" }, + _("Anyone who applies this gets all of it. You can update or remove it later."), + ), + ); + + // 只在真的要发字体文件时才出现。字体和图标不是一回事:一个自己画的 + // logo 是自己的,而一份字体绝大多数情况下是别人的作品,多数商业授权 + // 明确不允许再分发。这句话必须在按下发布之前就在眼前,不能藏进条款。 + if (sharedFonts.length) + children.push( + E( + "p", + { class: "aurora-store-license-note" }, + _("This share includes your uploaded font file. Please make sure you have the right to redistribute it — most commercial font licences do not allow it. Configurations reported for infringement are taken down."), + ), + ); + + children.push( + E("div", { class: "right", style: "margin-top:1em;" }, [submitBtn]), + ); + + return E("div", { class: "aurora-store-share" }, children); + }; + + // ------------------------------------------------------------------ + // Header: search + tabs + share + + // count 是惰性的:builtinItems 在渲染前就定好了,myShares 要等 hub_my_shares + // 回来。两个都读的是渲染那一刻的值,而不是定义这张表时的值。 + const INBOX_TAB = { + key: "inbox", + label: _("Inbox"), + badge: () => notices.unreadCount(inboxItems()), + }; + + const TABS = [ + { key: "all", label: _("All") }, + { key: "builtin", label: _("Built-in"), count: () => builtinItems.length }, + { key: "hot", label: _("Hot") }, + { key: "new", label: _("New") }, + // 「我的分享」变成「我的」:这一页现在还收着「我的配置」那张卡,而它 + // 恰恰是唯一一份从没发布出去的东西。 + { + key: "mine", + label: _("Mine"), + count: () => myShares.length + (buildMineModel() ? 1 : 0), + }, + INBOX_TAB, + ]; + + const renderTabLabel = (tab) => { + const btn = tabButtons[tab.key]; + if (!btn) return; + while (btn.firstChild) btn.removeChild(btn.firstChild); + btn.appendChild(document.createTextNode(tab.label)); + const n = tab.count ? tab.count() : 0; + if (n) btn.appendChild(E("span", { class: "n" }, [document.createTextNode(String(n))])); + const unread = tab.badge ? tab.badge() : 0; + if (unread) + btn.appendChild( + E("span", { class: "aurora-store-count" }, [document.createTextNode(String(unread))]), + ); + }; + + const tabButtons = {}; + const tabsEl = E( + "div", + { class: "aurora-store-filters" }, + TABS.map((tab) => { + const btn = E("button", { type: "button", click: () => selectTab(tab.key) }); + tabButtons[tab.key] = btn; + return btn; + }), + ); + TABS.forEach(renderTabLabel); + + const searchInput = E("input", { + type: "text", + class: "cbi-input-text", + placeholder: _("Search themes or authors…"), + }); + searchInput.addEventListener("input", () => { + state.query = searchInput.value.trim().toLowerCase(); + renderContent(); + }); + + // Tabs and search share one row. They were stacked, and because nothing + // sat to the left of the search box its row was a band of empty pixels + // running the full width of the page -- the spacer was the only thing in + // it. Now the spacer earns its keep: it is what holds the search box + // against the right edge with the tab strip on the left. + // + // No heading here: the tab strip directly above already reads "Theme + // Marketplace", and this printed the same words again a hand's width + // below it. The publish button that used to sit next to the search box is + // gone with the other two: this page no longer starts a publish at all. + const headEl = E("div", { class: "aurora-store-head" }, [ + tabsEl, + E("span", { class: "sp" }), + searchInput, + ]); + + // ------------------------------------------------------------------ + // Content sections + + const matchesQuery = (name, author) => { + if (!state.query) return true; + return ( + (name || "").toLowerCase().includes(state.query) || + (author || "").toLowerCase().includes(state.query) + ); + }; + + const buildBuiltinGrid = () => { + const items = builtinItems.filter((preset) => + matchesQuery(preset.label, "Aurora"), + ); + if (!items.length) return null; + return E( + "div", + { class: "aurora-store-grid" }, + items.map((preset) => buildBuiltinCard(preset)), + ); + }; + + const buildOnlineGrid = (sort) => { + const grid = E("div", { class: "aurora-store-grid", id: "hub-cards" }); + const items = state.online[sort]; + + if (state.onlineFailed && !items) { + grid.appendChild( + E( + "div", + { + style: + "grid-column:1/-1;text-align:center;color:var(--text-muted);padding:2em 1em;", + }, + [ + E("p", {}, _("Unable to reach the theme store right now.")), + E( + "button", + { + type: "button", + class: "cbi-button cbi-button-apply", + click: () => fetchSort(sort), + }, + _("Retry"), + ), + ], + ), + ); + return grid; + } + + if (!items) { + grid.appendChild( + E( + "p", + { + class: "spinning", + style: "grid-column:1/-1;padding:1.5em 0;", + }, + _("Loading…"), + ), + ); + fetchSort(sort); + return grid; + } + + const visible = items + .filter((item) => !isBuiltinSeed(item)) + .filter((item) => matchesQuery(item.name, item.author)); + + if (!visible.length) { + grid.appendChild( + E( + "div", + { + style: + "grid-column:1/-1;text-align:center;color:var(--text-muted);padding:2em 1em;", + }, + state.query + ? _("No themes match your search.") + : _("No configs here yet — go share the first one."), + ), + ); + return grid; + } + + visible.forEach((item) => grid.appendChild(buildOnlineCard(item))); + return grid; + }; + + const buildStaleNotice = () => + state.showingStale + ? E( + "em", + { + style: + "display:block;color:var(--text-muted);font-size:0.85em;margin-top:0.5em;", + }, + _("Showing a saved copy — reconnecting…"), + ) + : null; + + const renderContent = () => { + while (contentEl.firstChild) contentEl.removeChild(contentEl.firstChild); + const push = (node) => node && contentEl.appendChild(node); + + // 排在最前面:正在用的那张卡应该第一眼看见。既没改过、又没有可回去的 + // 配置时整段不出现 —— 那时用户还没有属于自己的东西。 + // + // 「全部」页上它是一张卡:那一页是拿来逛的,内置和社区也是网格,它跟着 + // 一样才读得顺。「我的」页在下面单独处理 —— 那里它是主角,画成横幅。 + if (state.tab === "all") { + const mineGrid = buildMineGrid(); + if (mineGrid) { + push(buildSectionTitle(_("Mine"), _("On this router only"))); + push(mineGrid); + } + } + + if (state.tab === "all" || state.tab === "builtin") { + const builtinGrid = buildBuiltinGrid(); + if (builtinGrid) { + push(buildSectionTitle(_("Built-in"), _("Ships with the theme, works offline"))); + push(builtinGrid); + } + } + + if (state.tab === "all") { + push(buildSectionTitle(_("Community"), _("Shared by other people"))); + push(buildStaleNotice()); + push(buildOnlineGrid("hot")); + } else if (state.tab === "hot" || state.tab === "new") { + push(buildStaleNotice()); + push(buildOnlineGrid(state.tab)); + } else if (state.tab === "mine") { + // 发布态独占这一页。原先是顺着往下堆的:塞一张面板,然后照样再画一次 + // 区块标题和空态引导卡 —— 于是面板已经开着的时候,下面还在劝你"把这 + // 台路由器的整套外观发布到商店"。目标选择器已经把"你有哪些作品"说清 + // 楚了,底下再列一遍就是第二遍。 + if (shareOpen) { + push(buildSharePanel()); + return; + } + // 一屏两个标题,不是三个。身份曾经占着和作品同一级的一个 section —— + // 它是设施,不是目的,现在并进「我的分享」:这台路由器以谁的名义发布, + // 说的本来就是下面那张表里的东西归谁。 + const banner = buildMineBanner(); + if (banner) { + push(buildSectionTitle(_("Mine"), _("On this router only"))); + push(banner); + } + push(buildSectionTitle(_("My Shares"), "")); + push(buildIdentityCard()); + push(mySharesEl); + } else if (state.tab === "inbox") { + push(buildInbox()); + } + }; + + const selectTab = (key) => { + state.tab = key; + TABS.forEach((tab) => + tabButtons[tab.key].classList.toggle("active", tab.key === key), + ); + searchInput.style.display = key === "inbox" ? "none" : ""; + // 顶栏指示器靠这个 hash 把人送到 Inbox;离开 Inbox 就摘掉它,否则再点一次 + // 指示器时地址没变,hashchange 不会来。 + const hash = key === "inbox" ? inboxIndicator.INBOX_HASH : ""; + if (window.location.hash !== hash && window.history && window.history.replaceState) + window.history.replaceState( + window.history.state, + "", + window.location.pathname + window.location.search + hash, + ); + renderContent(); + }; + + // ------------------------------------------------------------------ + // Online data lifecycle: instant paint from cache, reconcile with the + // network, degrade to the built-in group plus a retry notice on failure. + + const applyResult = (res, sort) => { + if (res && res.result === 0 && res.data) { + if (sort === "hot") hubApi.listCache.set(res.data); + state.online[sort] = Array.isArray(res.data.items) ? res.data.items : []; + state.onlineFailed = false; + state.showingStale = false; + renderContent(); + return; + } + + const stale = sort === "hot" ? hubApi.listCache.getStale() : null; + if (stale && Array.isArray(stale.items) && stale.items.length) { + state.online.hot = stale.items; + state.showingStale = true; + } else { + state.onlineFailed = true; + } + renderContent(); + }; + + const fetchSort = (sort) => + L.resolveDefault(hubApi.callHubList(sort, 1), null).then((res) => + applyResult(res, sort), + ); + + // ------------------------------------------------------------------ + // Boot: cached hot list first (even stale), then reconcile with the + // fetch load() already ran. + + const cached = hubApi.listCache.getStale(); + if (cached && Array.isArray(cached.items) && cached.items.length) + state.online.hot = cached.items; + + // 缓存或 null。null 走空态,不是错误态 —— 一台刚装好的路由器本来就没有 + // 已发布的作品,和"拿不到数据"是两回事。 + applyMe(hubApi.meCache.getStale()); + // 从工作台带着发布意图过来,直接落在"我的分享"上,不必再点一次标签。 + selectTab( + shareIntent + ? "mine" + : window.location.hash === inboxIndicator.INBOX_HASH + ? "inbox" + : "all", + ); // 末行已 renderContent(),首帧在此成型 + + [ + styleEl, + headEl, + contentEl, + drawerMask, + drawerEl, + ].forEach((child) => rootEl.appendChild(child)); + + // 网络在 DOM 挂载之后才发起。此刻画面已经成型:内置预设来自随包的 + // presets.json,在线列表与创作者档案来自上面两处缓存。两个请求回来后 + // 各自 reconcile,失败则保留当前这帧。 + fetchSort("hot"); + // 打开商店永远两路都刷,不看 preload 的 12 小时节流;hub_me 只跑这一趟。 + hubApi + .refreshNotices({ me: refreshMyShares() }) + .then((snapshot) => { + inboxSnapshot = snapshot; + inboxChanged(); + }); + + return rootEl; + }, +}); diff --git a/luci-app-aurora-config/.dev/src/resource/view/aurora/studio.js b/luci-app-aurora-config/.dev/src/resource/view/aurora/studio.js new file mode 100644 index 00000000..99ac9f23 --- /dev/null +++ b/luci-app-aurora-config/.dev/src/resource/view/aurora/studio.js @@ -0,0 +1,3958 @@ +"use strict"; +"require view"; +"require form"; +"require uci"; +"require rpc"; +"require ui"; +"require fs"; +"require utils.asset-upload as assetUpload"; +"require utils.feed-check as feedCheck"; + +const CONFIG_IMPORT_PATH = "/tmp/aurora_config_import.tmp"; + +// LuCI 客户端渲染的表单控件没有 name 属性,真实 input/select 挂的是 +// id="widget.";按 name 查找永远落空且被 if 守卫静默吞掉。 +const themeFormField = (key) => + document.getElementById("widget.cbid.aurora.theme." + key); + +const FEED_HOST = "openwrt.eamonxg.fun"; +const MANIFEST_URL = `https://${FEED_HOST}/manifest.json`; +const MANIFEST_CACHE_KEY = "aurora.manifest"; + +// Version of the vendored @eamonxg/luci-theme-tokens engine -- stamped by +// scripts/sync-tokens.mjs, verified by tests/theme-token-sync.test.mjs. +// Appended as ?v= when loading utils/tokens.global.js so the browser never +// pairs this file with an HTTP-cached engine from a previous release. +const TOKENS_ENGINE_VERSION = "2.0.0"; + +const loadGlobalScript = (src, version) => + new Promise((resolve, reject) => { + const script = E("script", { + type: "text/javascript", + src: L.resource(src) + (version ? "?v=" + version : ""), + }); + // A dynamically inserted script is async by default, which would let + // tokens.global.js evaluate before color.global.js has defined Color. + // async=false puts both into the browser's in-order queue instead: they + // download in parallel and execute in insertion order. + script.async = false; + script.addEventListener("load", resolve, { once: true }); + script.addEventListener( + "error", + () => reject(new Error(_("Unable to load %s.").format(src))), + { once: true }, + ); + document.querySelector("head").appendChild(script); + }); + +// color.global.js (colorjs.io) powers swatch conversion; tokens.global.js is the +// shared derivation engine (mirrors the theme's tokens/spec.js) used to expand +// the 10 editable inputs into the full set of stored/derived colour tokens. +// Both are requested before either is awaited. The engine is 65KB with no gzip +// in front of it, so waiting for it to arrive before even asking for the 4KB +// file that follows costs a whole extra round trip on the settings page. +// Content hash of the vendored colour library, substituted by +// scripts/build-js.mjs. The library is vendored by hand and carries no version +// of its own, and without a ?v= a browser that already has it will never ask +// again: uhttpd dates our installed files at the epoch and sends no +// Cache-Control, so its heuristic freshness runs to years. Re-vendoring a +// newer colorjs would then reach nobody, and the two halves of the colour +// pipeline -- this library in the browser, the same one in the tokens package +// at build time -- would quietly disagree about what a hex value is. +const COLOR_LIBRARY_VERSION = "__ASSET_HASH(utils/color.global.js)__"; + +const colorLibraryReady = (async () => { + const pending = []; + if (typeof Color !== "function") + pending.push( + loadGlobalScript("utils/color.global.js", COLOR_LIBRARY_VERSION), + ); + if (typeof AuroraTokens === "undefined") + pending.push( + loadGlobalScript("utils/tokens.global.js", TOKENS_ENGINE_VERSION), + ); + await Promise.all(pending); +})().then(() => buildColorTokenTables()); + +const callUploadIcon = rpc.declare({ + object: "luci.aurora", + method: "upload_icon", + params: ["filename"], +}); + +const callListIcons = rpc.declare({ + object: "luci.aurora", + method: "list_icons", +}); + +const callGetInitData = rpc.declare({ + object: "luci.aurora", + method: "get_init_data", +}); + +let _iconsPromise = null; +const getIconsOnce = () => { + if (!_iconsPromise) + _iconsPromise = L.resolveDefault(callListIcons(), { icons: [] }); + return _iconsPromise; +}; + +const callRemoveIcon = rpc.declare({ + object: "luci.aurora", + method: "remove_icon", + params: ["filename"], +}); + +const callPrepareFont = rpc.declare({ + object: "luci.aurora", + method: "prepare_font", + params: ["sans", "mono", "sans_stack"], + // result -1 marks a dead call (rpcd timeout or transport error). No message + // is carried here: this literal would be evaluated at load time, before + // LuCI's translation catalogue exists, so the wording lives at the call + // site where _() is safe to use. + expect: { "": { result: -1 } }, +}); + +const callGetFontStatus = rpc.declare({ + object: "luci.aurora", + method: "get_font_status", + params: ["job_id"], + expect: { "": { state: "missing" } }, +}); + +const callUploadFont = rpc.declare({ + object: "luci.aurora", + method: "upload_font", + params: ["slot", "family"], +}); + +const callRemoveFont = rpc.declare({ + object: "luci.aurora", + method: "remove_font", + params: ["slot", "name"], +}); + +const callExportConfig = rpc.declare({ + object: "luci.aurora", + method: "export_config", +}); + +const callImportConfig = rpc.declare({ + object: "luci.aurora", + method: "import_config", +}); + +const callResetDefaults = rpc.declare({ + object: "luci.aurora", + method: "reset_defaults", +}); + +const callWritePwaManifest = rpc.declare({ + object: "luci.aurora", + method: "write_pwa_manifest", +}); + +// UI copy for each token, keyed by token name. Which tokens exist, their +// order, and the input/derived split all come from the AuroraTokens registry +// (generated from the theme's token spec); buildColorTokenTables() below joins +// the two and throws on any mismatch, so a spec change that adds or removes a +// token fails loudly here until this map is updated. +const COLOR_TOKEN_METADATA = { + bg: { + label: _("Background"), + description: _( + "Page canvas behind the header, navigation, content, and login screen.", + ), + group: "foundation", + }, + surface: { + label: _("Surface"), + description: _( + "Base surface for panels, cards, forms, tables, and neutral controls.", + ), + group: "foundation", + }, + text: { + label: _("Text"), + description: _( + "Default foreground for headings, body text, icons, and form values.", + ), + group: "identity", + }, + brand: { + label: _("Brand"), + description: _( + "Accent for primary buttons, active navigation, and selected states.", + ), + group: "identity", + }, + on_brand: { + label: _("On-Brand Text"), + description: _("Text and icons shown on filled brand backgrounds."), + group: "identity", + }, + link: { + label: _("Link"), + description: _("Hyperlinks in page content, help text, and status output."), + group: "identity", + }, + info: { + label: _("Info"), + description: _("Accent for informational alerts, labels, and tooltips."), + group: "status", + }, + warning: { + label: _("Warning"), + description: _("Accent for warnings, notices, and validation messages."), + group: "status", + }, + success: { + label: _("Success"), + description: _("Accent for successful operations and healthy status."), + group: "status", + }, + danger: { + label: _("Danger"), + description: _( + "Accent for errors, destructive controls, and critical states.", + ), + group: "status", + }, +}; + +const DERIVED_COLOR_TOKEN_METADATA = { + text_muted: { + label: _("Muted Text"), + description: _( + "Medium-emphasis text for helper copy, metadata, and summaries.", + ), + group: "hierarchy", + }, + text_subtle: { + label: _("Subtle Text"), + description: _( + "Low-emphasis text for small labels, placeholders, and disabled hints.", + ), + group: "hierarchy", + }, + surface_sunken: { + label: _("Sunken Surface"), + description: _( + "Inset layer for inputs, code blocks, table headers, and badges.", + ), + group: "hierarchy", + }, + surface_overlay: { + label: _("Overlay Surface"), + description: _("Raised layer for dropdowns, modals, and tooltips."), + group: "hierarchy", + }, + control_bg: { + label: _("Control Fill"), + description: _( + "Fill for form controls: inputs, selects, textareas, and checkboxes.", + ), + group: "hierarchy", + }, + hairline: { + label: _("Hairline"), + description: _("Separators, dividers, and input or card borders."), + group: "hierarchy", + }, + hover_faint: { + label: _("Faint Hover"), + description: _( + "Hover fill for menu items, table rows, and neutral controls.", + ), + group: "hierarchy", + }, + brand_hover: { + label: _("Brand Hover"), + description: _( + "Hover state for filled primary buttons and high-emphasis controls.", + ), + group: "brand_interaction", + }, + brand_subtle: { + label: _("Subtle Brand"), + description: _( + "Brand-tinted surface for active navigation and selected states.", + ), + group: "brand_interaction", + }, + brand_subtle_hover: { + label: _("Subtle Brand Hover"), + description: _( + "Hover fill for subtle primary buttons and selected surfaces.", + ), + group: "brand_interaction", + }, + focus_ring: { + label: _("Focus Ring"), + description: _( + "Focus outline for inputs, selects, and keyboard-operated controls.", + ), + group: "brand_interaction", + }, + progress_start: { + label: _("Progress Start"), + description: _("Leading color for progress meters."), + group: "brand_interaction", + }, + progress_end: { + label: _("Progress End"), + description: _("Trailing color for progress meters."), + group: "brand_interaction", + }, + info_surface: { + label: _("Info Surface"), + description: _( + "Background for informational alerts, labels, and tooltips.", + ), + group: "status_surfaces", + }, + warning_surface: { + label: _("Warning Surface"), + description: _("Background for warning alerts, notices, and labels."), + group: "status_surfaces", + }, + success_surface: { + label: _("Success Surface"), + description: _("Background for success alerts and healthy-state labels."), + group: "status_surfaces", + }, + danger_surface: { + label: _("Danger Surface"), + description: _( + "Background for error alerts and destructive-action messages.", + ), + group: "status_surfaces", + }, + danger_surface_hover: { + label: _("Danger Surface Hover"), + description: _("Hover fill for quiet destructive and delete controls."), + group: "status_surfaces", + }, + scrim: { + label: _("Scrim"), + description: _("Dimming backdrop behind modal dialogs."), + group: "hierarchy", + }, + mega_menu_bg: { + label: _("Mega Menu Background"), + description: _("Opaque surface for the expanded mega menu and its header."), + group: "hierarchy", + }, + mega_menu_scrim: { + label: _("Mega Menu Scrim"), + description: _("Light backdrop behind the expanded mega menu."), + group: "hierarchy", + }, +}; + +const COLOR_GROUPS = [ + { + key: "foundation", + title: _("System Surfaces"), + description: _("Canvas and panel surfaces that frame the interface."), + }, + { + key: "identity", + title: _("Text, Links & Brand"), + description: _( + "Foregrounds, hyperlinks, the brand color, and its contrast text.", + ), + }, + { + key: "status", + title: _("Operational Status Accents"), + description: _( + "Accents for notifications, validation, and status feedback.", + ), + }, +]; + +const DERIVED_COLOR_GROUPS = [ + { + key: "brand_interaction", + title: _("Brand Interaction Details"), + description: _( + "Derived from the brand color: hover states, subtle fills, focus rings, and progress meters.", + ), + }, + { + key: "hierarchy", + title: _("Interface Hierarchy"), + description: _( + "Derived from the canvas, surface, and text colors: muted and subtle text, borders, overlays, and menu layers.", + ), + }, + { + key: "status_surfaces", + title: _("Status Message Surfaces"), + description: _( + "Derived from the status accents: alert, label, and badge backgrounds.", + ), + }, +]; + +// Ordered token tables, joined from the AuroraTokens registry and the UI +// metadata above once the engine scripts have loaded (colorLibraryReady). +// Empty until then; load() awaits colorLibraryReady before building the form. +let COLOR_TOKENS = []; +let DERIVED_COLOR_TOKENS = []; +let ALL_COLOR_TOKENS = []; + +const buildColorTokenTables = () => { + const fromMetadata = (keys, metadata, extra) => + keys.map((key) => { + const meta = metadata[key]; + if (!meta) + throw new Error(`missing color token metadata for "${key}"`); + return Object.assign({ key: key }, meta, extra); + }); + const known = new Set(AuroraTokens.INPUTS.concat(AuroraTokens.DERIVED_KEYS)); + const stale = Object.keys(COLOR_TOKEN_METADATA) + .concat(Object.keys(DERIVED_COLOR_TOKEN_METADATA)) + .filter((key) => !known.has(key)); + if (stale.length) + throw new Error(`stale color token metadata: ${stale.join(", ")}`); + COLOR_TOKENS = fromMetadata(AuroraTokens.INPUTS, COLOR_TOKEN_METADATA, {}); + DERIVED_COLOR_TOKENS = fromMetadata( + AuroraTokens.DERIVED_KEYS, + DERIVED_COLOR_TOKEN_METADATA, + { derived: true }, + ); + ALL_COLOR_TOKENS = COLOR_TOKENS.concat(DERIVED_COLOR_TOKENS); +}; +const COLOR_FORMAT_HELP = _( + "Fields accept #hex, rgb(), hsl(), lab(), and oklch(). The picker fills hex; other formats can be typed.", +); + +// Shown once per mode sub-tab. This used to be the description of the two +// SectionValue cards that wrapped the colour fields; with those cards gone the +// tab's own description slot is where LuCI expects this kind of preamble. +// It cannot move into the field placeholder -- renderColorField already writes +// that with the token's preset value, which is the more useful hint. +const COLOR_TAB_HINT = + _("Changes preview here instantly; Save or Save & Apply persists them.") + + ` ${COLOR_FORMAT_HELP}`; + +const cssTokenName = (key) => key.replaceAll("_", "-"); +const colorOptionName = (mode, key) => `${mode}_${key}`; + +const toRuntimeColor = (value) => { + const raw = value?.trim?.() || ""; + if (!raw || typeof Color !== "function") return raw; + + try { + return new Color(raw).to("srgb").toString({ format: "hex" }); + } catch (_error) { + return raw; + } +}; + +const toPickerColor = (value) => { + const color = new Color(value).to("srgb"); + color.alpha = 1; + return color.toString({ format: "hex" }); +}; + +const sameColorValue = (a, b) => + Boolean(a && b) && + toRuntimeColor(a).toLowerCase() === toRuntimeColor(b).toLowerCase(); + +const readThemeConfigFromUci = () => { + const config = {}; + const copyOption = (option) => { + const value = uci.get("aurora", "theme", option); + if (value != null) config[option] = value; + }; + + copyOption("active_preset"); + copyOption("struct_font_sans"); + copyOption("struct_font_mono"); + ["light", "dark"].forEach((mode) => { + ALL_COLOR_TOKENS.forEach(({ key }) => + copyOption(colorOptionName(mode, key)), + ); + }); + + return config; +}; + +const createColorResolver = () => { + let framePromise = null; + let queue = Promise.resolve(); + + const ensureFrame = () => { + if (framePromise) return framePromise; + + framePromise = new Promise((resolve, reject) => { + const iframe = document.createElement("iframe"); + iframe.setAttribute("aria-hidden", "true"); + iframe.tabIndex = -1; + iframe.style.cssText = + "position:fixed;left:-10000px;top:-10000px;width:1px;height:1px;border:0;visibility:hidden;pointer-events:none;"; + + const timeout = window.setTimeout( + () => reject(new Error(_("Theme stylesheet resolver timed out."))), + 10000, + ); + + iframe.addEventListener( + "load", + () => { + const doc = iframe.contentDocument; + const link = doc?.querySelector('link[rel="stylesheet"]'); + const finish = () => { + window.clearTimeout(timeout); + const probe = doc.createElement("span"); + probe.style.cssText = + "position:absolute;left:-9999px;top:-9999px;visibility:hidden;"; + doc.body.appendChild(probe); + resolve({ iframe, doc, root: doc.documentElement, probe }); + }; + + if (!doc || !link) { + window.clearTimeout(timeout); + reject(new Error(_("Unable to create the theme color resolver."))); + } else if (link.sheet) { + finish(); + } else { + link.addEventListener("load", finish, { once: true }); + link.addEventListener( + "error", + () => { + window.clearTimeout(timeout); + reject(new Error(_("Unable to load the Aurora stylesheet."))); + }, + { once: true }, + ); + } + }, + { once: true }, + ); + + iframe.srcdoc = + ''; + (document.body || document.documentElement).appendChild(iframe); + }); + + return framePromise; + }; + + const resolveMode = (mode, values, tokens = COLOR_TOKENS) => { + queue = queue + .catch(() => {}) + .then(async () => { + const { root, probe } = await ensureFrame(); + root.setAttribute("data-darkmode", mode === "dark" ? "true" : "false"); + + ALL_COLOR_TOKENS.forEach(({ key }) => { + root.style.removeProperty(`--${cssTokenName(key)}`); + }); + ALL_COLOR_TOKENS.forEach(({ key }) => { + const value = values[key]?.trim(); + if (value) + root.style.setProperty( + `--${cssTokenName(key)}`, + toRuntimeColor(value), + ); + }); + + const results = new Map(); + tokens.forEach(({ key }) => { + const name = cssTokenName(key); + probe.parentElement.style.color = "rgb(1 2 3)"; + probe.style.color = `var(--${name}, rgb(1 2 3))`; + const first = getComputedStyle(probe).color; + probe.parentElement.style.color = "rgb(4 5 6)"; + probe.style.color = `var(--${name}, rgb(4 5 6))`; + const second = getComputedStyle(probe).color; + + if (!first || !second || first !== second) { + results.set(key, { + valid: false, + error: _("Invalid, missing, or cyclic color expression."), + }); + } else { + results.set(key, { valid: true, color: first }); + } + }); + probe.parentElement.style.removeProperty("color"); + return results; + }); + + return queue; + }; + + const destroy = () => { + framePromise?.then(({ iframe }) => iframe.remove()).catch(() => {}); + framePromise = null; + }; + + return { resolveMode, destroy }; +}; + +const createColorEditor = (themeConfig, presetColors) => { + const resolver = createColorResolver(); + const fields = { light: new Map(), dark: new Map() }; + const states = { light: new Map(), dark: new Map() }; + const derivedOverrides = { light: new Map(), dark: new Map() }; + const timers = { light: null, dark: null }; + const previewOriginal = new Map(); + let modeObserver = null; + + const currentMode = () => + document.documentElement.getAttribute("data-darkmode") === "true" + ? "dark" + : "light"; + + const stateFor = (mode, key) => { + if (!states[mode].has(key)) { + states[mode].set(key, { pending: true, valid: false, error: null }); + } + return states[mode].get(key); + }; + + const valueFor = (mode, key) => { + const field = fields[mode].get(key); + if (field?.input) return field.input.value; + return themeConfig[colorOptionName(mode, key)] || ""; + }; + + const isInputToken = (key) => COLOR_TOKENS.some((token) => token.key === key); + const isDerivedToken = (key) => + DERIVED_COLOR_TOKENS.some((token) => token.key === key); + + const valuesForMode = (mode) => + Object.fromEntries( + ALL_COLOR_TOKENS.map(({ key }) => [key, valueFor(mode, key)]), + ); + + // What a source token is worth for derivation purposes. An untouched field is + // empty and shows its preset as a placeholder -- the theme still renders with + // that preset, so derivation has to use it too. Reading only input.value made + // a single unset source colour blank all 21 derived previews, which is the + // default state on a fresh install: uci stores nothing until you edit. + const sourceValueFor = (mode, key) => { + const typed = valueFor(mode, key).trim(); + if (typed) return typed; + return (presetColors?.[colorOptionName(mode, key)] || "").trim(); + }; + + const automaticForMode = (mode) => { + if (typeof AuroraTokens === "undefined") return null; + const inputs = {}; + for (const { key } of COLOR_TOKENS) { + const value = sourceValueFor(mode, key); + if (!value) return null; + inputs[key] = value; + } + try { + return AuroraTokens.resolve(mode, inputs); + } catch (_error) { + return null; + } + }; + + const isDerivedOverride = (mode, key) => + Boolean(derivedOverrides[mode].get(key)); + + const setDerivedOverride = (mode, key, enabled) => { + if (!isDerivedToken(key)) return; + derivedOverrides[mode].set(key, Boolean(enabled)); + }; + + const syncDerivedInitialState = (mode, automatic) => { + if (!automatic) return; + DERIVED_COLOR_TOKENS.forEach(({ key }) => { + const field = fields[mode].get(key); + if (!field || field.initialized) return; + + const saved = field.input.value.trim(); + const autoValue = automatic[key]?.trim() || ""; + const override = Boolean(saved && !sameColorValue(saved, autoValue)); + field.initialized = true; + setDerivedOverride(mode, key, override); + if (!override) field.input.value = ""; + }); + }; + + // Expand the 10 input colors into a full token snapshot. Derived tokens use + // automatic values unless the user explicitly supplied an override. + const resolvedForMode = (mode) => { + const automatic = automaticForMode(mode); + if (!automatic) return null; + syncDerivedInitialState(mode, automatic); + + const resolved = { ...automatic }; + for (const { key } of DERIVED_COLOR_TOKENS) { + const value = valueFor(mode, key).trim(); + const state = stateFor(mode, key); + if (isDerivedOverride(mode, key) && value) { + if (!state.valid) return null; + resolved[key] = value; + } + } + + return resolved; + }; + + const rememberPreview = (property) => { + if (previewOriginal.has(property)) return; + previewOriginal.set(property, { + value: document.documentElement.style.getPropertyValue(property), + priority: document.documentElement.style.getPropertyPriority(property), + }); + }; + + const restorePreviewProperty = (property) => { + const original = previewOriginal.get(property); + if (!original) { + document.documentElement.style.removeProperty(property); + } else if (original.value) { + document.documentElement.style.setProperty( + property, + original.value, + original.priority, + ); + } else { + document.documentElement.style.removeProperty(property); + } + }; + + const cleanupPreview = () => { + previewOriginal.forEach((_original, property) => { + restorePreviewProperty(property); + }); + previewOriginal.clear(); + }; + + const applyPreview = (mode) => { + if (mode !== currentMode()) return; + + COLOR_TOKENS.forEach(({ key }) => { + const state = stateFor(mode, key); + if (!state.valid) return; + + const property = `--${cssTokenName(key)}`; + const value = valueFor(mode, key).trim(); + rememberPreview(property); + if (value) { + document.documentElement.style.setProperty( + property, + toRuntimeColor(value), + ); + } else { + restorePreviewProperty(property); + } + }); + + // Derived tokens are baked literals in the theme stylesheet, so changing an + // input no longer cascades on its own -- recompute and preview them too. + const resolved = resolvedForMode(mode); + if (resolved) { + Object.keys(resolved).forEach((key) => { + if (isInputToken(key)) return; + const property = `--${cssTokenName(key)}`; + rememberPreview(property); + document.documentElement.style.setProperty( + property, + toRuntimeColor(resolved[key]), + ); + }); + } + }; + + const triggerValidation = (field) => { + field?.option.triggerValidation(field.sectionId); + }; + + const refreshTabErrors = (mode) => { + const field = fields[mode].values().next().value; + const mapRoot = field?.option?.map?.root; + if (mapRoot) ui.tabs.updateTabs(null, mapRoot); + }; + + const updateField = (mode, key, result, options = {}) => { + const shouldValidate = options.validateKeys?.has(key) ?? false; + const field = fields[mode].get(key); + const state = stateFor(mode, key); + state.pending = false; + state.valid = Boolean(result?.valid); + state.error = result?.error || null; + if (!field) return; + + field.input.setCustomValidity(state.valid ? "" : state.error); + field.element.classList.toggle("cbi-value-error", !state.valid); + + if (!state.valid) { + field.status.textContent = state.error; + if (shouldValidate) triggerValidation(field); + return; + } + + try { + const runtimeColor = toRuntimeColor(result.color); + field.picker.value = toPickerColor(result.color); + field.swatch.style.backgroundColor = runtimeColor; + field.swatch.title = `${_("Resolved color")}: ${runtimeColor}`; + if (field.token.derived) { + if (result.autoValue) + field.input.placeholder = toRuntimeColor(result.autoValue); + field.status.textContent = ""; + } else { + field.status.textContent = ""; + } + } catch (error) { + state.valid = false; + state.error = _("Resolved color cannot be shown by the picker."); + field.input.setCustomValidity(state.error); + field.element.classList.add("cbi-value-error"); + field.status.textContent = state.error; + } + if (shouldValidate) triggerValidation(field); + }; + + const refresh = (mode, options = {}) => { + const validateKeys = options.validateKeys || new Set(); + + return colorLibraryReady + .then(() => { + const automatic = automaticForMode(mode); + syncDerivedInitialState(mode, automatic); + + const validationTokens = COLOR_TOKENS.concat( + DERIVED_COLOR_TOKENS.filter( + ({ key }) => + isDerivedOverride(mode, key) && valueFor(mode, key).trim(), + ), + ); + + return resolver + .resolveMode(mode, valuesForMode(mode), validationTokens) + .then((results) => ({ automatic, results })); + }) + .then((results) => { + COLOR_TOKENS.forEach(({ key }) => { + updateField(mode, key, results.results.get(key), { validateKeys }); + }); + + DERIVED_COLOR_TOKENS.forEach(({ key }) => { + if (isDerivedOverride(mode, key) && valueFor(mode, key).trim()) { + const result = results.results.get(key); + updateField( + mode, + key, + { + ...result, + autoValue: results.automatic?.[key] || "", + }, + { validateKeys }, + ); + return; + } + + const autoValue = results.automatic?.[key]; + updateField( + mode, + key, + autoValue + ? { valid: true, color: autoValue, autoValue } + : { + valid: false, + error: _("Unable to generate the automatic derived value."), + }, + { validateKeys }, + ); + }); + applyPreview(mode); + }) + .catch((error) => { + ALL_COLOR_TOKENS.forEach(({ key }) => { + updateField( + mode, + key, + { + valid: false, + error: + error?.message || _("Unable to resolve color expressions."), + }, + { validateKeys }, + ); + }); + }) + .finally(() => refreshTabErrors(mode)); + }; + + const affectedKeysFor = (key) => { + if (!key) return ALL_COLOR_TOKENS.map((token) => token.key); + if (isInputToken(key)) + return COLOR_TOKENS.concat(DERIVED_COLOR_TOKENS).map( + (token) => token.key, + ); + return [key]; + }; + + const schedule = (mode, key, options = {}) => { + const affectedKeys = affectedKeysFor(key); + const validateKeys = new Set(options.validate ? [key].filter(Boolean) : []); + + window.clearTimeout(timers[mode]); + affectedKeys.forEach((affectedKey) => { + const state = stateFor(mode, affectedKey); + state.pending = true; + }); + timers[mode] = window.setTimeout( + () => refresh(mode, { validateKeys }), + 120, + ); + }; + + const register = ( + mode, + token, + element, + input, + option, + sectionId, + controls, + ) => { + fields[mode].set(token.key, { + element, + input, + option, + sectionId, + token, + ...controls, + }); + input.addEventListener("input", () => { + themeConfig[colorOptionName(mode, token.key)] = input.value; + if (token.derived) + setDerivedOverride(mode, token.key, Boolean(input.value.trim())); + schedule(mode, token.key, { validate: true }); + }); + schedule(mode); + }; + + const validate = (mode, key, value) => { + if (!value?.trim()) return true; + const state = stateFor(mode, key); + if (state.pending) + return state.valid || state.error == null ? true : state.error; + return state.valid ? true : state.error || _("Invalid color expression."); + }; + + const attach = () => { + schedule("light"); + schedule("dark"); + modeObserver = new MutationObserver((mutations) => { + if ( + mutations.some((mutation) => mutation.attributeName === "data-darkmode") + ) { + cleanupPreview(); + applyPreview(currentMode()); + } + }); + modeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-darkmode"], + }); + window.addEventListener("beforeunload", cleanupPreview, { once: true }); + }; + + const destroy = () => { + window.clearTimeout(timers.light); + window.clearTimeout(timers.dark); + modeObserver?.disconnect(); + cleanupPreview(); + resolver.destroy(); + }; + + const flush = () => { + window.clearTimeout(timers.light); + window.clearTimeout(timers.dark); + return Promise.all([refresh("light"), refresh("dark")]); + }; + + return { + attach, + cleanupPreview, + destroy, + flush, + presetColors, + register, + resolvedForMode, + schedule, + stateFor, + validate, + }; +}; + +// Miniature wireframes for the navigation choices. Pure CSS -- no images, no +// requests -- painted from theme variables, so they follow dark mode on their +// own. A dropdown named "Mega Menu" says nothing about what the page will look +// like; these do. +const navWireBar = (width) => + E("span", { class: "aurora-nav-wire-bar", style: `width:${width};` }); + +// What separates the three layouts is which part of the page the menu covers +// when it opens, so that is what each drawing shows: a full-width sheet, a +// narrow panel anchored under one item, or a full-height rail. The page +// content sits underneath, dimmed, which is what makes the dropdown legible -- +// you can see the page still showing beside the panel. +// Logo left, menu items centred, icon area right -- the real header's shape. +// The active item has to sit among the menu items: highlighting something in +// the icon area would point at the search button instead of the navigation. +const navTopBar = (activeIndex) => + E("span", { class: "aurora-nav-wire-top" }, [ + E("span", { class: "aurora-nav-wire-logo" }), + E( + "span", + { class: "aurora-nav-wire-menu" }, + [0, 1, 2, 3].map((index) => + E("span", { + class: + "aurora-nav-wire-item" + (index === activeIndex ? " is-active" : ""), + }), + ), + ), + E("span", { class: "aurora-nav-wire-icon" }), + ]); + +const navWireBarActive = (width) => + E("span", { class: "aurora-nav-wire-bar is-active", style: `width:${width};` }); + +const navUnderlay = (widths) => + E( + "span", + { class: "aurora-nav-wire-underlay" }, + widths.map((width) => navWireBar(width)), + ); + +const NAV_CHOICE_WIREFRAMES = { + "mega-menu": () => + E("span", { class: "aurora-nav-wire" }, [ + navTopBar(1), + navUnderlay(["70%", "90%", "55%", "80%"]), + // One entry inside the panel is brand-tinted too: the open menu marks the + // page you are on, and a panel of uniformly grey bars loses that. + E("span", { class: "aurora-nav-wire-panel is-full" }, [ + E("span", { class: "aurora-nav-wire-col" }, [ + navWireBar("80%"), + navWireBar("60%"), + ]), + E("span", { class: "aurora-nav-wire-col" }, [ + navWireBar("70%"), + navWireBarActive("85%"), + ]), + E("span", { class: "aurora-nav-wire-col" }, [ + navWireBar("60%"), + navWireBar("70%"), + ]), + ]), + ]), + dropdown: () => + E("span", { class: "aurora-nav-wire" }, [ + navTopBar(1), + navUnderlay(["70%", "90%", "55%", "80%"]), + E("span", { class: "aurora-nav-wire-panel is-anchored" }, [ + E("span", { class: "aurora-nav-wire-col" }, [ + navWireBar("85%"), + navWireBarActive("65%"), + navWireBar("75%"), + ]), + ]), + ]), + sidebar: () => + E("span", { class: "aurora-nav-wire aurora-nav-wire-side" }, [ + E("span", { class: "aurora-nav-wire-rail" }, [ + navWireBar("70%"), + navWireBar("90%"), + E("span", { class: "aurora-nav-wire-bar is-active", style: "width:75%;" }), + E("span", { class: "aurora-nav-wire-bar is-child", style: "width:80%;" }), + navWireBar("65%"), + ]), + E("span", { class: "aurora-nav-wire-main" }, [ + navWireBar("55%"), + E("span", { class: "aurora-nav-wire-bar is-dim", style: "width:85%;" }), + E("span", { class: "aurora-nav-wire-bar is-dim", style: "width:70%;" }), + ]), + ]), +}; + +const ensureNavChoiceStyles = () => { + if (document.getElementById("aurora-nav-choice-styles")) return; + document.head.appendChild( + E( + "style", + { id: "aurora-nav-choice-styles" }, + ` +/* Sized to their content and left-aligned: three choices have no reason to + span the full row, and letting them stretch flattened the thumbnails into + 6:1 letterboxes where a sidebar rail no longer read as a sidebar. */ +.aurora-nav-choices { + display: flex; + flex-wrap: wrap; + gap: .75rem; +} +.aurora-nav-choices > .cbi-radio { + border: 1px solid var(--hairline); + border-radius: calc(var(--radius-base) * 1.5); + cursor: pointer; + display: block; + flex: 0 0 auto; + padding: .5rem; + width: 12rem; +} +.aurora-nav-choices > .cbi-radio:has(input:checked) { + border-color: var(--brand); + box-shadow: 0 0 0 1px var(--brand); +} +.aurora-nav-choices > .cbi-radio:focus-within { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +/* Locked to the shape of a screen, never to the shape of its card. */ +.aurora-nav-wire { + aspect-ratio: 4 / 3; + background: var(--bg); + border: 1px solid var(--hairline); + border-radius: var(--radius-base); + display: block; + margin-bottom: .45rem; + /* Clips the sidebar rail to the rounded corners; without it the rail's + square top-left leaves a notch in the frame. */ + overflow: hidden; + padding: .3rem; + position: relative; +} +.aurora-nav-wire-top { + align-items: center; + display: flex; + gap: .18rem; + height: .5rem; +} +.aurora-nav-wire-menu { + display: flex; + flex: 1; + gap: .18rem; + justify-content: center; +} +.aurora-nav-wire-logo, +.aurora-nav-wire-item, +.aurora-nav-wire-bar { + background: var(--hairline); + border-radius: 1px; +} +.aurora-nav-wire-logo { + background: var(--text-subtle); + height: .2rem; + width: .5rem; +} +.aurora-nav-wire-item { + height: .2rem; + width: .4rem; +} +/* A square, because that corner of the header holds the search icon -- a wide + bar there reads as one more menu item. */ +.aurora-nav-wire-icon { + background: var(--hairline); + border-radius: 1px; + height: .22rem; + width: .22rem; +} +.aurora-nav-wire-bar { + height: .18rem; +} +.aurora-nav-wire-item.is-active, +.aurora-nav-wire-bar.is-active { + background: var(--brand); +} +.aurora-nav-wire-bar.is-child { + margin-left: 18%; +} +.aurora-nav-wire-bar.is-dim { + opacity: .5; +} +.aurora-nav-wire-underlay { + display: flex; + flex-direction: column; + gap: .18rem; + left: .3rem; + opacity: .3; + position: absolute; + right: .3rem; + top: 1rem; +} +/* The panel floats above the page: that is the whole distinction between a + full-width sheet and a dropdown anchored under one item. */ +.aurora-nav-wire-panel { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: 3px; + box-shadow: 0 2px 5px rgba(0, 0, 0, .16); + display: flex; + gap: .25rem; + padding: .25rem; + position: absolute; + top: .95rem; +} +.aurora-nav-wire-panel.is-full { + left: .3rem; + right: .3rem; +} +.aurora-nav-wire-panel.is-anchored { + right: 22%; + width: 38%; +} +.aurora-nav-wire-col, +.aurora-nav-wire-main { + display: flex; + flex: 1; + flex-direction: column; + gap: .18rem; +} +.aurora-nav-wire-side { + display: flex; + gap: .25rem; + padding: 0; +} +.aurora-nav-wire-rail { + background: var(--surface); + border-right: 1px solid var(--hairline); + display: flex; + flex-direction: column; + gap: .2rem; + padding: .3rem; + width: 32%; +} +.aurora-nav-wire-main { + gap: .2rem; + padding: .3rem .3rem .3rem 0; +} +`, + ), + ); +}; + +const renderNavChoiceWidget = function (section_id, option_index, cfgvalue) { + ensureNavChoiceStyles(); + const node = form.ListValue.prototype.renderWidget.apply(this, arguments); + node.classList.add("aurora-nav-choices"); + + // ui.Select separates horizontal radios with " \xa0 " text nodes; they would + // wedge gaps into the flex row. + Array.from(node.childNodes) + .filter((child) => child.nodeType !== Node.ELEMENT_NODE) + .forEach((child) => node.removeChild(child)); + + node.querySelectorAll("input[type=radio]").forEach((input) => { + const wireframe = NAV_CHOICE_WIREFRAMES[input.value]; + if (!wireframe || !input.parentNode) return; + // Before the input, never between input/label/text: ui.Select's caption + // handler reaches the input via previousElementSibling twice, and breaking + // that chain would silently kill click-to-select. + const drawing = wireframe(); + drawing.addEventListener("click", () => input.click()); + input.parentNode.insertBefore(drawing, input); + }); + + return node; +}; + +const renderColorField = function (optionIndex, sectionId, inTable) { + const rendered = form.Value.prototype.render.apply(this, [ + optionIndex, + sectionId, + inTable, + ]); + + return Promise.resolve(rendered).then((element) => { + const input = element.querySelector('input[type="text"]'); + if (!input) return element; + + const token = this.colorToken; + const mode = this.colorMode; + const editor = this.colorEditor; + const optionKey = colorOptionName(mode, token.key); + const presetValue = editor.presetColors?.[optionKey] || ""; + element.dataset.auroraColorMode = mode; + element.dataset.auroraColorKind = token.derived ? "derived" : "base"; + element.dataset.auroraColorGroup = token.group || ""; + input.placeholder = token.derived + ? _("Automatic") + : presetValue || _("Saved or preset value"); + + const picker = E("input", { + type: "color", + value: "#000000", + style: + "position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0;opacity:0;cursor:pointer;", + title: _("Choose a literal color for this token"), + }); + const swatch = E( + "span", + { + style: + "position:relative;display:inline-block;width:2rem;height:2rem;margin-left:.5rem;border:1px solid currentColor;border-radius:.5rem;vertical-align:middle;background:transparent;overflow:hidden;cursor:pointer;", + title: _("Resolved preview color - click to pick"), + }, + [picker], + ); + const status = E("small", { + style: "display:block;margin-top:.35rem;opacity:.75;", + }); + + const controls = E("span", {}, [swatch]); + input.parentNode.appendChild(controls); + input.parentNode.appendChild(status); + + picker.addEventListener("change", () => { + try { + input.value = picker.value; + input.dispatchEvent(new Event("input")); + } catch (error) { + input.setCustomValidity(_("Unable to convert the selected color.")); + } + }); + + editor.register(mode, token, element, input, this, sectionId, { + picker, + status, + swatch, + }); + return element; + }); +}; + +const addColorInputs = (section, mode, tokens, editor) => { + tokens.forEach((token) => { + const optionKey = colorOptionName(mode, token.key); + const option = section.option(form.Value, optionKey, token.label); + option.rmempty = true; + if (token.description) option.description = token.description; + option.colorEditor = editor; + option.colorMode = mode; + option.colorToken = token; + option.render = renderColorField; + option.validate = (_sectionId, value) => + editor.validate(mode, token.key, value); + option.write = (sectionId, value) => { + const trimmed = value?.trim(); + if (trimmed) { + uci.set("aurora", sectionId, optionKey, toRuntimeColor(trimmed)); + } else { + uci.unset("aurora", sectionId, optionKey); + } + }; + option.remove = (sectionId) => { + uci.unset("aurora", sectionId, optionKey); + }; + }); +}; + +const createColorSections = (section, mode, editor) => { + // Source tokens hang straight off the mode sub-tab -- no SectionValue in + // between. addColorInputs speaks section.option(...), so this adapter turns + // those calls into taboption(mode, ...) without touching its signature. + addColorInputs( + { option: (...args) => section.taboption(mode, ...args) }, + mode, + COLOR_TOKENS, + editor, + ); + + // The derived tokens keep the one remaining wrapper. It earns its place + // twice over: it gives the 30 fields a DOM container for enhanceDerivedFold + // to collapse, and it keeps them out of the source rows' parent, which is + // how enhanceColorTokenGroups tells the two apart (it groups by + // row.parentElement). It renders no title -- the fold's summary is the label. + const derivedSection = section.taboption( + mode, + form.SectionValue, + `_${mode}_derived_colors`, + form.NamedSection, + "theme", + "aurora", + ); + addColorInputs( + derivedSection.subsection, + mode, + DERIVED_COLOR_GROUPS.flatMap((group) => + DERIVED_COLOR_TOKENS.filter((token) => token.group === group.key), + ), + editor, + ); +}; + +const colorGroupFor = (kind, key) => + (kind === "derived" ? DERIVED_COLOR_GROUPS : COLOR_GROUPS).find( + (group) => group.key === key, + ); + +const ensureColorGroupStyles = () => { + if (document.getElementById("aurora-color-group-styles")) return; + document.head.appendChild( + E( + "style", + { id: "aurora-color-group-styles" }, + ` +/* Both folds -- the per-group headings and the derived block -- share one + summary treatment. They differ only in how they draw their chevron: the + groups use a real button so a stray click cannot collapse them, the derived + block is a plain marker. */ +.aurora-token-group, +.aurora-derived-fold { + margin: 0; +} +.aurora-token-group > summary, +.aurora-derived-fold > summary { + align-items: center; + border-bottom: 1px solid var(--hairline); + display: flex; + gap: 1rem; + list-style: none; + padding: 1rem 0 .75rem; +} +.aurora-token-group > summary::-webkit-details-marker, +.aurora-derived-fold > summary::-webkit-details-marker { + display: none; +} +.aurora-token-group > summary { + cursor: default; + justify-content: space-between; +} +.aurora-derived-fold > summary { + cursor: pointer; +} +.aurora-derived-fold > summary::before { + color: var(--text-muted); + content: "\\25B8"; + transition: rotate .2s ease; +} +.aurora-derived-fold[open] > summary::before { + rotate: 90deg; +} +.aurora-token-group-title, +.aurora-derived-fold-title { + display: block; + font-size: 1rem; + font-weight: 700; +} +.aurora-token-group-description, +.aurora-derived-fold-description { + color: var(--text-muted); + display: block; + font-size: .875rem; + line-height: 1.45; +} +.aurora-token-group-description { + margin-top: .25rem; +} +.aurora-token-group-body { + padding: 1rem 0; +} +/* Box + interaction reset; the chevron glyph is reused from the Aurora theme's + .navigation-group-toggle::after, so no SVG is duplicated here. */ +.aurora-token-group-toggle { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 999px; + color: var(--text-muted); + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + height: 1.75rem; + justify-content: center; + padding: 0; + width: 1.75rem; +} +.aurora-token-group-toggle:hover { + color: var(--text); +} +.aurora-token-group-toggle:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +.aurora-token-group-toggle::after { + transition: rotate .25s ease; +} +.aurora-token-group[open] .aurora-token-group-toggle::after { + rotate: 90deg; +} +/* Derived colours: 21 tokens nobody edits on most installs. As a tile grid the + whole computed palette is visible at once instead of scrolling past 21 + near-identical rows, and each tile still holds its real input. */ +.aurora-derived-host { + display: block; +} +.aurora-derived-host > .cbi-value-title { + display: none; +} +/* The fold only groups rows. Left alone the theme gives this nested section the + same card chrome as the page card around it -- a border, radius and shadow + drawn a second time just inside the first. The #maincontent prefix is not + decoration: the theme sets that chrome from "#maincontent .cbi-section", and + a plain class selector loses to it. */ +#maincontent .aurora-derived-fold > .cbi-section { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + margin: 0; + padding: 0; +} +.aurora-derived-fold .aurora-token-group-body { + display: grid; + gap: .5rem; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); +} +.aurora-tile { + align-items: center; + border: 1px solid var(--hairline); + border-radius: var(--radius-base); + display: grid; + gap: 0 .5rem; + grid-template-areas: "swatch title" "swatch field"; + grid-template-columns: auto 1fr; + padding: .5rem; +} +/* The theme spaces stacked rows with ".cbi-value + .cbi-value{margin-top}". + In a grid that is the gap's job, and the stray top margin left the first + tile of each row 12px taller than its neighbours, since grid items stretch + to the row height while the others start lower. Matching the adjacent- + sibling specificity is what makes this stick. */ +.aurora-tile + .aurora-tile, +.aurora-derived-fold .aurora-token-group-body > .cbi-value { + margin: 0; +} +.aurora-tile > .aurora-tile-swatch { + grid-area: swatch; +} +.aurora-tile > .cbi-value-title { + font-size: .8125rem; + grid-area: title; + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: auto; +} +.aurora-tile > .cbi-value-field { + grid-area: field; + min-width: 0; +} +/* Width only. Shrinking the font and padding squashed the theme's pill input + without shrinking its radius, so the tiles ended up with flattened capsules + that matched nothing else on the page. */ +.aurora-tile input[type="text"] { + width: 100%; +} +/* The description is the tile's tooltip instead; the validation message under + the input is not hidden -- a rejected colour has to say so. */ +.aurora-tile .cbi-value-description { + display: none; +} +`, + ), + ); +}; + +/* The Page Backgrounds component's own layout. It used to live in + luci-theme-aurora as patches/admin-system-aurora.css, loaded on demand by + that theme's header.ut when the page path matched the filename. Install this + app under any other theme -- shadcn has the same patch mechanism but not this + file -- and the sheet simply never arrived: .bg-preview lost + position/height/overflow, and the eight absolutely positioned layers inside + it resolved against the initial containing block and covered the page. The + layout of this app's own widget is this app's job, so it travels with the + view now and the theme owns nothing about this page. + + Design A: the two background targets sit side by side as always-visible + picker cards (select in the card head, mini preview below); one shared slider + pane under the pair follows whichever card is active. No tab ever hides the + other target's state. + + Every theme value is a fallback chain -- Aurora token, then the nearest + shadcn/generic name, then something derived from currentColor -- because a + bare var(--hairline) under a theme that never heard of it drops the whole + declaration, which is how the borders vanished too. */ +// 菜单的浮层、项和 hover 用主题现成的 .cbi-dropdown > ul.dropdown;这里只补 +// 它表达不了的:安静的版本行、方形的"更多"、右对齐、分隔线、危险色、窄屏。 +const ensureToolbarStyles = () => { + if (document.getElementById("aurora-studio-head-styles")) return; + document.head.appendChild( + E( + "style", + { id: "aurora-studio-head-styles" }, + ` +.aurora-studio-head { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px 16px; +} +.aurora-studio-versions { + color: var(--text-subtle, var(--muted-foreground, color-mix(in srgb, currentColor 55%, transparent))); + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + font-size: .88em; + align-items: center; + gap: 4px 1.6em; + min-width: 0; +} +.aurora-studio-versions b { + color: var(--text-muted, var(--muted-foreground, color-mix(in srgb, currentColor 70%, transparent))); + font-weight: 500; +} +.aurora-studio-versions .label { + font-variant-numeric: tabular-nums; + margin-left: .45em; +} +.aurora-studio-versions .arrow { + margin: 0 .35em; +} +.aurora-studio-versions a { + color: var(--brand, var(--primary, currentColor)); + font-weight: 500; + text-decoration: none; +} +.aurora-studio-versions a:hover { + text-decoration: underline; +} +.aurora-studio-acts { + display: flex; + gap: 8px; + margin-left: auto; +} +.cbi-dropdown.aurora-studio-more { + cursor: pointer; + height: 38px; + justify-content: center; + min-width: 0; + position: relative; + width: 38px; +} +.cbi-dropdown.aurora-studio-more > ul.dropdown { + left: auto; + min-width: 0; + right: 0; + top: calc(100% + 6px); + width: min(280px, calc(100vw - 32px)); +} +.cbi-dropdown.aurora-studio-more:not([open]) > ul.dropdown { + display: none; +} +.cbi-dropdown.aurora-studio-more > ul.dropdown > li.danger { + color: var(--danger, var(--destructive, #c0392b)); +} +.cbi-dropdown.aurora-studio-more > ul.dropdown > li.sep { + background: var(--hairline, var(--border, color-mix(in srgb, currentColor 18%, transparent))); + height: 1px; + margin: 4px 0; + min-height: 0; + padding: 0; + pointer-events: none; +} +@media (max-width: 600px) { + .aurora-studio-acts { + flex: 1 1 100%; + } + .aurora-studio-acts > .cbi-button:not(.aurora-studio-more) { + flex: 1; + min-height: 42px; + } + .cbi-dropdown.aurora-studio-more { + height: 42px; + width: 42px; + } +} +@media (max-width: 600px), (hover: none) { + .cbi-dropdown.aurora-studio-more > ul.dropdown > li[role="menuitem"] { + min-height: 42px; + } +} +`, + ), + ); +}; + +const ensureBgCardStyles = () => { + if (document.getElementById("aurora-bg-card-styles")) return; + document.head.appendChild( + E( + "style", + { id: "aurora-bg-card-styles" }, + ` +.bg-duo, +.bg-pane, +[data-bg-target] { + --bgp-line: var(--hairline, var(--border, color-mix(in srgb, currentColor 18%, transparent))); + --bgp-subtle: var(--text-subtle, var(--muted-foreground, color-mix(in srgb, currentColor 60%, transparent))); + --bgp-muted: var(--text-muted, var(--muted-foreground, color-mix(in srgb, currentColor 65%, transparent))); + --bgp-control: var(--control-bg, var(--input, color-mix(in srgb, currentColor 8%, transparent))); + --bgp-surface: var(--surface, var(--background, Canvas)); + --bgp-bg: var(--bg, var(--background, Canvas)); + --bgp-text: var(--text, var(--foreground, CanvasText)); + --bgp-brand: var(--brand, var(--primary, currentColor)); + --bgp-radius: var(--radius-base, var(--radius, .5rem)); +} +[data-bg-target].cbi-value { + display: block; +} +[data-bg-target] > .cbi-value-title { + display: none; +} +/* 双卡并排;窄屏落回单列 */ +.bg-duo { + display: grid; + gap: 1rem; + grid-template-columns: 1fr 1fr; + max-width: 760px; +} +@media (width < 680px) { + .bg-duo { + grid-template-columns: 1fr; + } +} +.bg-duo [data-bg-target].cbi-value { + background: var(--bgp-surface); + border: 2px solid var(--bgp-line); + border-radius: calc(var(--bgp-radius) * 1.8); + cursor: pointer; + margin: 0; + padding: .8rem; + transition: border-color .15s; +} +.bg-duo [data-bg-target].on { + border-color: var(--bgp-brand); +} +.bg-card-head { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + margin-bottom: .6rem; +} +.bg-card-title { + font-size: .95rem; + font-weight: 650; + white-space: nowrap; +} +/* The one rule the whole component stands on: the preview's layers are + absolute, so it has to be the positioned, sized, clipping ancestor. */ +.bg-preview { + background: var(--bgp-bg); + border: 1px solid var(--bgp-line); + border-radius: var(--bgp-radius); + height: 130px; + overflow: hidden; + position: relative; +} +/* 共享滑杆面板:跟随选中的卡 */ +.bg-pane { + margin-top: 1rem; + max-width: 620px; +} +.bg-srow { + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: minmax(6.5em, max-content) 1fr 3.2em; + margin-top: 10px; +} +.bg-srow label { + font-size: .9rem; + white-space: nowrap; +} +.bg-srow input[type="range"] { + accent-color: var(--bgp-brand); + width: 100%; +} +.bg-srow output { + color: var(--bgp-muted); + font-size: .85rem; + font-variant-numeric: tabular-nums; + text-align: right; +} +`, + ), + ); +}; + +const enhanceColorTokenGroups = (root) => { + ensureColorGroupStyles(); + const rows = Array.from(root.querySelectorAll("[data-aurora-color-group]")); + const containers = new Set( + rows.map((row) => row.parentElement).filter(Boolean), + ); + + containers.forEach((container) => { + if (container.dataset.auroraTokenGroupsEnhanced === "true") return; + const children = Array.from(container.children).filter( + (child) => child.dataset?.auroraColorGroup, + ); + if (children.length === 0) return; + + container.dataset.auroraTokenGroupsEnhanced = "true"; + let index = 0; + while (index < children.length) { + const first = children[index]; + const groupKey = first.dataset.auroraColorGroup; + const kind = first.dataset.auroraColorKind; + const groupRows = []; + + while ( + index < children.length && + children[index].dataset.auroraColorGroup === groupKey + ) { + groupRows.push(children[index]); + index += 1; + } + + const group = colorGroupFor(kind, groupKey); + if (!group) continue; + + const body = E("div", { class: "aurora-token-group-body" }); + const toggle = E("button", { + type: "button", + // Reuse the Aurora theme's navigation chevron glyph; the local class + // only adds the button box reset and the open-state rotation. + class: "aurora-token-group-toggle navigation-group-toggle", + "aria-label": _("Expand or collapse this group"), + "aria-expanded": "true", + }); + const summary = E("summary", {}, [ + E("span", {}, [ + E("span", { class: "aurora-token-group-title" }, group.title), + E( + "span", + { class: "aurora-token-group-description" }, + group.description, + ), + ]), + toggle, + ]); + + const details = E("details", { class: "aurora-token-group", open: "" }, [ + summary, + body, + ]); + + // A bare toggles on ANY click anywhere on the header strip, so a + // stray click -- notably the click that dismisses the native color picker + // landing on this large header -- would collapse the group. Restrict + // toggling to the chevron button: it drives the open state explicitly, + // every other summary click is cancelled, and the toggle listener reverts + // any collapse that still slips through to the expected state. + let expectedOpen = true; + const setGroupOpen = (open) => { + expectedOpen = open; + if (details.open !== open) details.open = open; + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }; + toggle.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setGroupOpen(!expectedOpen); + }); + summary.addEventListener("click", (ev) => { + if (!ev.target.closest(".aurora-token-group-toggle")) + ev.preventDefault(); + }); + details.addEventListener("toggle", () => { + if (details.open !== expectedOpen) details.open = expectedOpen; + }); + + container.insertBefore(details, first); + groupRows.forEach((row) => body.appendChild(row)); + } + }); +}; + +// Collapse each mode's derived tokens into one fold. The anchor is the +// .cbi-section that createColorSections keeps around them -- the one wrapper +// that survived the flattening. Thirty automatic tokens are a reference, not +// a control panel: they stay one click away rather than pushing the ten source +// colours off the top of the page. +const enhanceDerivedFold = (root) => { + const derivedRows = Array.from( + root.querySelectorAll('[data-aurora-color-kind="derived"]'), + ); + const sections = new Set( + derivedRows + .map((row) => row.closest(".cbi-section")) + .filter((section) => section && !section.dataset.auroraDerivedFolded), + ); + + sections.forEach((section) => { + section.dataset.auroraDerivedFolded = "true"; + const summary = E("summary", {}, [ + E("span", { class: "aurora-derived-fold-title" }, _("Derived Colors")), + E( + "span", + { class: "aurora-derived-fold-description" }, + _( + "Computed from the source colors; leave a field empty to keep its automatic value.", + ), + ), + ]); + const details = E("details", { class: "aurora-derived-fold" }, [summary]); + section.parentNode.insertBefore(details, section); + details.appendChild(section); + // The SectionValue renders inside a .cbi-value flex row that still holds an + // empty title column; left alone it takes a third of the width off a grid + // that wants all of it. + details.parentElement?.classList.add("aurora-derived-host"); + }); + + // Reshape each derived row into a tile: the swatch leads, the label and the + // input stack beside it. Moving the node is safe -- the colour editor holds + // it by reference and keeps repainting it wherever it lives. + derivedRows.forEach((row) => { + if (row.dataset.auroraTileBuilt) return; + const picker = row.querySelector('input[type="color"]'); + const swatch = picker?.parentElement; + if (!swatch) return; + row.dataset.auroraTileBuilt = "true"; + row.classList.add("aurora-tile"); + swatch.classList.add("aurora-tile-swatch"); + swatch.style.marginLeft = "0"; + row.insertBefore(swatch, row.firstChild); + + // The description is hidden by the grid; keep it reachable on hover. + const description = row.querySelector(".cbi-value-description"); + const text = description?.textContent?.trim(); + if (text) row.title = text; + }); +}; + +const createRangeControlRenderer = (config) => { + return function (option_index, section_id, in_table) { + const self = this; + const el = form.Value.prototype.render.apply(this, [ + option_index, + section_id, + in_table, + ]); + return Promise.resolve(el).then((element) => { + const input = element.querySelector("input"); + if (input) { + input.type = "hidden"; + + const viewportMax = () => + typeof config.max === "function" ? config.max() : Number(config.max); + + const storedNum = parseFloat( + String(input.value || self.default || "").trim(), + ); + const numValue = storedNum || config.default; + const format = (value) => `${value.toFixed(config.precision)}rem`; + + // The ceiling tracks the viewport — widening past it changes nothing on + // screen — but it must never fall below a value that is already stored. + // A config written on a wider display otherwise clamps the slider to + // its own maximum while the readout still shows the larger number, and + // the next drag silently rewrites the setting. + const ceiling = () => + Number.isFinite(storedNum) + ? Math.max(viewportMax(), storedNum) + : viewportMax(); + + const valueDisplay = E( + "span", + { + style: `margin-left: 10px; min-width: ${config.displayWidth}px; display: inline-block;`, + }, + format(numValue), + ); + + const commit = (value) => { + input.value = format(value); + valueDisplay.textContent = input.value; + }; + + const rangeInput = E("input", { + type: "range", + min: config.min.toString(), + max: ceiling().toString(), + step: config.step.toString(), + value: numValue, + style: "width: 200px; vertical-align: middle;", + input: function () { + commit(parseFloat(this.value)); + }, + }); + + if (typeof config.max === "function") { + const handleResize = () => { + const max = ceiling(); + rangeInput.max = max.toString(); + if (parseFloat(rangeInput.value) > max) { + rangeInput.value = max; + commit(max); + } + }; + + window.addEventListener("resize", handleResize); + } + + input.parentNode.appendChild(rangeInput); + input.parentNode.appendChild(valueDisplay); + } + return element; + }); + }; +}; + +const renderSpacingControl = createRangeControlRenderer({ + min: "-0.1", + max: "0.5", + step: "0.05", + default: 0.25, + precision: 2, + displayWidth: 60, +}); + +// Widening past the viewport has no visible effect, so the ceiling tracks the +// current window rather than being an arbitrary constant. +const viewportWidthInRem = () => { + const rootFontSize = parseFloat( + window.getComputedStyle(document.documentElement).fontSize, + ); + const usable = window.innerWidth * (23 / 24); + return Math.max(Math.floor((usable / rootFontSize) * 10) / 10, 80); +}; + +const renderContentWidthControl = createRangeControlRenderer({ + min: "72", + max: viewportWidthInRem, + step: "1", + default: 80, + precision: 1, + displayWidth: 80, +}); + +const renderRadiusControl = createRangeControlRenderer({ + min: "0", + max: "1.5", + step: "0.125", + default: 0.5, + precision: 3, + displayWidth: 70, +}); + +const generateLqip = (source) => + new Promise((resolve) => { + const img = new Image(); + const isBlob = source instanceof Blob; + const url = isBlob ? URL.createObjectURL(source) : source; + const cleanup = () => { + if (isBlob) URL.revokeObjectURL(url); + }; + + img.onload = () => { + const W = 32; + const canvas = document.createElement("canvas"); + canvas.width = W; + canvas.height = Math.round(img.naturalHeight * (W / img.naturalWidth)); + canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); + cleanup(); + canvas.toBlob( + (blob) => { + if (!blob) { + resolve(null); + return; + } + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }, + "image/webp", + 0.1, + ); + }; + img.onerror = () => { + cleanup(); + resolve(null); + }; + img.src = url; + }); + +const toBgUrl = (filename) => + "url('/luci-static/aurora/images/" + filename + "')"; + +const fromBgUrl = (value) => { + if (!value || typeof value !== "string") return ""; + const match = value.match(/\/images\/([^')]+)/); + return match ? match[1] : ""; +}; + +// 正则先绑常量,绝不能让字面量紧跟在箭头后面 —— jsmin 会把它当成除号,压缩 +// 产物直接语法错误。见 docs/DEVELOPMENT.md §11 与 tests/jsmin-safety.test.mjs。 +const IMAGE_NAME_RE = /\.(jpg|jpeg|png|gif|webp|avif|svg|bmp|ico)$/i; +const PNG_NAME_RE = /\.png$/i; +const ICO_NAME_RE = /\.ico$/i; + +const isImageFile = (filename) => IMAGE_NAME_RE.test(filename); + +const makeIconListLoader = ( + filterFn, + { prepend = [], empty = [], valueForIcon = (icon) => icon } = {}, +) => + function (section_id) { + return getIconsOnce().then( + L.bind(function (response) { + const icons = response?.icons || []; + const matches = icons.filter(filterFn); + this.keylist = []; + this.vallist = []; + prepend.forEach(([value, label]) => this.value(value, label)); + if (matches.length > 0) { + matches.forEach((icon) => this.value(valueForIcon(icon), icon)); + } else { + empty.forEach(([value, label]) => this.value(value, label)); + } + return form.ListValue.prototype.load.apply(this, [section_id]); + }, this), + ); + }; + +// Expand the 10 editable inputs into the full resolved token set and stage every +// resulting key (inputs + derived) into UCI so the saved snapshot fully overrides +// the theme's baked _tokens.css defaults. No-op until the engine is loaded and all +// inputs are present, in which case the baked theme defaults remain in effect. +const persistDerivedTokens = (editor) => { + if (!editor) return; + ["light", "dark"].forEach((mode) => { + const resolved = editor.resolvedForMode(mode); + if (!resolved) return; + Object.keys(resolved).forEach((key) => { + uci.set( + "aurora", + "theme", + `${mode}_${key}`, + toRuntimeColor(resolved[key]), + ); + }); + }); +}; + +const runSavePipeline = function (ev, after) { + const save = L.bind(function () { + return colorLibraryReady + .catch(() => {}) + .then(() => this.colorEditor?.flush?.()) + .then(() => this.super("handleSave", [ev])) + .then(() => persistDerivedTokens(this.colorEditor)) + .then(() => uci.save()); + }, this); + const writePwa = () => L.resolveDefault(callWritePwaManifest(), {}); + const cleanup = () => this.colorEditor?.cleanupPreview(); + const handleFailure = (error) => { + cleanup(); + throw error; + }; + const saveReady = + typeof this.prepareAuroraFonts === "function" + ? this.prepareAuroraFonts().then(save) + : save(); + + return saveReady.then(writePwa).then(after).catch(handleFailure); +}; + +return view.extend({ + handleSave: function (ev) { + return runSavePipeline.call(this, ev, () => + this.colorEditor?.cleanupPreview(), + ); + }, + + handleSaveApply: function (ev, mode) { + const apply = () => { + this.colorEditor?.cleanupPreview(); + ui.changes.apply(mode === "0"); + }; + return runSavePipeline.call(this, ev, apply); + }, + + handleReset: function (ev) { + this.colorEditor?.cleanupPreview(); + return this.super("handleReset", [ev]).then(() => { + this.colorEditor?.schedule("light"); + this.colorEditor?.schedule("dark"); + }); + }, + + load: function () { + // colorLibraryReady must settle before the form builds: the token tables + // (COLOR_TOKENS et al.) are joined from the AuroraTokens registry once the + // engine scripts load. A load failure rejects here instead of silently + // rendering a page with no derived-token preview. + return Promise.all([ + uci.load("aurora"), + L.resolveDefault(callGetInitData(), {}), + colorLibraryReady, + // Already fetched and session-cached while LuCI rendered the navigation, + // so this resolves from cache rather than costing a request. + L.resolveDefault(ui.menu.load(), null), + ]).then(([uciData, initData, , menuTree]) => { + // Theme config comes from the uci cache populated by uci.load("aurora") + // above, so no separate theme-config RPC is needed. + const themeConfig = readThemeConfigFromUci(); + const iconsData = { + icons: Array.isArray(initData?.icons) ? initData.icons : [], + icon_sizes: + initData?.icon_sizes && typeof initData.icon_sizes === "object" + ? initData.icon_sizes + : {}, + }; + if (Array.isArray(initData?.icons)) + _iconsPromise = Promise.resolve(iconsData); + + // Preserve the positional layout render() expects: + // [0]=uci [1]={theme} [2]=versions [3]=fonts [4]=icons [5]=preset + // [6]=feed [7]=menu + return [ + uciData, + { theme: themeConfig }, + initData?.versions || {}, + { fonts: initData?.fonts || {} }, + iconsData, + initData?.theme_preset || { result: -1, colors: {} }, + initData?.feed || { pm: "unknown", configured: false, channel: "" }, + menuTree, + ]; + }); + }, + + render(loadData) { + const themeConfig = loadData[1]?.theme || {}; + const installedVersions = loadData[2]; + const fontPresetsBySlot = loadData[3]?.fonts || {}; + const presetColors = loadData[5]?.colors || {}; + const feedStatus = loadData[6] || { + pm: "unknown", + configured: false, + channel: "", + }; + const menuTree = loadData[7]; + this.colorEditor?.destroy(); + const colorEditor = createColorEditor(themeConfig, presetColors); + this.colorEditor = colorEditor; + + // No title: LuCI's tab strip sits directly above this and already reads + // "Design Studio". form.Map renders its title as an

right under that + // strip, so passing one printed the same words twice. description is a + // separate slot in form.js, so the header bar below still renders. + const m = new form.Map("aurora"); + + let so; + const viewCtx = this; + + const FONT_DEFAULT_STACKS = { + sans: '"Lato", ui-sans-serif, system-ui, sans-serif', + mono: 'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace', + }; + + const fontOptionsCache = {}; + const buildFontOptions = (slot) => { + if (fontOptionsCache[slot]) return fontOptionsCache[slot]; + fontOptionsCache[slot] = computeFontOptions(slot); + return fontOptionsCache[slot]; + }; + + const computeFontOptions = (slot) => { + const list = fontPresetsBySlot?.[slot]; + const custom = (fontPresetsBySlot?.custom || []) + .filter((font) => font?.slot === slot && font?.name) + .map((font) => ({ + name: "custom-" + font.name, + label: font.family || font.name, + source: _("Custom"), + family: font.family || "", + stack: font.stack || "", + custom: true, + customName: font.name, + })); + if (Array.isArray(list) && list.length > 0) { + const options = list + .filter((font) => font?.name) + .map((font) => ({ + name: font.name, + label: font.label || font.name, + source: font.source || "", + family: font.family || "", + stack: font.stack || "", + })) + .concat(custom); + if (options.length > 0) return options; + } + // Preset list unavailable (e.g. font-presets.conf unreadable): + // uploaded custom fonts still exist on disk, keep them selectable. + const fallbackStack = FONT_DEFAULT_STACKS[slot] || ""; + if (!fallbackStack) return custom; + return [ + { + name: "default", + label: slot === "sans" ? "Lato" : _("System Mono"), + source: _("Built-in"), + stack: fallbackStack, + }, + ].concat(custom); + }; + + const buildConfigToolbarNode = () => { + // The preset dropdown moved to the Marketplace's "Built-in" group, and + // the store itself is one tabmenu entry away -- a header button for it + // would be a second entrance to the same page. What is left here are the + // config-level actions, which have nowhere else to live. + const exportItem = E( + "li", + { + role: "menuitem", + tabindex: "-1", + click: ui.createHandlerFn(viewCtx, () => { + return L.resolveDefault(callExportConfig(), null) + .then((res) => { + if (!res || res.result !== 0) { + throw new Error(res?.error || _("Export failed")); + } + + const form = E( + "form", + { + method: "post", + action: L.env.cgi_base + "/cgi-download", + enctype: "application/x-www-form-urlencoded", + }, + [ + E("input", { + type: "hidden", + name: "sessionid", + value: rpc.getSessionID(), + }), + E("input", { + type: "hidden", + name: "path", + value: res.path, + }), + E("input", { + type: "hidden", + name: "filename", + value: res.filename || "aurora", + }), + ], + ); + + document.body.appendChild(form); + form.submit(); + form.parentNode.removeChild(form); + + ui.addNotification( + null, + E("p", _("Configuration exported successfully.")), + "info", + ); + }) + .catch((err) => { + ui.addNotification( + null, + E("p", _("Export failed: %s").format(err.message || err)), + "error", + ); + }); + }), + }, + _("Export configuration"), + ); + + const importItem = E( + "li", + { + role: "menuitem", + tabindex: "-1", + click: ui.createHandlerFn(viewCtx, function (ev) { + const btn = ev.currentTarget || ev.target; + const originalLabel = btn?.firstChild?.data; + + return ui + .uploadFile(CONFIG_IMPORT_PATH, btn) + .then( + L.bind(function (res) { + if (!res?.name) + throw new Error(_("No file selected or upload failed")); + if (btn?.firstChild) + btn.firstChild.data = _("Checking file…"); + return fs.read(CONFIG_IMPORT_PATH); + }, this), + ) + .then( + L.bind(function (content) { + const preview = content || ""; + + ui.showModal(_("Import Aurora Configuration?"), [ + E( + "p", + {}, + _( + "Importing replaces /etc/config/aurora, applies all settings, and reloads the page. Uploaded images stay on disk.", + ), + ), + E("pre", {}, preview), + E("div", { class: "right" }, [ + E( + "button", + { + class: "btn", + click: ui.createHandlerFn(this, () => + fs.remove(CONFIG_IMPORT_PATH).finally(ui.hideModal), + ), + }, + _("Cancel"), + ), + " ", + E( + "button", + { + class: "btn cbi-button-action important", + click: ui.createHandlerFn(this, () => { + ui.showModal(_("Importing..."), [ + E("p", { class: "spinning" }, _("Applying...")), + ]); + return L.resolveDefault( + callImportConfig(), + {}, + ).then((ret) => { + ui.hideModal(); + if (ret?.result === 0) { + ui.addNotification( + null, + E( + "p", + _("Configuration imported successfully."), + ), + "info", + ); + window.location.reload(); + } else { + const errorMsg = ret?.error || _("Unknown error"); + ui.addNotification( + null, + E( + "p", + _("Import failed: %s").format(errorMsg), + ), + "error", + ); + } + }); + }), + }, + _("Import"), + ), + ]), + ]); + }, this), + ) + .catch((err) => { + ui.addNotification( + null, + E("p", _("Import failed: %s").format(err.message || err)), + "error", + ); + return L.resolveDefault(fs.remove(CONFIG_IMPORT_PATH), {}); + }) + .finally(() => { + if (btn?.firstChild && originalLabel !== undefined) + btn.firstChild.data = originalLabel; + }); + }), + }, + _("Import configuration"), + ); + + const resetItem = E( + "li", + { + role: "menuitem", + tabindex: "-1", + class: "danger", + click: ui.createHandlerFn(viewCtx, () => { + return ui.showModal(_("Reset All Aurora Settings"), [ + E( + "p", + {}, + _( + "Reset /etc/config/aurora to the packaged Default preset — colors, layout, typography, branding, navigation, and toolbar. Uploaded images stay on disk; custom selections not in the Default preset are cleared.", + ), + ), + E("div", { class: "right" }, [ + E("button", { class: "btn", click: ui.hideModal }, _("Cancel")), + " ", + E( + "button", + { + class: "btn cbi-button-negative", + click: () => { + ui.showModal(_("Resetting..."), [ + E("p", { class: "spinning" }, _("Restoring...")), + ]); + return L.resolveDefault(callResetDefaults(), {}).then( + (ret) => { + ui.hideModal(); + if (ret?.result === 0) { + window.location.reload(); + ui.addNotification( + null, + E("p", _("Settings reset successfully.")), + "info", + ); + } else { + ui.addNotification( + null, + E( + "p", + _("Error: %s").format( + ret?.error || _("Unknown"), + ), + ), + "error", + ); + } + }, + ); + }, + }, + _("Reset"), + ), + ]), + ]); + }), + }, + _("Reset everything to defaults"), + ); + + // 发布的起点在这里,不在商店。这一排本来就是对"整套配置"动手的地方 —— + // 导出是存给自己,分享是发给别人,同一族。商店那边原先有三个入口同时在 + // 场(页头按钮、面板标题、空态卡按钮),说的都是同一句话;它们一起删掉, + // 商店从此只干两件事:逛别人的、管自己的。 + const shareButton = E( + "button", + { + class: "cbi-button cbi-button-action", + click: () => { + window.location.href = + L.url("admin/system/aurora/marketplace") + "?share=1"; + }, + }, + _("Share to the store"), + ); + + return E("div", { class: "aurora-config-toolbar aurora-studio-acts" }, [ + shareButton, + buildMoreMenu([exportItem, importItem, E("li", { role: "separator", class: "sep" }), resetItem]), + ]); + }; + + // 外壳是 LuCI 的 .cbi-dropdown:开合状态就是它的 [open] 属性,浮层就是它的 + // ul.dropdown,所以每个主题都按自己的下拉样式画这张菜单。 + const buildMoreMenu = (items) => { + const menu = E( + "ul", + { class: "dropdown", role: "menu", id: "aurora-studio-more-menu", tabindex: "-1" }, + items, + ); + const toggle = E( + "div", + { + class: "cbi-dropdown cbi-button aurora-studio-more", + role: "button", + tabindex: "0", + title: _("More"), + "aria-label": _("More"), + "aria-haspopup": "menu", + "aria-expanded": "false", + "aria-controls": menu.id, + }, + [E("span", { "aria-hidden": "true" }, "⋯"), menu], + ); + const entries = () => Array.from(menu.querySelectorAll('[role="menuitem"]')); + + const onOutside = (ev) => { + if (!toggle.contains(ev.target)) close(false); + }; + const close = (refocus) => { + toggle.removeAttribute("open"); + toggle.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", onOutside); + if (refocus) toggle.focus(); + }; + const open = () => { + toggle.setAttribute("open", ""); + toggle.setAttribute("aria-expanded", "true"); + document.addEventListener("click", onOutside); + entries()[0].focus(); + }; + + toggle.addEventListener("click", (ev) => { + if (menu.contains(ev.target)) close(false); + else if (toggle.hasAttribute("open")) close(true); + else open(); + }); + + toggle.addEventListener("keydown", (ev) => { + const isOpen = toggle.hasAttribute("open"); + const activate = ev.key === "Enter" || ev.key === " "; + if (ev.key === "Escape" && isOpen) close(true); + else if (ev.key === "Tab") close(false); + else if (!isOpen && (activate || ev.key === "ArrowDown")) open(); + else if (isOpen && activate && menu.contains(ev.target)) ev.target.click(); + else if (isOpen && ["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) { + const all = entries(); + const at = all.indexOf(document.activeElement); + const next = + ev.key === "Home" + ? 0 + : ev.key === "End" + ? all.length - 1 + : (at + (ev.key === "ArrowDown" ? 1 : -1) + all.length) % all.length; + all[next].focus(); + } else return; + if (ev.key !== "Tab") ev.preventDefault(); + }); + + return toggle; + }; + + // Named node on purpose: once the feed manifest has been compared, each + // package's update is attached to that package's own entry in this line. + // 颜色全交给主题的 .label:当前是 success,有新版时换成 warning。 + const versionEntry = (attrs, label, installed) => + E("span", attrs, [ + E("b", {}, label), + E( + "span", + { class: installed ? "label success" : "label" }, + installed || _("Unknown"), + ), + ]); + + const versionArea = E("div", { class: "aurora-studio-versions" }, [ + versionEntry({ id: "theme-version" }, _("Theme"), installedVersions?.theme?.installed_version), + versionEntry({ id: "config-version" }, _("Config"), installedVersions?.config?.installed_version), + ]); + + ensureToolbarStyles(); + const headerBar = E("div", { class: "aurora-studio-head" }, [ + versionArea, + buildConfigToolbarNode(), + ]); + + m.description = headerBar; + + // ------------------------------------------------------------------ + // Update check. A pure read that goes straight from the browser to the + // CDN, so the router takes no part. Adding the update source itself lives + // in the Marketplace inbox. + + const packagePagePath = feedCheck.pickPackageManagerPath(menuTree); + + const checkForUpdates = () => { + // Not configured means there is nothing to compare against, so no + // request goes out at all. + if (!feedStatus.configured) return Promise.resolve(null); + + const cached = sessionStorage.getItem(MANIFEST_CACHE_KEY); + if (cached) { + try { + return Promise.resolve(JSON.parse(cached)); + } catch (error) { + sessionStorage.removeItem(MANIFEST_CACHE_KEY); + } + } + + return fetch(MANIFEST_URL, { credentials: "omit" }) + .then((response) => (response.ok ? response.json() : null)) + .then((manifest) => { + if (manifest) + sessionStorage.setItem( + MANIFEST_CACHE_KEY, + JSON.stringify(manifest), + ); + return manifest; + }) + // Offline, feed unreachable, CORS not deployed yet -- all the same + // outcome: say nothing. A settings page has no business raising an + // error because it could not reach an update server. + .catch(() => null); + }; + + // 两个包各判各的,更新挂在它自己那一项后面。isNewer 判不出来就什么都不说, + // 所以没有更新时这一行不出现任何强调色。 + const showUpdateCapsule = (manifest) => { + if (!manifest) return; + const channel = feedStatus.channel || "snapshots"; + const format = feedStatus.pm === "opkg" ? "opkg" : "apk"; + const packages = [ + ["luci-theme-aurora", "theme-version", installedVersions?.theme?.installed_version], + ["luci-app-aurora-config", "config-version", installedVersions?.config?.installed_version], + ]; + + packages.forEach(([pkg, id, installed]) => { + const available = feedCheck.findManifestVersion( + manifest, + channel, + format, + pkg, + ); + if (!feedCheck.isNewer(installed, available)) return; + const entry = versionArea.querySelector("#" + id); + if (!entry || entry.classList.contains("up")) return; + const label = _("%s available").format(available); + entry.classList.add("up"); + entry.querySelector(".label").className = "label warning"; + entry.appendChild(E("span", { class: "arrow", "aria-hidden": "true" }, "→")); + entry.appendChild( + packagePagePath + ? E("a", { href: L.url(packagePagePath) }, [label]) + : E("span", {}, [label]), + ); + }); + }; + + const s = m.section(form.NamedSection, "theme", "aurora"); + + s.tab("colors", _("Colors")); + s.tab("layout_typography", _("Layout & Typography")); + s.tab("icons_branding", _("Branding & Shortcuts")); + + const colorSection = s.taboption( + "colors", + form.SectionValue, + "_colors", + form.NamedSection, + "theme", + "aurora", + ); + const colorSubsection = colorSection.subsection; + colorSubsection.tab("light", _("Light Mode"), COLOR_TAB_HINT); + colorSubsection.tab("dark", _("Dark Mode"), COLOR_TAB_HINT); + + createColorSections(colorSubsection, "light", colorEditor); + createColorSections(colorSubsection, "dark", colorEditor); + + const structureSection = s.taboption( + "layout_typography", + form.SectionValue, + "_structure_layout", + form.NamedSection, + "theme", + "aurora", + _("Layout"), + _( + "Navigation, spacing, corner radius, and content width. Takes effect after Save & Apply.", + ), + ); + const structureSubsection = structureSection.subsection; + + so = structureSubsection.option( + form.ListValue, + "nav_type", + _("Navigation Style"), + ); + so.description = _("Layout pattern for the primary navigation menu."); + so.value("mega-menu", _("Mega Menu")); + so.value("dropdown", _("Dropdown")); + so.value("sidebar", _("Sidebar")); + so.default = "mega-menu"; + so.rmempty = false; + // Still a ListValue: struct_content_width_centered depends() on this, and + // LuCI's dependency tracking rides on the standard widget's change event. + // A hand-rolled control would make parse() drop the width on save -- see + // the retain comment below. So the radios stay, and only get a picture. + so.widget = "radio"; + so.renderWidget = renderNavChoiceWidget; + + so = structureSubsection.option( + form.Value, + "struct_spacing", + _("Spacing Scale"), + ); + so.description = _( + "Base spacing unit that scales padding and gaps across the interface.", + ); + so.default = "0.25rem"; + so.placeholder = "0.25rem"; + so.rmempty = false; + so.render = renderSpacingControl; + + so = structureSubsection.option( + form.Value, + "struct_radius_base", + _("Corner Radius"), + ); + so.description = _( + "Base corner radius applied to buttons, inputs, cards, and surfaces.", + ); + so.default = "0.5rem"; + so.placeholder = "0.5rem"; + so.rmempty = false; + so.render = renderRadiusControl; + + // Centred layouts only. The sidebar shell is asymmetric — its first column + // already acts as the margin — so the theme runs it full-width and reads no + // cap; offering this control there would be a dead knob. + so = structureSubsection.option( + form.Value, + "struct_content_width_centered", + _("Content Max Width"), + ); + so.description = _("Maximum width of the centered content area."); + so.default = "80rem"; + so.placeholder = "80rem"; + so.rmempty = false; + so.render = renderContentWidthControl; + // Required alongside depends: LuCI's parse() deletes any option whose + // depends are unsatisfied at save time (form.js CBIAbstractValue.parse), so + // without retain, saving anything while the Sidebar layout is selected + // silently drops this width entirely. + so.retain = true; + so.depends("nav_type", "mega-menu"); + so.depends("nav_type", "dropdown"); + + const fontSection = s.taboption( + "layout_typography", + form.SectionValue, + "_font_settings", + form.NamedSection, + "theme", + "aurora", + _("Typography"), + _( + "Sans-serif and monospace typefaces used across the theme. Pick a curated webfont — downloaded once after saving, from pinned, checksum-verified sources, then served locally — or upload your own .woff2 files.", + ), + ); + const fontSubsection = fontSection.subsection; + + const fontTableSo = fontSubsection.option( + form.DummyValue, + "_font_table", + _("Custom Fonts"), + ); + fontTableSo.rawhtml = false; + fontTableSo.cfgvalue = () => ""; + // renderWidget (not render): LuCI's renderFrame() then wraps the manager + // in the standard cbi-value label/field row, matching the typeface + // selects below. The brand-asset manager overrides render() instead -- + // its section holds only the table, so it stays full-width. + fontTableSo.renderWidget = () => { + const FONT_TMP_PATH = "/tmp/aurora_font.tmp"; + const customs = fontPresetsBySlot?.custom || []; + + // 同 IMAGE_NAME_RE 上方的说明:正则不能紧跟在箭头后面。 + const LOWER_START_RE = /^[a-z]/; + + const familyFromFilename = (name) => { + const stem = name.replace(/\.woff2$/i, ""); + const parts = stem + .split(/[-_]+/) + .filter( + (p) => + !/^(regular|bold|light|medium|semibold|thin|italic|normal|latin|\d+)$/i.test( + p, + ), + ); + const words = parts.length ? parts : [stem]; + return words + .map((w) => + LOWER_START_RE.test(w) ? w.charAt(0).toUpperCase() + w.slice(1) : w, + ) + .join(" "); + }; + + const removeFont = (font) => + assetUpload.confirmDelete({ + title: _("Delete Custom Font"), + message: _( + "Delete '%s'? The interface will fall back to the default typeface.", + ).format(font.family), + }).then((confirmed) => { + if (!confirmed) return; + return L.resolveDefault( + callRemoveFont(font.slot, font.name), + {}, + ).then((ret) => { + if (ret?.result === 0) window.location.reload(); + else + ui.addNotification( + null, + E( + "p", + _("Delete failed: %s").format(ret?.error || _("Unknown")), + ), + "error", + ); + }); + }); + + const fontManager = assetUpload.createAssetManager({ + badgeHeader: _("Slot"), + emptyText: _("No custom fonts uploaded."), + rows: customs.map((font) => ({ + preview: E( + "div", + { + style: + "width:34px;height:34px;border-radius:0.4em;" + + "background:var(--surface-sunken);border:1px solid var(--hairline);" + + "display:flex;align-items:center;justify-content:center;" + + "font-weight:700;font-size:0.95em;" + + "font-family:" + + (font.slot === "mono" ? "var(--font-mono)" : "var(--font-sans)") + + ";", + }, + "Aa", + ), + name: font.family, + badge: font.slot === "sans" ? _("Sans-Serif") : _("Monospace"), + size: font.size || 0, + onDelete: ui.createHandlerFn(viewCtx, () => removeFont(font)), + })), + bar: { + hint: _("Drop a .woff2 font here, or click to browse"), + sub: _("Only .woff2 files up to 8MB are accepted."), + accept: ".woff2", + }, + checkFile: (file) => assetUpload.checkFile(file, { exts: ["woff2"] }), + form: { + fields: (file) => { + const nameInput = E("input", { + type: "text", + class: "cbi-input-text", + placeholder: _("Font family name, e.g. MiSans"), + value: familyFromFilename(file.name), + }); + const slotSelect = E( + "select", + { class: "cbi-input-select" }, + [ + E("option", { value: "sans" }, _("Sans-Serif")), + E("option", { value: "mono" }, _("Monospace")), + ], + ); + return { + rows: [ + { label: _("Name"), control: nameInput }, + { label: _("Slot"), control: slotSelect }, + ], + value: () => ({ + name: nameInput.value.trim(), + slot: slotSelect.value, + }), + valid: () => !!nameInput.value.trim(), + setDisabled: (disabled) => { + nameInput.disabled = disabled; + slotSelect.disabled = disabled; + }, + }; + }, + }, + upload: (file, meta, onProgress) => + assetUpload + .uploadToRouter({ tmpPath: FONT_TMP_PATH, file, onProgress }) + .then(() => + L.resolveDefault(callUploadFont(meta.slot, meta.name), {}), + ) + .then((ret) => { + if (ret?.result === 0) window.location.reload(); + else throw new Error(ret?.error || _("Unknown")); + }), + }); + + return E("div", {}, [ + fontManager, + E( + "p", + { style: "opacity:0.6;font-size:0.9em;margin:0.35em 0 0;" }, + _( + "Keep custom font files as small as possible, as they are stored in the router's limited flash storage.", + ), + ), + ]); + }; + + const fontSlotOpts = {}; + + const findFontByPreset = (slot, preset) => + buildFontOptions(slot).find((font) => font.name === preset); + + const findFontByStack = (slot, stack) => + buildFontOptions(slot).find((font) => font.stack === stack); + + const getDefaultFont = (slot) => + findFontByPreset(slot, "default") || buildFontOptions(slot)[0]; + + const addFontSlot = (ss, slot) => { + const options = buildFontOptions(slot); + const stackKey = "struct_font_" + slot; + const defaultFont = getDefaultFont(slot); + + const presetOpt = ss.option( + form.ListValue, + stackKey, + slot === "sans" ? _("Sans-Serif Typeface") : _("Monospace Typeface"), + ); + presetOpt.description = + slot === "sans" + ? _( + "Primary font for all interface text — headings, body, menus, forms, and tables.", + ) + : _("Font for code, command output, and the system log viewer."); + presetOpt.default = themeConfig[stackKey] || defaultFont?.stack || ""; + presetOpt.rmempty = false; + options.forEach((font) => { + if (font.stack) { + presetOpt.value( + font.stack, + font.source + ? "%s (%s)".format(font.label, font.source) + : font.label, + ); + } + }); + fontSlotOpts[slot] = presetOpt; + }; + + addFontSlot(fontSubsection, "sans"); + addFontSlot(fontSubsection, "mono"); + + const getFontSelection = (slot) => { + const value = + (fontSlotOpts[slot] && fontSlotOpts[slot].formvalue("theme")) || + getDefaultFont(slot)?.stack || + ""; + const font = findFontByStack(slot, value) || + getDefaultFont(slot) || { name: "default" }; + + return { + preset: font.custom ? "default" : font.name || "default", + stack: font.stack || value, + }; + }; + + const getSelectedFonts = () => { + const sans = getFontSelection("sans"); + const mono = getFontSelection("mono"); + + return { + sans: sans.preset, + mono: mono.preset, + sansStack: sans.stack, + monoStack: mono.stack, + }; + }; + + const applyFontCss = (selected) => { + return fetch( + "/luci-static/aurora/fonts/aurora-font.css?v=" + Date.now(), + { cache: "no-store" }, + ) + .then((r) => { + if (!r.ok) throw new Error(_("Font CSS file is not available")); + return r.text(); + }) + .then((css) => { + if (!css) throw new Error(_("Font CSS file is empty")); + + let styleEl = document.getElementById("aurora-preview-fonts"); + if (!styleEl) { + styleEl = document.createElement("style"); + styleEl.id = "aurora-preview-fonts"; + document.head.appendChild(styleEl); + } + styleEl.textContent = css; + + if (selected.sansStack) { + document.documentElement.style.setProperty( + "--font-sans", + selected.sansStack, + ); + } + if (selected.monoStack) { + document.documentElement.style.setProperty( + "--font-mono", + selected.monoStack, + ); + } + }); + }; + + const pollFontCache = (jobId, remaining, selected) => { + if (!jobId || remaining <= 0) return; + + window.setTimeout(() => { + L.resolveDefault(callGetFontStatus(jobId), {}).then((status) => { + if (status && status.state === "done") { + const current = getSelectedFonts(); + if ( + current.sans === selected.sans && + current.mono === selected.mono + ) { + if ( + status.sans_status === "fallback" || + status.mono_status === "fallback" + ) { + ui.addNotification( + null, + E( + "p", + _( + "Typeface download failed after retries — the system font stack is in effect. Save again to retry.", + ), + ), + "warning", + ); + } + applyFontCss(selected); + } + } else if (status && status.state !== "missing") { + pollFontCache(jobId, remaining - 1, selected); + } + }); + }, 1500); + }; + + const prepareSelectedFonts = () => { + const selected = getSelectedFonts(); + const statusNode = E( + "p", + { class: "spinning" }, + _("Preparing selected typefaces..."), + ); + + ui.showModal(_("Preparing Typography"), [statusNode]); + + return callPrepareFont(selected.sans, selected.mono, selected.sansStack) + .then((res) => { + if (!res || res.result !== 0) { + throw new Error( + res?.result === -1 + ? _("The router did not respond. Please try again.") + : res?.error || _("Unknown error"), + ); + } + + pollFontCache(res.job_id, 20, selected); + + return applyFontCss(selected); + }) + .then(() => { + ui.hideModal(); + }) + .catch((err) => { + ui.hideModal(); + ui.addNotification( + null, + E( + "p", + _("Typography preparation failed: ") + + (err.message || String(err)), + ), + "warning", + ); + return Promise.reject(err); + }); + }; + + this.prepareAuroraFonts = prepareSelectedFonts; + + const assetSection = s.taboption( + "icons_branding", + form.SectionValue, + "_asset_library", + form.NamedSection, + "theme", + "aurora", + _("Brand Asset Library"), + _( + "Upload and manage images for icons, favicons, PWA assets, and page backgrounds. Files are stored in /www/luci-static/aurora/images/.", + ), + ); + const assetSubsection = assetSection.subsection; + + const assetTableSo = assetSubsection.option( + form.DummyValue, + "_asset_table", + " ", + ); + assetTableSo.load = () => getIconsOnce(); + assetTableSo.cfgvalue = (section_id, data) => data?.icons || []; + // AVIF and GIF are gone: no asset slot can use them, so accepting them + // only bought a theme you could build locally and never share. + const ICON_EXTS = ["jpg", "jpeg", "png", "webp", "svg", "ico"]; + + // render (not renderWidget): full-width mount, no cbi-value label row -- + // this section holds only the asset table. The font manager in the + // Typography section uses renderWidget for the labeled-row wrapper. + assetTableSo.render = function (option_index, section_id, in_table) { + return this.load(section_id).then((data) => { + const icons = this.cfgvalue(section_id, data); + const sizes = data?.icon_sizes || {}; + const tmpPath = "/tmp/aurora_icon.tmp"; + + const idleCallback = window.requestIdleCallback + ? (fn) => window.requestIdleCallback(fn, { timeout: 2000 }) + : (fn) => setTimeout(fn, 100); + + const makePreview = (icon) => { + const placeholder = E("div", { + style: + "width:34px;height:34px;border-radius:0.4em;" + + "background:var(--surface-sunken);border:1px solid var(--hairline);", + }); + idleCallback(() => { + generateLqip("/luci-static/aurora/images/" + icon).then( + (dataUrl) => { + if (!dataUrl) return; + placeholder.replaceWith( + E("img", { + src: dataUrl, + style: + "width:34px;height:34px;object-fit:cover;" + + "border-radius:0.4em;display:block;", + alt: "", + }), + ); + }, + ); + }); + return placeholder; + }; + + const removeIcon = (icon) => + assetUpload.confirmDelete({ + title: _("Delete Brand Asset"), + message: _( + "Delete '%s' from /www/luci-static/aurora/images/? Theme settings that reference it may need updating.", + ).format(icon), + }).then((confirmed) => { + if (!confirmed) return; + ui.showModal(_("Deleting…"), [ + E("p", { class: "spinning" }, _("Please wait…")), + ]); + return L.resolveDefault(callRemoveIcon(icon), {}).then((ret) => { + ui.hideModal(); + if (ret?.result === 0) { + ui.addNotification( + null, + E("p", _("Deleted: %s").format(icon)), + ); + window.location.reload(); + } else { + ui.addNotification( + null, + E( + "p", + _("Failed to delete: %s").format( + ret?.error || _("Unknown"), + ), + ), + "error", + ); + } + }); + }); + + const iconManager = assetUpload.createAssetManager({ + badgeHeader: _("Type"), + emptyText: _("No brand assets uploaded yet."), + rows: icons.map((icon) => ({ + preview: makePreview(icon), + name: icon, + badge: assetUpload.extOf(icon).toUpperCase(), + size: sizes[icon] || 0, + onDelete: ui.createHandlerFn(this, () => removeIcon(icon)), + })), + bar: { + hint: _("Drop image asset here, or click to browse"), + sub: _("JPG · PNG · WebP · SVG · ICO · up to 8 MB each"), + accept: ".jpg,.jpeg,.png,.webp,.svg,.ico", + }, + checkFile: (file) => + assetUpload.checkFile(file, { + exts: ICON_EXTS, + }), + form: { + fields: (file) => { + const nameInput = E("input", { + type: "text", + class: "cbi-input-text", + value: file.name, + }); + const collisionWarning = E( + "p", + { + style: + "color:var(--warning);font-size:0.9em;" + + "margin:0.35em 0 0;display:none;", + }, + "", + ); + const updateCollisionWarning = () => { + const v = nameInput.value.trim(); + if (v && icons.includes(v)) { + collisionWarning.textContent = _( + "Will replace the existing '%s'.", + ).format(v); + collisionWarning.style.display = ""; + } else { + collisionWarning.style.display = "none"; + } + }; + nameInput.addEventListener("input", updateCollisionWarning); + updateCollisionWarning(); + const noteEl = E( + "div", + { + style: + "font-size:0.82em;color:var(--text-muted);" + + "opacity:0.8;margin-top:0.35em;", + }, + _( + "Name it login-bg.* to use it as the login page background.", + ), + ); + return { + rows: [ + { + label: _("Filename (with extension)"), + control: E("div", {}, [ + nameInput, + collisionWarning, + noteEl, + ]), + }, + ], + value: () => ({ name: nameInput.value.trim() }), + valid: () => { + const v = nameInput.value.trim(); + return ( + !!v && + !v.includes("/") && + !v.includes("..") && + ICON_EXTS.includes(assetUpload.extOf(v)) + ); + }, + setDisabled: (disabled) => { + nameInput.disabled = disabled; + }, + }; + }, + }, + upload: (file, meta, onProgress) => + assetUpload + .uploadToRouter({ tmpPath, file, onProgress }) + .then(() => L.resolveDefault(callUploadIcon(meta.name), {})) + .then((ret) => { + if (ret?.result === 0) { + if (/^login-bg\./i.test(meta.name)) { + localStorage.setItem("aurora.pending_bg", meta.name); + } + window.location.reload(); + } else { + throw new Error(ret?.error || _("Unknown")); + } + }), + }); + + return E("div", { "data-name": this.option }, [iconManager]); + }); + }; + + // ── 页面背景组件(B 版设计)──────────────────────────────────────── + // 选图下拉 + 迷你实时预览 + 可选滑杆组,登录背景与主界面背景共用这一份 + // 实现——第二份手抄的 LQIP/预览逻辑就是下一处 drift。滑杆的真实数据源 + // 是隐藏的 form.Value 字段(LuCI 保存管线原样工作),滑杆只是它们的可视 + // 外壳;重置 = 清空字段不写键,主题的 var() fallback 默认生效。 + + // 迷你预览:页面缩影(图层/遮罩/画布/卡片/磨砂顶栏或登录卡),颜色全部 + // 取当前主题 token,亮暗模式自动跟随。 + const buildBgPreview = (previewKind) => { + const layer = (style) => E("div", { style: "position:absolute;" + style }); + const img = layer("inset:0;background-size:cover;background-position:center;"); + const hint = layer( + "inset:0;display:flex;align-items:center;justify-content:center;" + + "color:var(--bgp-subtle);font-size:.85em;", + ); + hint.textContent = _("No background selected"); + const parts = [img, hint]; + + let scrim, canvas, card, topbar, loginCard; + if (previewKind === "admin") { + scrim = layer("inset:0;background:var(--bgp-bg);"); + canvas = layer( + "inset:27% 5% 5% 5%;border-radius:8px;" + + "background:color-mix(in srgb, var(--bgp-bg) 55%, transparent);", + ); + card = layer( + "left:13%;right:13%;top:40%;border-radius:8px;padding:8px 12px;" + + "font-size:11px;border:1px solid var(--bgp-line);color:var(--bgp-text);", + ); + card.textContent = "Aa 123 · OpenWrt"; + topbar = layer( + "top:0;left:0;right:0;height:30px;display:flex;align-items:center;" + + "gap:8px;padding:0 10px;font-size:10px;color:var(--bgp-text);" + + "border-bottom:1px solid var(--bgp-line);", + ); + topbar.textContent = "☰ OpenWrt"; + parts.push(scrim, canvas, card, topbar); + } else { + scrim = layer("inset:0;background:var(--bgp-bg);opacity:0;"); + parts.push(scrim); + loginCard = layer( + "left:27%;right:27%;top:22%;bottom:22%;border-radius:10px;" + + "background:var(--bgp-surface);border:1px solid var(--bgp-line);" + + "box-shadow:0 8px 24px #0004;padding:10px 12px;color:var(--bgp-text);" + + "font-size:11px;", + ); + loginCard.appendChild(E("div", {}, "Aa 123")); + ["", ""].forEach(() => + loginCard.appendChild( + E("div", { + style: + "height:9px;margin-top:6px;border-radius:5px;" + + "background:var(--bgp-control);border:1px solid var(--bgp-line);", + }), + ), + ); + parts.push(loginCard); + } + + const el = E( + "div", + { class: "bg-preview" }, + parts, + ); + + return { + el, + setImage(url) { + img.style.backgroundImage = url ? 'url("' + url + '")' : ""; + hint.style.display = url ? "none" : "flex"; + }, + setVals(v) { + scrim.style.opacity = String(v.scrim / 100); + if (previewKind === "admin") { + topbar.style.background = + "color-mix(in srgb, var(--bgp-bg) " + v.alpha + "%, transparent)"; + topbar.style.backdropFilter = topbar.style.webkitBackdropFilter = + "blur(" + v.blur + "px) saturate(150%)"; + card.style.background = + "color-mix(in srgb, var(--bgp-surface) " + + v.alpha + + "%, transparent)"; + return; + } + loginCard.style.background = + "color-mix(in srgb, var(--bgp-surface) " + v.alpha + "%, transparent)"; + loginCard.style.backdropFilter = + loginCard.style.webkitBackdropFilter = + "blur(" + v.blur + "px) saturate(150%)"; + }, + }; + }; + + const addBackgroundOption = ( + section, + { key, lqipKey, label, heading, previewKind, tunables, withUpload }, + ) => { + // 滑杆的隐藏数据字段。存进 uci 的是带单位的 CSS 值(67% / 20px)。刻意 + // 不用 depends(条件不满足时保存会静默删键,见 LuCI depends/retain 陷阱)。 + (tunables || []).forEach(([tkey, , unit, min, max]) => { + const tuneSo = section.option(form.Value, tkey, ""); + tuneSo.rmempty = true; + tuneSo.cfgvalue = function (section_id) { + const raw = uci.get("aurora", section_id, tkey) || ""; + return raw.endsWith(unit) ? raw.slice(0, -unit.length) : raw; + }; + tuneSo.validate = function (section_id, value) { + const v = (value || "").trim(); + if (!v) return true; + if (!/^\d{1,3}$/.test(v) || +v < min || +v > max) + return _("Enter a number between %d and %d").format(min, max); + return true; + }; + tuneSo.write = function (section_id, value) { + const v = (value || "").trim(); + if (!v) { + uci.unset("aurora", section_id, tkey); + return; + } + uci.set("aurora", section_id, tkey, v + unit); + }; + tuneSo.render = function () { + return form.Value.prototype.render.apply(this, arguments).then((el) => { + el.style.display = "none"; + return el; + }); + }; + }); + let bgSo = section.option(form.ListValue, key, label); + bgSo.rmempty = true; + bgSo.load = makeIconListLoader( + (icon) => isImageFile(icon) && !icon.endsWith(".svg"), + { + prepend: [["", _("None")]], + valueForIcon: toBgUrl, + }, + ); + bgSo.cfgvalue = function (section_id) { + return uci.get("aurora", section_id, key) || ""; + }; + bgSo.write = function (section_id, value) { + if (!value) { + uci.unset("aurora", section_id, key); + uci.unset("aurora", section_id, lqipKey); + return; + } + uci.set("aurora", section_id, key, value); + }; + + const _renderBg = bgSo.render.bind(bgSo); + bgSo.render = function (option_index, section_id, in_table) { + ensureBgCardStyles(); + return _renderBg(option_index, section_id, in_table).then((el) => { + el.dataset.bgTarget = key; + const field = el.querySelector(".cbi-value-field") || el; + const select = el.querySelector("select"); + // 卡头:标题在左,LuCI 渲染好的选图控件整体迁到右侧 + const widget = field.firstElementChild; + const head = E("div", { class: "bg-card-head" }, [ + E("span", { class: "bg-card-title" }, heading), + ]); + field.insertBefore(head, field.firstChild); + if (widget) head.appendChild(widget); + if (select) + select.addEventListener("click", (e) => e.stopPropagation()); + const preview = buildBgPreview(previewKind); + field.appendChild(preview.el); + if (withUpload) { + // 规矩写在挑图的地方,而不是等传上配置广场被退回来才知道。 + field.appendChild( + E( + "div", + { style: "color:var(--text-muted);font-size:0.85em;margin-top:0.4em;" }, + _("PNG · JPG · WebP · up to 8 MB"), + ), + ); + } + + // 就地上传(withUpload 的实例才有):按钮 + 直接把图拖进预览框, + // 复用资产库同一条上传管线;成功后带着 pending 归属键整页刷新, + // 列表随 reload 重新拉取并自动选中刚传的图。统一管理归置顶的资产 + // 库,这里不铺缩略图——画廊会把每张原图都拉一遍,和库的预览列 + // 重复加载。 + if (withUpload) { + const uploadBg = (file) => { + const check = assetUpload.checkFile(file, { + exts: ["jpg", "jpeg", "png", "webp"], + }); + if (!check.ok) { + ui.addNotification(null, E("p", check.err), "error"); + return; + } + assetUpload + .uploadToRouter({ tmpPath: "/tmp/aurora_icon.tmp", file }) + .then(() => L.resolveDefault(callUploadIcon(file.name), {})) + .then((ret) => { + if (ret?.result === 0) { + localStorage.setItem("aurora.pending_bg", file.name); + localStorage.setItem("aurora.pending_bg_key", key); + window.location.reload(); + } else { + throw new Error(ret?.error || _("Unknown")); + } + }) + .catch((err) => + ui.addNotification( + null, + E("p", _("Upload failed: %s").format(err.message)), + "error", + ), + ); + }; + preview.el.addEventListener("dragover", (e) => { + e.preventDefault(); + preview.el.style.outline = "2px dashed var(--bgp-brand)"; + }); + preview.el.addEventListener("dragleave", () => { + preview.el.style.outline = ""; + }); + preview.el.addEventListener("drop", (e) => { + e.preventDefault(); + preview.el.style.outline = ""; + const f = e.dataTransfer && e.dataTransfer.files[0]; + if (f) uploadBg(f); + }); + } + + const urlOf = (value) => { + const m = (value || "").match(/url\(["']?(.+?)["']?\)/); + return m ? m[1] : ""; + }; + const vals = {}; + // 角色由键名后缀推断(_alpha/_blur/_scrim),login/main 两组键共用 + // 同一段预览联动;无滑杆的实例落到中性默认(不透明、无模糊、无遮罩)。 + const paneDiv = E("div", { "data-bg-pane": key }); + const roleVals = () => { + const out = { alpha: 100, blur: 0, scrim: 0 }; + (tunables || []).forEach(([tkey, , , , , def]) => { + const role = tkey.endsWith("_alpha") + ? "alpha" + : tkey.endsWith("_blur") + ? "blur" + : "scrim"; + out[role] = vals[tkey] !== undefined ? vals[tkey] : +def; + }); + return out; + }; + const refresh = () => { + preview.setImage(urlOf(select && select.value)); + preview.setVals(roleVals()); + }; + + (tunables || []).forEach(([tkey, tlabel, unit, min, max, def]) => { + const raw = uci.get("aurora", "theme", tkey) || ""; + const parsed = raw.endsWith(unit) ? raw.slice(0, -unit.length) : raw; + vals[tkey] = parsed === "" ? +def : +parsed; + const valEl = E("output", {}, vals[tkey] + unit); + const slider = E("input", { + type: "range", + min: String(min), + max: String(max), + value: String(vals[tkey]), + "data-tunable": tkey, + }); + slider.addEventListener("input", () => { + vals[tkey] = +slider.value; + valEl.textContent = slider.value + unit; + const hid = themeFormField(tkey); + if (hid) { + // 拖回默认值 = 回到"未设置":不写键,主题 fallback 接管。 + // 这也是砍掉"恢复默认"按钮的底气——默认位置就是重置。 + hid.value = +slider.value === +def ? "" : slider.value; + hid.dispatchEvent(new Event("change", { bubbles: true })); + } + refresh(); + }); + paneDiv.appendChild( + E("div", { class: "bg-srow" }, [ + E("label", {}, tlabel), + slider, + valEl, + ]), + ); + }); + + + if ((tunables || []).length) field.appendChild(paneDiv); + + if (select) { + select.addEventListener("change", function () { + refresh(); + const lqipEl = themeFormField(lqipKey); + if (!this.value) { + if (lqipEl) lqipEl.value = ""; + return; + } + const m = this.value.match(/url\(["']?(.+?)["']?\)/); + if (!m || !lqipEl) return; + generateLqip(m[1]).then((data) => { + if (data && lqipEl) lqipEl.value = data; + }); + }); + } + refresh(); + return el; + }); + }; + + const lqipSo = section.option(form.Value, lqipKey, ""); + lqipSo.rmempty = true; + lqipSo.render = function (option_index, section_id, in_table) { + return form.Value.prototype.render.apply(this, arguments).then((el) => { + el.style.display = "none"; + return el; + }); + }; + }; + + // 背景独立成区:它们是页面氛围,不是站点品牌标识。 + const bgSection = s.taboption( + "icons_branding", + form.SectionValue, + "_background_settings", + form.NamedSection, + "theme", + "aurora", + _("Page Backgrounds"), + _( + "Pick wallpapers for the login page and the admin interface. Drag the sliders and watch the live preview; Save & Apply makes it real.", + ), + ); + const bgSubsection = bgSection.subsection; + + addBackgroundOption(bgSubsection, { + key: "struct_login_bg", + lqipKey: "struct_login_bg_lqip", + label: "", + heading: _("Login Background"), + previewKind: "login", + tunables: [ + ["struct_login_bg_alpha", _("Surface Opacity"), "%", 50, 100, "100"], + ["struct_login_bg_blur", _("Frosted Blur"), "px", 0, 40, "0"], + ["struct_login_bg_scrim", _("Backdrop Scrim"), "%", 0, 70, "0"], + ], + }); + + addBackgroundOption(bgSubsection, { + key: "struct_main_bg", + lqipKey: "struct_main_bg_lqip", + label: "", + heading: _("Main Background"), + previewKind: "admin", + withUpload: true, + tunables: [ + ["struct_main_bg_alpha", _("Surface Opacity"), "%", 50, 100, "67"], + ["struct_main_bg_blur", _("Frosted Blur"), "px", 0, 40, "20"], + ["struct_main_bg_scrim", _("Backdrop Scrim"), "%", 0, 70, "20"], + ], + }); + + + + const logoSection = s.taboption( + "icons_branding", + form.SectionValue, + "_branding_settings", + form.NamedSection, + "theme", + "aurora", + _("Site Branding"), + _( + "Assign uploaded images to icons, favicons, and PWA metadata. Saved on Save or Save & Apply.", + ), + ); + const logoSubsection = logoSection.subsection; + + so = logoSubsection.option(form.ListValue, "logo_svg", _("Logo (SVG)")); + so.description = _("SVG mark for the browser tab and login page."); + so.default = "logo.svg"; + so.rmempty = false; + so.load = makeIconListLoader(isImageFile); + + so = logoSubsection.option( + form.ListValue, + "favicon_png", + _("Favicon (PNG)"), + ); + so.description = _("PNG fallback when SVG favicons are unsupported."); + so.rmempty = true; + so.load = makeIconListLoader((icon) => PNG_NAME_RE.test(icon), { + prepend: [["", _("(None)")]], + }); + + so = logoSubsection.option( + form.ListValue, + "favicon_ico", + _("Favicon (ICO / Legacy)"), + ); + so.description = _("Legacy ICO favicon fallback."); + so.default = "favicon.ico"; + so.rmempty = false; + so.load = makeIconListLoader((icon) => ICO_NAME_RE.test(icon)); + + const pwaIconSlots = [ + [ + "pwa_apple_touch", + _("Apple Touch Icon"), + "apple-touch-icon.png", + _("Home Screen icon for iOS and iPadOS."), + ], + [ + "pwa_icon_192", + _("App Icon 192×192"), + "app-icon-192x192.png", + _("192×192 icon for the installable web app manifest."), + ], + [ + "pwa_icon_512", + _("App Icon 512×512"), + "app-icon-512x512.png", + _("512×512 icon for the installable web app manifest."), + ], + ]; + + pwaIconSlots.forEach(function ([key, label, defaultVal, description]) { + so = logoSubsection.option(form.ListValue, key, label); + so.description = description; + so.default = defaultVal; + so.rmempty = false; + so.load = makeIconListLoader( + (icon) => isImageFile(icon) && !/\.svg$/i.test(icon), + ); + }); + + const toolbarSection = s.taboption( + "icons_branding", + form.SectionValue, + "_toolbar_settings", + form.NamedSection, + "theme", + "aurora", + _("Shortcut Toolbar"), + _( + "Floating shortcut launcher shown along the right edge of the interface.", + ), + ); + const toolbarSubsection = toolbarSection.subsection; + + so = toolbarSubsection.option( + form.Flag, + "toolbar_enabled", + _("Show Shortcut Toolbar"), + ); + so.description = _("Show the floating launcher on all pages."); + so.default = "1"; + so.rmempty = false; + + so = toolbarSubsection.option( + form.SectionValue, + "_toolbar_items", + form.GridSection, + "toolbar_item", + _("Toolbar Shortcuts"), + _("Add shortcuts, assign icons, and drag rows to reorder them."), + ); + so.depends("toolbar_enabled", "1"); + const toolbarGrid = so.subsection; + toolbarGrid.addremove = true; + toolbarGrid.sortable = true; + toolbarGrid.anonymous = true; + toolbarGrid.nodescriptions = true; + + so = toolbarGrid.option(form.Flag, "enabled", _("Enabled")); + so.default = "1"; + so.rmempty = false; + so.editable = true; + + so = toolbarGrid.option(form.Value, "title", _("Shortcut Label")); + so.rmempty = false; + so.placeholder = _("e.g., Network Interfaces"); + so.validate = (section_id, value) => + !value?.trim() ? _("Enter a shortcut label") : true; + + so = toolbarGrid.option(form.Value, "url", _("Shortcut URL")); + so.rmempty = false; + so.placeholder = "/cgi-bin/luci/admin/..."; + so.validate = (section_id, value) => + !value?.trim() ? _("Enter a LuCI shortcut URL") : true; + + so = toolbarGrid.option(form.ListValue, "icon", _("Icon")); + so.rmempty = false; + so.load = makeIconListLoader(() => true, { + empty: [["", _("(No icons uploaded)")]], + }); + so.validate = (section_id, value) => + !value?.trim() ? _("Choose an uploaded icon for this shortcut") : true; + + return m.render().then((mapNode) => { + colorEditor.attach(); + enhanceColorTokenGroups(mapNode); + enhanceDerivedFold(mapNode); + + // Auto-select uploaded background and auto-generate LQIP if missing. + // pending_bg(上传后自动选中)只归登录背景——那是设置它的上传入口; + // LQIP 自动补生成两对背景键都要(hub 应用与换图都会清掉旧 LQIP)。 + requestAnimationFrame(() => { + const pending = localStorage.getItem("aurora.pending_bg"); + // 上传入口会写明归属键;资产库里按 login-bg.* 改名的老路径没有键, + // 默认归登录背景,行为与从前一致。 + const pendingKey = + localStorage.getItem("aurora.pending_bg_key") || "struct_login_bg"; + [ + ["struct_login_bg", "struct_login_bg_lqip"], + ["struct_main_bg", "struct_main_bg_lqip"], + ].forEach(([key, lqipKey]) => { + const bgInput = themeFormField(key); + const lqipInput = themeFormField(lqipKey); + if (!bgInput || !lqipInput) return; + + if (pending && pendingKey === key) { + localStorage.removeItem("aurora.pending_bg"); + localStorage.removeItem("aurora.pending_bg_key"); + const pendingUrl = toBgUrl(pending); + if (bgInput.querySelector(`option[value="${pendingUrl}"]`)) { + bgInput.value = pendingUrl; + bgInput.dispatchEvent(new Event("change")); + return; + } + } + + if (bgInput.value && !lqipInput.value) { + const bgMatch = bgInput.value.match(/url\(["']?(.+?)["']?\)/); + if (bgMatch) { + generateLqip(bgMatch[1]).then((d) => { + if (d) lqipInput.value = d; + }); + } + } + }); + }); + + // 设计 A:两张背景卡并排常驻(状态全可见),共享滑杆面板跟随选中的 + // 卡。这里把两张卡行收进 .bg-duo 网格、把各自的滑杆组移进共享面板行; + // 隐藏字段不受 display 影响,保存管线照常收集两组键。 + const bgCards = Array.from(mapNode.querySelectorAll("[data-bg-target]")); + if (bgCards.length === 2) { + const duo = E("div", { class: "bg-duo" }); + bgCards[0].parentNode.insertBefore(duo, bgCards[0]); + bgCards.forEach((c) => duo.appendChild(c)); + const paneRow = E("div", { class: "bg-pane" }); + duo.parentNode.insertBefore(paneRow, duo.nextSibling); + mapNode + .querySelectorAll("[data-bg-pane]") + .forEach((pane) => paneRow.appendChild(pane)); + const showBg = (tkey) => { + bgCards.forEach((c) => + c.classList.toggle("on", c.dataset.bgTarget === tkey), + ); + paneRow.querySelectorAll("[data-bg-pane]").forEach((pane) => { + pane.style.display = pane.dataset.bgPane === tkey ? "" : "none"; + }); + }; + bgCards.forEach((c) => + c.addEventListener("click", () => showBg(c.dataset.bgTarget)), + ); + showBg("struct_main_bg"); + } + + // Fire and forget: the capsule appears when the answer arrives, and if + // it never does the header simply stays as it is. + checkForUpdates().then(showUpdateCapsule); + + return mapNode; + }); + + }, +}); diff --git a/luci-app-aurora-config/.dev/tests/artifact-safety.test.mjs b/luci-app-aurora-config/.dev/tests/artifact-safety.test.mjs new file mode 100644 index 00000000..37eeebaa --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/artifact-safety.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +// These assertions run against the bytes the router actually serves, not +// against the source they were made from. That is the whole point: the source +// is checked by every other test in here, and the interesting failures live in +// the gap between the two. +const OUT = repo("htdocs/luci-static/resources"); + +async function collect(dir, base = dir) { + const found = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...(await collect(full, base))); + else if (entry.name.endsWith(".js")) found.push(full.slice(base.length + 1)); + } + return found; +} + +const shipped = await collect(OUT); + +test("every shipped view is parseable JavaScript", async () => { + assert.ok(shipped.length > 0, "found no artifacts -- run `pnpm build`"); + for (const rel of shipped) { + const code = await readFile(join(OUT, rel), "utf8"); + // A LuCI view is evaluated as a function body, so a top-level return is + // legal there and only there -- new Function reproduces that context. + assert.doesNotThrow(() => new Function(code), `${rel} does not parse`); + } +}); + +test("every shipped file keeps the require directives its source declared", async () => { + for (const rel of shipped) { + const source = await readFile(srcPath(rel), "utf8"); + const built = await readFile(join(OUT, rel), "utf8"); + const declared = [...source.matchAll(/^"(require [^"]+)";$/gm)].map((m) => m[1]); + for (const directive of declared) + assert.ok( + built.includes(`"${directive}"`), + `${rel} lost its "${directive}" declaration -- LuCI will not load its dependency`, + ); + } +}); + +test("every shipped view keeps the top-level return LuCI reads it for", async () => { + for (const rel of shipped) { + const source = await readFile(srcPath(rel), "utf8"); + const match = /^return (view|baseclass|L\.Class)\.extend\(/m.exec(source); + if (!match) continue; + const built = await readFile(join(OUT, rel), "utf8"); + assert.ok( + built.includes(`return ${match[1]}.extend(`), + `${rel} lost its module export -- LuCI would load an undefined view`, + ); + } +}); + +test("the *.global.js artifacts still publish their globals", async () => { + // These two exist only to define a global for the page. mangle.toplevel or + // compress.toplevel would delete the declaration and terser would still + // report success -- the failure is a white page with nothing in the console. + for (const [rel, name] of [ + ["utils/color.global.js", "Color"], + ["utils/tokens.global.js", "AuroraTokens"], + ]) { + const built = await readFile(join(OUT, rel), "utf8"); + assert.match( + built, + new RegExp(`var ${name}\\s*=`), + `${rel} no longer declares the global ${name} -- is toplevel mangling on?`, + ); + } +}); + +test("vendored code ships the licence it is distributed under", async () => { + // MIT requires the notice to travel with every copy, and comments:false + // strips the plain // blocks the vendored file carries. Sidecars re-attach it. + let checked = 0; + for (const rel of shipped) { + let sidecar = null; + try { + sidecar = await readFile(`${srcPath(rel)}.license`, "utf8"); + } catch { + continue; + } + checked += 1; + const built = await readFile(join(OUT, rel), "utf8"); + assert.ok(built.startsWith("/*!"), `${rel} lost its licence banner`); + for (const line of sidecar.trimEnd().split("\n")) + assert.ok(built.includes(line), `${rel} banner is missing: ${line}`); + } + assert.ok( + checked > 0, + "no .license sidecar was found -- colorjs ships under MIT and must carry its notice", + ); +}); + +test("no shipped file points at a sourcemap that is not installed", async () => { + for (const rel of shipped) { + const built = await readFile(join(OUT, rel), "utf8"); + assert.ok( + !built.includes("sourceMappingURL"), + `${rel} would cost every visitor a 404 for a map the package does not ship`, + ); + } +}); + +test("translatable strings survive minification byte-for-byte", async () => { + // gen-pot reads the source; the runtime looks the string up in the artifact. + // If the two ever disagree the translation silently falls back to English. + const msgid = /\b_\(\s*"((?:[^"\\]|\\.)*)"/g; + // Comment-only lines are dropped first: asset-upload.js documents its own + // options with `// badgeHeader, // _("Slot") or _("Type")`, and a string the + // compressor removes along with its comment was never looked up at runtime. + // (gen-pot does record such lines as references -- harmless, since the msgid + // is also used for real elsewhere -- but that is its business, not this + // test's.) + const code = (text) => + text + .split("\n") + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join("\n"); + for (const rel of shipped) { + const source = code(await readFile(srcPath(rel), "utf8")); + const built = await readFile(join(OUT, rel), "utf8"); + const fromSource = new Set([...source.matchAll(msgid)].map((m) => m[1])); + const fromBuilt = new Set([...built.matchAll(msgid)].map((m) => m[1])); + for (const id of fromSource) + assert.ok( + fromBuilt.has(id), + `${rel}: msgid ${JSON.stringify(id)} was rewritten -- gen-pot reads the ` + + `source, so the runtime would look up a key no .po file contains`, + ); + } +}); + +test("no hidden file was shipped", async () => { + // .DS_Store rode along the first time the build ran and would have been + // installed onto every router that upgraded. + const all = []; + const walk = async (dir) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) await walk(join(dir, entry.name)); + else all.push(entry.name); + } + }; + await walk(OUT); + const hidden = all.filter((name) => name.startsWith(".")); + assert.deepEqual(hidden, [], `hidden files must not be installed: ${hidden}`); +}); + +// Every resource this package fetches by a URL it builds itself must carry a +// version, or browsers keep the copy they have: uhttpd dates our installed +// files at the epoch and sends no Cache-Control, so the heuristic freshness a +// browser computes runs to years and it never revalidates. This is how the +// Marketplace spent months rendering built-in cards from a presets.json whose +// shape had changed underneath it. +// +// Resources LuCI itself `require`s are out of scope -- LuCI appends its own +// ?v=, taken from luci-base's version, which we cannot influence. +test("self-fetched resources carry a cache-busting version", async () => { + const sentinel = /__ASSET_HASH\(/; + for (const rel of shipped) { + const built = await readFile(join(OUT, rel), "utf8"); + assert.ok( + !sentinel.test(built), + `${rel} still contains an unresolved __ASSET_HASH sentinel`, + ); + } + + const theme = await readFile(join(OUT, "view/aurora/studio.js"), "utf8"); + // The version is the loader's second argument; the "?v=" concatenation lives + // inside loadGlobalScript, so assert on both halves. + assert.match( + theme, + /L\.resource\([^)]*\)\+\([^)]*\?"\?v="\+/, + "loadGlobalScript no longer appends the version it is given", + ); + for (const asset of ["color.global.js", "tokens.global.js"]) + assert.match( + theme, + new RegExp(`"utils/${asset.replace(/\./g, "\\.")}",\\s*"[^"]+"`), + `theme.js loads ${asset} without a version argument -- a browser that ` + + `already has it will never ask for it again`, + ); + + const gallery = await readFile(join(OUT, "view/aurora/marketplace.js"), "utf8"); + assert.match( + gallery, + /aurora\/presets\.json"\)\s*\+\s*"\?v=[^"]+"/, + "marketplace.js fetches presets.json with no ?v=", + ); +}); diff --git a/luci-app-aurora-config/.dev/tests/asset-upload-module.test.mjs b/luci-app-aurora-config/.dev/tests/asset-upload-module.test.mjs new file mode 100644 index 00000000..28a1f542 --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/asset-upload-module.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const SRC = srcPath("utils/asset-upload.js"); + +test("asset-upload module exposes the shared surface", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^"require baseclass";/m); + assert.match(src, /^"require rpc";/m); + assert.match(src, /^"require ui";/m); + assert.match(src, /return baseclass\.extend\(/); + for (const name of [ + "MAX_UPLOAD", + "formatSize", + "extOf", + "checkFile", + "uploadToRouter", + "createAssetManager", + "confirmDelete", + ]) + assert.ok(src.includes(name), `missing export: ${name}`); + + assert.ok(!src.includes("createDropzone"), "dead export must stay deleted"); + assert.ok(!src.includes("createProgressRow"), "dead export must stay deleted"); +}); + +test("module owns the only cgi-upload pipeline", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /\/cgi-bin\/cgi-upload/); + assert.match(src, /filemode.*0600/); +}); diff --git a/luci-app-aurora-config/.dev/tests/build-js.test.mjs b/luci-app-aurora-config/.dev/tests/build-js.test.mjs new file mode 100644 index 00000000..8768ee2e --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/build-js.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const BUILD = fileURLToPath(new URL("../scripts/build-js.mjs", import.meta.url)); + +const VIEW = `"use strict"; +"require view"; +"require form"; + +const LABEL = _("A Translatable Label"); + +return view.extend({ + render() { + const unusedButNamed = 1; + return LABEL + unusedButNamed; + }, +}); +`; + +function scratch(files) { + const root = mkdtempSync(join(tmpdir(), "aurora-build-")); + const src = join(root, "src"); + for (const [rel, content] of Object.entries(files)) { + const full = join(src, rel); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, content); + } + return { root, src, out: join(root, "out") }; +} + +const run = (args) => + execFileSync(process.execPath, [BUILD, ...args], { encoding: "utf8" }); + +test("a LuCI view keeps its directive prologue, its top-level return and its _() calls", () => { + const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW }); + try { + run(["--src", src, "--out", out]); + const built = readFileSync(join(out, "view/aurora/studio.js"), "utf8"); + assert.ok( + built.startsWith('"use strict";"require view";"require form";'), + `directive prologue was dropped: ${built.slice(0, 80)}`, + ); + assert.match(built, /return view\.extend/, "top-level return was dropped"); + assert.match(built, /_\("A Translatable Label"\)/, "_() msgid was rewritten"); + assert.ok(built.length < VIEW.length, "output is not smaller than the source"); + assert.ok( + !built.includes("sourceMappingURL"), + "a sourceMappingURL comment costs every visitor a 404", + ); + assert.ok(!built.includes("unusedButNamed"), "locals were not mangled"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("json is compacted and stays parseable", () => { + const pretty = + JSON.stringify({ presets: { a: { light: { bg: "#fff" } } } }, null, 2) + "\n"; + const { root, src, out } = scratch({ "aurora/presets.json": pretty }); + try { + run(["--src", src, "--out", out]); + const built = readFileSync(join(out, "aurora/presets.json"), "utf8"); + assert.ok(built.length < pretty.length, "json was not compacted"); + assert.deepEqual(JSON.parse(built), JSON.parse(pretty), "json content changed"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("--check exits non-zero and names the stale file", () => { + const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW }); + try { + run(["--src", src, "--out", out]); + run(["--check", "--src", src, "--out", out]); // fresh: must not throw + writeFileSync(join(out, "view/aurora/studio.js"), "stale"); + assert.throws( + () => run(["--check", "--src", src, "--out", out]), + (error) => { + assert.equal(error.status, 1, "--check must exit 1 when stale"); + assert.match( + String(error.stdout) + String(error.stderr), + /view\/aurora\/studio\.js/, + ); + return true; + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("build removes artifacts whose source is gone", () => { + const { root, src, out } = scratch({ "view/aurora/studio.js": VIEW }); + try { + run(["--src", src, "--out", out]); + mkdirSync(join(out, "utils"), { recursive: true }); + writeFileSync(join(out, "utils/orphan.js"), "// no source produces this"); + run(["--src", src, "--out", out]); + assert.throws( + () => readFileSync(join(out, "utils/orphan.js")), + "a stale artifact would keep shipping after its source was deleted", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a top-level global survives, and its .license sidecar rides along", () => { + const lib = `var Color = (function () { + // The MIT License (MIT) + // Copyright (c) 2024 Somebody + var helperName = 1; + return helperName; +})(); +`; + const { root, src, out } = scratch({ + "utils/color.global.js": lib, + "utils/color.global.js.license": + "colorjs.io -- MIT\nCopyright (c) 2024 Somebody", + }); + try { + run(["--src", src, "--out", out]); + const built = readFileSync(join(out, "utils/color.global.js"), "utf8"); + // The whole point of this file is the global it publishes. mangle.toplevel + // or compress.toplevel would delete the declaration outright, and terser + // would report success. + assert.match(built, /var Color=/, "the top-level global was mangled or dropped"); + assert.ok(!built.includes("helperName"), "inner locals should still mangle"); + assert.ok( + built.startsWith("/*!"), + "a vendored file must ship its licence: MIT requires the notice in every copy", + ); + assert.match(built, /Copyright \(c\) 2024 Somebody/); + assert.throws( + () => readFileSync(join(out, "utils/color.global.js.license")), + "the sidecar is a source, not an artifact", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a syntax error in a source file fails the build loudly", () => { + const { root, src, out } = scratch({ "utils/broken.js": "const = ;" }); + try { + assert.throws( + () => run(["--src", src, "--out", out]), + (error) => { + assert.notEqual(error.status, 0, "a broken source must not build"); + assert.match(String(error.stderr), /broken\.js/); + return true; + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/luci-app-aurora-config/.dev/tests/builtin-presets.test.mjs b/luci-app-aurora-config/.dev/tests/builtin-presets.test.mjs new file mode 100644 index 00000000..bb6b0d6e --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/builtin-presets.test.mjs @@ -0,0 +1,431 @@ +// A built-in preset is a whole look, not a palette. Before this, all five +// templates carried nothing but 62 colour options, so "Sage Green" and +// "Monochrome" differed by hue alone while the theme's navigation shape, +// corner radius, spacing, content width and typography -- everything the +// settings page can actually configure -- stayed wherever the user had left +// them. These tests hold the three halves of that fix together: the templates +// carry the structure, rpcd applies it, and the Marketplace's offline copy +// says the same thing the templates do. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +const read = (rel) => readFileSync(path.join(repoRoot, rel), "utf8"); + +const PRESET_IDS = ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"]; + +// Duplicated from PRESET_STRUCT_KEYS in root/usr/libexec/rpcd/luci.aurora and +// from LAYOUT_KEYS/TYPOGRAPHY_KEYS in scripts/gen-presets.mjs. A test below +// asserts the shell list matches this one. +const LAYOUT_KEYS = [ + "nav_type", + "struct_spacing", + "struct_radius_base", + "struct_content_width_centered", + "toolbar_enabled", +]; +const TYPOGRAPHY_KEYS = ["struct_font_sans", "struct_font_mono"]; +const STRUCT_KEYS = LAYOUT_KEYS.concat(TYPOGRAPHY_KEYS); + +const templateOf = (id) => read(`root/usr/share/aurora/${id}.template`); + +const readOption = (source, key) => { + const match = new RegExp(`^\\s*option\\s+${key}\\s+'([^']*)'`, "m").exec(source); + return match ? match[1] : null; +}; + +const structOf = (id) => { + const source = templateOf(id); + return STRUCT_KEYS.reduce((acc, key) => { + acc[key] = readOption(source, key); + return acc; + }, {}); +}; + +const toolbarOf = (source) => { + const items = []; + let current = null; + for (const raw of source.split("\n")) { + const line = raw.trim(); + if (/^config\s+toolbar_item\s*$/.test(line)) { + if (current) items.push(current); + current = {}; + continue; + } + if (/^config\s/.test(line)) { + if (current) items.push(current); + current = null; + continue; + } + if (!current) continue; + const m = line.match(/^option\s+(title|url|icon|enabled)\s+'([^']*)'/); + if (m && m[2] !== "") current[m[1]] = m[2]; + } + if (current) items.push(current); + return items.map((it) => + it.icon + ? { title: it.title, url: it.url, icon: it.icon, enabled: it.enabled } + : { title: it.title, url: it.url, enabled: it.enabled }, + ); +}; + +const rpcd = read("root/usr/libexec/rpcd/luci.aurora"); +const gallery = read(".dev/src/resource/view/aurora/marketplace.js"); +const browserPresets = JSON.parse( + read(".dev/src/resource/aurora/presets.json"), +).presets; + +test("every preset template carries the full structural block", () => { + for (const id of PRESET_IDS) { + const struct = structOf(id); + for (const key of STRUCT_KEYS) + assert.ok( + struct[key], + `${id}.template is missing option ${key} -- load_preset_struct_snapshot ` + + `rejects a partial preset, so this template would fail to apply at all`, + ); + } +}); + +// The same bounds validate_and_apply_hub_payload enforces on an inbound hub +// configuration. A preset that could write a value the hub rejects would mean +// the package held two different definitions of a valid look. +test("preset structure values stay inside the bounds the hub path enforces", () => { + const rem = (value) => { + assert.match(value, /^[0-9]+(\.[0-9]+)?rem$/); + return parseFloat(value); + }; + + for (const id of PRESET_IDS) { + const s = structOf(id); + assert.ok( + ["mega-menu", "dropdown", "sidebar"].includes(s.nav_type), + `${id}: unknown nav_type ${s.nav_type}`, + ); + assert.ok(["0", "1"].includes(s.toolbar_enabled), `${id}: bad toolbar_enabled`); + + const spacing = rem(s.struct_spacing); + assert.ok(spacing >= 0.05 && spacing <= 1, `${id}: spacing out of range`); + + const radius = rem(s.struct_radius_base); + assert.ok(radius >= 0 && radius <= 2, `${id}: radius out of range`); + + const width = rem(s.struct_content_width_centered); + assert.ok(width >= 40 && width <= 160, `${id}: content width out of range`); + + // The settings page writes these through range inputs, so a preset landing + // between two stops would snap to a different value the first time anyone + // touches the slider -- and the config would silently stop matching the + // preset it says is active. + assert.equal( + Math.round(spacing * 100) % 5, + 0, + `${id}: spacing must sit on the 0.05rem slider step`, + ); + assert.equal( + Math.round(radius * 1000) % 125, + 0, + `${id}: radius must sit on the 0.125rem slider step`, + ); + assert.equal(width % 1, 0, `${id}: content width must sit on the 1rem slider step`); + + // Written into a single-quoted `uci batch` line; is_valid_font_stack + // rejects a literal quote, and so does this. + for (const key of TYPOGRAPHY_KEYS) { + assert.ok(s[key].length <= 200, `${id}: ${key} too long`); + assert.match(s[key], /^[A-Za-z0-9 ,"-]+$/, `${id}: ${key} has illegal characters`); + } + } +}); + +// The whole point of the change. Colour-only presets were five paint jobs on +// one layout; if a new preset ships without pulling any other lever, the store +// is back to selling hues. +test("the five presets are five different looks, not five palettes", () => { + const structs = PRESET_IDS.map(structOf); + + const distinct = (key) => new Set(structs.map((s) => s[key])).size; + + assert.ok(distinct("nav_type") >= 3, "all three navigation shapes should be on offer"); + assert.equal(distinct("struct_radius_base"), 5, "every preset should own its corner radius"); + assert.equal(distinct("struct_font_sans"), 5, "every preset should own its sans typeface"); + assert.ok(distinct("struct_spacing") >= 3, "all three spacing stops should be on offer"); + assert.ok(distinct("struct_content_width_centered") >= 3, "content width should vary"); + assert.equal(distinct("toolbar_enabled"), 2, "at least one preset should turn the toolbar off"); + + // No two presets may agree on their entire structure: that would be two + // names for one look. + const signatures = structs.map((s) => JSON.stringify(s)); + assert.equal(new Set(signatures).size, PRESET_IDS.length); +}); + +// Spacing scales the padding and gaps of every component at once, so it is the +// lever with the least room to be wrong: three stops are usable and one +// combination is not. A looser scale needs somewhere to breathe, and the +// sidebar shell has none -- its first column already eats the horizontal +// margin and the page runs full-width, so 0.3rem there reads as the layout +// having come apart rather than as a roomier theme. It belongs on a centred +// layout with a wide content column. +const SPACING_STOPS = ["0.2rem", "0.25rem", "0.3rem"]; + +test("spacing stays on its three stops, and the loose one avoids the sidebar", () => { + assert.equal( + structOf("default").struct_spacing, + "0.25rem", + "the default preset ships the middle stop", + ); + + const defaultWidth = parseFloat(structOf("default").struct_content_width_centered); + + for (const id of PRESET_IDS) { + const s = structOf(id); + assert.ok( + SPACING_STOPS.includes(s.struct_spacing), + `${id}: ${s.struct_spacing} is not one of ${SPACING_STOPS.join(" / ")}`, + ); + + if (s.struct_spacing !== "0.3rem") continue; + + assert.notEqual( + s.nav_type, + "sidebar", + `${id}: the loose spacing must not ride on the sidebar shell`, + ); + assert.ok( + parseFloat(s.struct_content_width_centered) > defaultWidth, + `${id}: the loose spacing needs a content column wider than the default ` + + `${defaultWidth}rem to sit in`, + ); + } +}); + +// The font stack is how the device reverse-maps a configuration back to a +// roster entry (resolve_font_preset_id / get_font_preset_by_stack). A stack +// retyped by hand instead of copied would resolve to nothing: the settings +// page would show the wrong font, and sync_and_cache_fonts_from_uci would +// never fetch the woff2 files, so the preset would silently render its +// fallback family. +test("every preset font stack is a verbatim font-presets.conf roster stack", () => { + const roster = { sans: new Map(), mono: new Map() }; + const files = new Set(); + for (const line of read("root/usr/share/aurora/font-presets.conf").split("\n")) { + const parts = line.split("|"); + if (parts[0] === "font") roster[parts[1]]?.set(parts[6], parts[2]); + if (parts[0] === "file") files.add(`${parts[1]}/${parts[2]}`); + } + + for (const id of PRESET_IDS) { + const s = structOf(id); + for (const slot of ["sans", "mono"]) { + const fontId = roster[slot].get(s[`struct_font_${slot}`]); + assert.ok(fontId, `${id}: struct_font_${slot} matches no ${slot} roster entry`); + + // marketplace.js reads typography.font_* to decide whether to warn that the + // typeface has to be downloaded first, treating exactly "default" and + // "system" as already on the router. That rule only holds while those + // two ids are the ones with no woff2 files to fetch. + const bundled = fontId === "default" || fontId === "system"; + assert.equal( + files.has(`${slot}/${fontId}`), + !bundled, + `${slot}/${fontId}: BUNDLED_FONT_IDS in marketplace.js assumes only ` + + `default/system need no download`, + ); + } + } +}); + +test("rpcd applies the same structural keys the templates carry", () => { + const declared = /readonly PRESET_STRUCT_KEYS="([^"]+)"/.exec(rpcd); + assert.ok(declared, "PRESET_STRUCT_KEYS is not declared"); + assert.deepEqual( + declared[1].split(/\s+/).filter(Boolean).sort(), + STRUCT_KEYS.slice().sort(), + ); + + // The colour snapshot alone is not enough any more: apply_theme_preset + // writes one flat list, and the structural half has to be in it. + assert.match( + rpcd, + /struct=\$\(load_preset_struct_snapshot "\$template"\) \|\| \{ PRESET_SNAPSHOT=""; return 1; \}/, + "load_preset_snapshot must fail the whole preset when its structure is unusable", + ); + + const start = rpcd.indexOf('"apply_theme_preset")'); + const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start)); + assert.match(applyBlock, /backup_if_mine/, "删之前必须先留底"); + assert.match( + applyBlock, + /delete aurora\.@toolbar_item\[0\]/, + "applying a built-in preset replaces the shortcuts with its own", + ); + assert.match(applyBlock, /load_preset_toolbar "\$template"/); +}); + +// PRESET_SNAPSHOT is one flat list now that it carries structure as well as +// colour. get_theme_preset publishes it grouped the way a hub payload groups +// the same fields -- an object called "colors" holding nav_type would sit +// undetected until something iterated it, because the settings UI only ever +// looks colours up by key. +test("get_theme_preset publishes the snapshot grouped, not dumped into colors", () => { + const start = rpcd.indexOf("json_add_theme_preset_snapshot() {"); + assert.ok(start !== -1, "json_add_theme_preset_snapshot not found"); + // The top-level dispatcher, not one of the `case "$1" in` lines inside a + // function -- those all sit above this one and would slice to nothing. + const fn = rpcd.slice(start, rpcd.indexOf('\ncase "$1" in')); + assert.ok(fn.length, "the function body is empty"); + + for (const group of ["colors", "layout", "typography"]) + assert.match(fn, new RegExp(`json_add_object "${group}"`), `missing ${group} group`); + + assert.match( + fn, + /light_\*\|dark_\*\) json_add_string/, + "the colors object must take only the colour keys", + ); + assert.match(fn, /struct_font_sans\|struct_font_mono\) json_add_string/); + assert.ok( + !/\[ -n "\$key" \] && json_add_string/.test(fn), + "the unfiltered dump would put struct_* keys inside colors", + ); +}); + +test("applying a preset fetches the typeface it names", () => { + assert.match(rpcd, /sync_and_cache_fonts_from_uci\(\)\s*\{/); + assert.match( + rpcd, + /cache_font_slot "sans" "\$sans_preset"/, + "the helper must download, not merely rewrite the CSS", + ); + // Detached: it talks to the network and the rpc call must not wait on it. + assert.match(rpcd, /\) <\/dev\/null >\/dev\/null 2>&1 &/); + + const start = rpcd.indexOf('"apply_theme_preset")'); + const applyBlock = rpcd.slice(start, rpcd.indexOf('"export_config")', start)); + assert.ok( + applyBlock.indexOf('uci -q commit aurora') < + applyBlock.indexOf("sync_and_cache_fonts_from_uci"), + "apply_theme_preset must resync fonts after committing", + ); +}); + +test("the browser copy of the presets matches the templates", () => { + assert.deepEqual(Object.keys(browserPresets).sort(), PRESET_IDS.slice().sort()); + + for (const id of PRESET_IDS) { + const entry = browserPresets[id]; + const struct = structOf(id); + + for (const key of LAYOUT_KEYS) + assert.equal(entry.layout[key], struct[key], `${id}: layout.${key} drifted`); + for (const key of TYPOGRAPHY_KEYS) + assert.equal(entry.typography[key], struct[key], `${id}: typography.${key} drifted`); + + // The hub's payload shape, so the store reads a built-in preset with the + // accessors it already uses for a shared configuration. + assert.ok(Array.isArray(entry.toolbar), `${id}: toolbar must be an array`); + assert.deepEqual( + entry.toolbar, + toolbarOf(templateOf(id)), + `${id}: toolbar drifted from the template`, + ); + assert.ok(entry.toolbar.length > 0, `${id}: every preset ships its own shortcuts`); + for (const key of Object.keys(entry.colors)) + assert.equal( + entry.colors[key], + readOption(templateOf(id), key), + `${id}: colors.${key} drifted from the template`, + ); + } +}); + +// The browser copy is downloaded by every visitor to the store; the templates +// are not. So it carries only what the store reads, and the store reads only +// the swatch keys -- applying a built-in preset goes through +// apply_theme_preset, which reads the full template on the router. Shipping all +// 62 colours cost 7,968 bytes that nothing looked at. +test("the browser copy carries exactly the swatch keys the store reads", () => { + const declared = /const SWATCH_KEYS = \[([^\]]*)\]/.exec(gallery); + assert.ok(declared, "marketplace.js no longer declares SWATCH_KEYS"); + const swatchKeys = [...declared[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); + assert.ok(swatchKeys.length > 0, "SWATCH_KEYS parsed empty"); + + const expected = ["light", "dark"] + .flatMap((mode) => swatchKeys.map((key) => `${mode}_${key}`)) + .sort(); + + for (const id of PRESET_IDS) { + assert.deepEqual( + Object.keys(browserPresets[id]).includes("colors") + ? Object.keys(browserPresets[id].colors).sort() + : null, + expected, + `${id}: the browser copy must carry the store's swatch keys and nothing else ` + + `-- add a key to SWATCH_KEYS and gen-presets.mjs must follow, or the ` + + `card renders a blank swatch`, + ); + } +}); + +test("the Marketplace no longer promises that a preset only repaints", () => { + assert.ok( + !gallery.includes("Layout (unchanged by this preset)"), + "the built-in drawer must show the preset's own layout, not the current one", + ); + assert.ok( + !/Presets set the light and dark colors only/.test(gallery), + "the apply confirmation must stop claiming a preset changes colours alone", + ); + assert.match( + gallery, + /const buildLayoutRows = /, + "built-in and community drawers must share one layout table", + ); + assert.match( + gallery, + /buildLayoutRows\(preset\.preview\.layout \|\| \{\}, typography\)/, + "the built-in drawer must render that table", + ); + // The card advertises "Works offline"; a preset naming a Fontsource family + // is not fully offline until the woff2 files land. + assert.match(gallery, /const needsFontDownload = /); + assert.match(gallery, /const BUNDLED_FONT_IDS = \["default", "system"\]/); +}); + +// The shape of presets.json changed once already (151e50a moved it from +// {light,dark} to {colors,layout,typography,toolbar}), and browsers kept +// serving the old one for months: uhttpd reports Last-Modified as the epoch +// and sends no Cache-Control, so a browser's heuristic freshness works out to +// roughly 5.6 years and it never revalidates. A cached copy of the old shape +// renders every built-in card with the fallback palette and a top nav bar -- +// no colours, no sidebar, no font chip -- and nothing in the UI says why. +// +// So the fetch carries a content hash, the way theme.js does for the token +// engine. gen-presets.mjs stamps it; this checks the two agree. +test("the store cache-busts presets.json with a hash of its contents", () => { + const data = read(".dev/src/resource/aurora/presets.json"); + const expected = createHash("sha256").update(data).digest("hex").slice(0, 8); + + const stamped = /const PRESETS_VERSION = "([^"]+)";/.exec(gallery)?.[1]; + assert.ok(stamped, "marketplace.js declares PRESETS_VERSION"); + assert.equal( + stamped, + expected, + "PRESETS_VERSION is stale -- rerun `pnpm gen-presets`", + ); + + assert.match( + gallery, + /L\.resource\("aurora\/presets\.json"\)\s*\+\s*"\?v="\s*\+\s*PRESETS_VERSION/, + "the fetch must carry the stamp, or browsers keep the copy they already have", + ); +}); diff --git a/luci-app-aurora-config/.dev/tests/feed-check-module.test.mjs b/luci-app-aurora-config/.dev/tests/feed-check-module.test.mjs new file mode 100644 index 00000000..cc6f4aee --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/feed-check-module.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const SRC = srcPath("utils/feed-check.js"); + +// These are pure functions, so the test actually runs them rather than +// pattern-matching the source: strip LuCI's "require" directives and hand the +// remaining function body a stub baseclass. +async function load() { + const src = await readFile(SRC, "utf8"); + const body = src + .replace(/^"use strict";$/m, "") + .replace(/^"require [^"]+";$/gm, ""); + return new Function("baseclass", body)({ extend: (obj) => obj }); +} + +test("parseRevision pulls the rYYYYMMDD stamp", async () => { + const m = await load(); + assert.equal(m.parseRevision("1.1.3-r20260803"), 20260803); + assert.equal(m.parseRevision("1.1.13-r20260802"), 20260802); +}); + +test("parseRevision returns null for anything else", async () => { + const m = await load(); + assert.equal(m.parseRevision("26.192.49224.6b21ba7"), null); + assert.equal(m.parseRevision("1.1.3"), null); + assert.equal(m.parseRevision(""), null); + assert.equal(m.parseRevision(undefined), null); + assert.equal(m.parseRevision(null), null); +}); + +test("isNewer never orders versions it cannot parse", async () => { + const m = await load(); + assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4-r20260901"), true); + assert.equal(m.isNewer("1.1.3-r20260803", "1.1.3-r20260803"), false); + // Measured on a live router: the installed config was newer than the feed's + // snapshot build. Claiming an update there would be a lie. + assert.equal(m.isNewer("1.1.3-r20260803", "1.1.2-r20260727"), false); + // install.sh: opkg and apk version schemes are not comparable -- never order. + assert.equal(m.isNewer("1.1.3", "1.1.4"), false); + assert.equal(m.isNewer("1.1.3-r20260803", "1.1.4"), false); +}); + +test("findManifestVersion reads the live manifest shape", async () => { + const m = await load(); + // Shape confirmed against https://openwrt.eamonxg.fun/manifest.json + const manifest = { + generated: "...", + channels: { + snapshots: { + apk: [ + { pkg: "luci-theme-aurora", version: "1.1.13-r20260802" }, + { pkg: "luci-app-aurora-config", version: "1.1.2-r20260727" }, + ], + opkg: [{ pkg: "luci-theme-aurora", version: "1.1.13-r20260802" }], + }, + }, + }; + assert.equal( + m.findManifestVersion(manifest, "snapshots", "apk", "luci-app-aurora-config"), + "1.1.2-r20260727", + ); + assert.equal(m.findManifestVersion(manifest, "releases", "apk", "x"), null); + assert.equal(m.findManifestVersion(manifest, "snapshots", "apk", "nope"), null); + assert.equal(m.findManifestVersion(null, "snapshots", "apk", "x"), null); +}); + +test("pickPackageManagerPath reads the live menu instead of guessing", async () => { + const m = await load(); + const tree = (children) => ({ + children: { admin: { children: { system: { children } } } }, + }); + assert.equal( + m.pickPackageManagerPath(tree({ "package-manager": {} })), + "admin/system/package-manager", + ); + assert.equal(m.pickPackageManagerPath(tree({ opkg: {} })), "admin/system/opkg"); +}); + +test("pickPackageManagerPath skips nodes the user may not open", async () => { + const m = await load(); + const tree = (children) => ({ + children: { admin: { children: { system: { children } } } }, + }); + // ui.menu keeps unauthorised nodes but flags them; linking there would 403. + assert.equal( + m.pickPackageManagerPath(tree({ "package-manager": { satisfied: false } })), + null, + ); + assert.equal( + m.pickPackageManagerPath( + tree({ "package-manager": { satisfied: false }, opkg: {} }), + ), + "admin/system/opkg", + ); +}); + +test("pickPackageManagerPath yields null when there is no software page", async () => { + const m = await load(); + assert.equal(m.pickPackageManagerPath({ children: {} }), null); + assert.equal(m.pickPackageManagerPath(null), null); + assert.equal( + m.pickPackageManagerPath({ + children: { admin: { children: { system: { children: { reboot: {} } } } } }, + }), + null, + ); +}); + diff --git a/luci-app-aurora-config/.dev/tests/feed-rpcd.test.mjs b/luci-app-aurora-config/.dev/tests/feed-rpcd.test.mjs new file mode 100644 index 00000000..61b6e2bf --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/feed-rpcd.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const RPCD = repo("root/usr/libexec/rpcd/luci.aurora"); + +test("rpcd: the package manager is detected from its database, like install.sh", async () => { + const src = await readFile(RPCD, "utf8"); + const block = src.slice( + src.indexOf("detect_package_manager()"), + src.indexOf("get_system_lang()"), + ); + assert.match(block, /\/lib\/apk\/db\/installed/); + assert.match(block, /\/usr\/lib\/opkg\/status/); + assert.ok( + !/command -v (opkg|apk)/.test(block), + "binary probing can disagree with install.sh on the same machine", + ); +}); + +test("rpcd: apk versions are read from the database, not by spawning apk", async () => { + const src = await readFile(RPCD, "utf8"); + // Match a command substitution, not the words: the comment above the awk + // block names the call it replaced, and that is worth keeping. + assert.ok( + !/\$\(apk list -I/.test(src), + "spawning apk parses the whole installed db on every page load", + ); + // Verified on a live snapshot router: records are P: then V:. + assert.match(src, /\^P:/, "records are keyed by the P: field"); + assert.match(src, /\^V:/, "versions come from the V: field"); +}); + +test("rpcd: feed status rides along on get_init_data", async () => { + const src = await readFile(RPCD, "utf8"); + assert.match(src, /json_add_feed_status\(\)/, "missing the status helper"); + assert.match( + src, + /json_add_installed_versions[\s\S]{0,200}json_add_feed_status/, + "feed status must be emitted inside the existing init-data call", + ); + assert.ok( + !/"get_feed_status"/.test(src), + "a separate method would add a round trip on every page load", + ); +}); + +test("rpcd: feed constants match the spec", async () => { + const src = await readFile(RPCD, "utf8"); + assert.match(src, /AURORA_FEED_HOST=["']?openwrt\.eamonxg\.fun/); + assert.match(src, /AURORA_FEED_FPR=["']?82d72f5ededb6163/); + assert.match(src, /AURORA_FEED_CHANNEL=["']?snapshots/); +}); + +test("rpcd: add_feed drops its own lines before appending", async () => { + const src = await readFile(RPCD, "utf8"); + const block = src.slice(src.indexOf("aurora_add_feed()")); + assert.match(block, /aurora_drop_feed_lines/, "old lines must be removed first"); + assert.match( + block, + /https:\/\/\$AURORA_FEED_HOST\//, + "apk lines are matched by the host prefix", + ); + assert.match(block, /src\/gz eamonxg /, "opkg lines are matched by the feed name"); +}); + +test("rpcd: add_feed distinguishes a write failure from an index failure", async () => { + const src = await readFile(RPCD, "utf8"); + assert.match(src, /json_add_boolean "index_refreshed"/); +}); + +test("rpcd: add_feed installs the keys where each manager looks for them", async () => { + const src = await readFile(RPCD, "utf8"); + assert.match(src, /\/etc\/opkg\/keys\/\$AURORA_FEED_FPR/); + assert.match(src, /\/etc\/apk\/keys\/eamonxg\.pem/); +}); + +test("rpcd: add_feed is registered on the ubus object", async () => { + const src = await readFile(RPCD, "utf8"); + assert.match(src, /"add_feed"/); +}); diff --git a/luci-app-aurora-config/.dev/tests/feed-ui.test.mjs b/luci-app-aurora-config/.dev/tests/feed-ui.test.mjs new file mode 100644 index 00000000..af828e66 --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/feed-ui.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const SRC = srcPath("view/aurora/studio.js"); + +test("feed ui: no manifest request when the feed is not configured", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /if \(!feedStatus\.configured\) return Promise\.resolve\(null\)/, + "an unconfigured router must not reach out to the network at all", + ); +}); + +test("feed ui: the manifest is fetched by the browser, never through rpcd", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const FEED_HOST = "openwrt\.eamonxg\.fun"/); + assert.match(src, /const MANIFEST_URL = `https:\/\/\$\{FEED_HOST\}\/manifest\.json`/); + assert.match(src, /fetch\(MANIFEST_URL/); + assert.ok( + !/callFetchManifest|hub_http|fetch_url/.test(src), + "the router must not proxy the manifest", + ); +}); + +test("feed ui: the result is cached for the session", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /sessionStorage/); +}); + +test("feed ui: the update-source prompt left Studio for the Marketplace inbox", async () => { + const src = await readFile(SRC, "utf8"); + for (const gone of ["buildFeedNotice", "FEED_NOTICE_KEY", "feed_notice_dismissed", "callAddFeed", "Add update source", "Upgrading Aurora needs"]) + assert.ok(!src.includes(gone), gone); + assert.ok( + !/uci\.set\("aurora", "theme", "feed_/.test(src), + "nothing about the feed is a uci change", + ); + // "A newer build exists" is a different thing and stays in Studio. + assert.match(src, /_\("%s available"\)/); +}); + +test("feed ui: nothing is said when there is no update", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(!/Up to date|已是最新/.test(src), "silence is the up-to-date state"); +}); + +test("feed ui: add_feed is the only rpcd method, and status still rides on get_init_data", async () => { + const hubApi = await readFile(srcPath("utils/hub-api.js"), "utf8"); + assert.match(hubApi, /method: "add_feed"/, "add_feed must be declared"); + for (const file of [hubApi, await readFile(SRC, "utf8")]) + assert.ok(!/method: "get_feed_status"/.test(file), "feed status rides on get_init_data"); +}); + +test("feed ui: a network failure degrades in silence", async () => { + const src = await readFile(SRC, "utf8"); + const block = src.slice( + src.indexOf("const checkForUpdates ="), + src.indexOf("const showUpdateCapsule ="), + ); + assert.match(block, /\.catch\(\(\) => null\)/, "no error toast, no console noise"); +}); diff --git a/luci-app-aurora-config/.dev/tests/font-presets.test.mjs b/luci-app-aurora-config/.dev/tests/font-presets.test.mjs new file mode 100644 index 00000000..9615c252 --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/font-presets.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const confPath = fileURLToPath( + new URL("../../root/usr/share/aurora/font-presets.conf", import.meta.url), +); +const lines = readFileSync(confPath, "utf8").trim().split("\n"); + +test("header declares v2 generated format", () => { + assert.match(lines[0], /^v2\|generated-by-gen-font-presets\|do-not-edit$/); +}); + +const fonts = lines.filter((l) => l.startsWith("font|")).map((l) => l.split("|")); +const files = lines.filter((l) => l.startsWith("file|")).map((l) => l.split("|")); + +test("font lines have 7 fields and known slots", () => { + assert.ok(fonts.length >= 8); + for (const f of fonts) { + assert.equal(f.length, 7, `bad font line: ${f.join("|")}`); + assert.ok(["sans", "mono"].includes(f[1])); + assert.ok(/^[a-z0-9-]+$/.test(f[2]), `bad name: ${f[2]}`); + assert.ok(f[6].length > 0, "stack required"); + } +}); + +test("file lines carry valid sha256 and pinned dual-source urls", () => { + assert.ok(files.length > 0); + for (const f of files) { + const [, slot, name, weight, sha256, url1, url2] = f; + assert.equal(f.length, 7, `bad file line: ${f.join("|")}`); + assert.ok(["sans", "mono"].includes(slot)); + assert.ok(/^(400|500|600|700)$/.test(weight)); + assert.match(sha256, /^[0-9a-f]{64}$/); + assert.match(url1, /^https:\/\/cdn\.jsdelivr\.net\/npm\/@fontsource\/[a-z0-9-]+@\d+\.\d+\.\d+\/files\/.+\.woff2$/); + assert.match(url2, /^https:\/\/registry\.npmmirror\.com\/@fontsource\/[a-z0-9-]+\/\d+\.\d+\.\d+\/files\/files\/.+\.woff2$/); + assert.ok(fonts.some((x) => x[1] === slot && x[2] === name), `orphan file line for ${slot}/${name}`); + } +}); + +test("every webfont preset stays within 4 files, non-webfont has none", () => { + for (const f of fonts) { + const [, slot, name, , , family] = f; + const n = files.filter((x) => x[1] === slot && x[2] === name).length; + if (name === "default" || name === "system") assert.equal(n, 0); + else { + assert.ok(n >= 2 && n <= 4, `${slot}/${name} has ${n} files`); + assert.ok(files.some((x) => x[1] === slot && x[2] === name && x[3] === "400"), `${slot}/${name} missing weight 400`); + assert.ok(family.length > 0); + } + } +}); diff --git a/luci-app-aurora-config/.dev/tests/hub-api-module.test.mjs b/luci-app-aurora-config/.dev/tests/hub-api-module.test.mjs new file mode 100644 index 00000000..626b24a8 --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/hub-api-module.test.mjs @@ -0,0 +1,745 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const SRC = srcPath("utils/hub-api.js"); + +test("hub-api module exposes the shared surface", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^"require baseclass";/m); + assert.match(src, /^"require rpc";/m); + assert.ok(src.includes("aurora.hub.list"), "missing cache key"); + assert.ok(src.includes("callHubList"), "missing callHubList"); + assert.ok(src.includes("callHubGet"), "missing callHubGet"); + assert.ok(src.includes("getStale"), "missing getStale"); + assert.match(src, /return baseclass\.extend\(/); +}); + +test("hub-api list/detail go straight to the hub from the browser", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const HUB_BASE = "https:\/\/themes\.eamonxg\.fun"/); + assert.match(src, /fetch\(HUB_BASE \+ path/, "list/get must use fetch, not ubus"); + assert.match(src, /AbortController/, "missing fetch timeout"); + assert.match(src, /hub_unreachable/, "network failures must map to the rpcd envelope"); + assert.match(src, /invalid_id/, "bad ids must short-circuit without a request"); + assert.match(src, /result: 0, data/, "success must keep the rpcd envelope"); + // list/get 不得再走 rpc.declare + assert.ok( + !/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_list"/.test(src), + "hub_list still declared over ubus", + ); + assert.ok( + !/rpc\.declare\(\{\s*object: "luci\.aurora",\s*method: "hub_get"/.test(src), + "hub_get still declared over ubus", + ); +}); + +// 两份缓存共享一个语义:只作首帧种子,不设 TTL。fetchSort / refreshMyShares +// 每次渲染都会覆盖它们,所以"过期"没有意义 —— 而一份永远可用的旧数据,正是 +// 首屏不必等待 205ms 跨太平洋往返的前提。 +test("hub-api: caches are first-paint seeds, not TTL'd stores", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(!src.includes("CACHE_TTL"), "CACHE_TTL had no live reader; it should be gone"); + assert.ok( + !/^\s+get\(\)\s*\{/m.test(src), + "listCache.get() had no caller; it should be gone", + ); + assert.match(src, /localStorage\.getItem\(/); + assert.match(src, /localStorage\.setItem\(/); + assert.match(src, /localStorage\.removeItem\(/); +}); + +test("hub-api exposes meCache alongside listCache", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("meCache"), "missing meCache"); + assert.ok(src.includes("aurora.hub.list"), "missing list cache key"); + assert.ok(src.includes("aurora.hub.me"), "missing me cache key"); +}); + +test("hub-api module exposes the apply/status/restore declares (Task 6)", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubApply"), "missing callHubApply"); + assert.match(src, /method:\s*"hub_apply"/); + assert.match(src, /params:\s*\["id"\]/); + assert.ok(src.includes("callGetHubStatus"), "missing callGetHubStatus"); + assert.match(src, /method:\s*"get_hub_status"/); + assert.match(src, /params:\s*\["job_id"\]/); + assert.ok(src.includes("callHubRestore"), "missing callHubRestore"); + assert.match(src, /method:\s*"hub_restore_backup"/); +}); + +test("hub-api module exposes the publish/my-shares/delete declares (Task 8)", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubShareBegin"), "missing callHubShareBegin"); + assert.ok(src.includes("callHubShareCommit"), "missing callHubShareCommit"); + assert.ok(src.includes("callHubMe"), "missing callHubMe"); + assert.match(src, /method:\s*"hub_me"/); + assert.ok(src.includes("callHubDelete"), "missing callHubDelete"); + assert.match(src, /method:\s*"hub_delete"/); + // 单请求的老路必须彻底消失:它把整张登录背景 base64 塞进一个 1.6MB 的 + // body,而 uclient-fetch 走 TLS 推不动 —— 留着它就是留着那个 bug。 + assert.ok(!/method:\s*"hub_share"/.test(src), "hub_share must not be re-declared"); + assert.ok(!/method:\s*"hub_update"/.test(src), "hub_update must not be re-declared"); +}); + +// hubAssetUrl is pure, so these tests actually run it rather than +// pattern-matching the source. Same harness as feed-check-module.test.mjs: +// strip LuCI's "require" directives and hand the body stub globals. +const strip = (src) => + src.replace(/^"use strict";$/m, "").replace(/^"require [^"]+";$/gm, ""); + +// hub-api requires utils.notices; hand it the real module rather than a stub, +// so the refresh tests below exercise the sanitizer the router will run. +async function load(rpc) { + const notices = new Function( + "baseclass", + strip(await readFile(srcPath("utils/notices.js"), "utf8")), + )({ extend: (obj) => obj }); + return new Function("baseclass", "rpc", "notices", strip(await readFile(SRC, "utf8")))( + { extend: (obj) => obj }, + rpc || { declare: () => () => Promise.resolve(null) }, + notices, + ); +} + +test("hubAssetUrl makes the hub's relative asset path absolute", async () => { + const m = await load(); + assert.equal( + m.hubAssetUrl("/assets/6zcxcg07/logo_svg"), + "https://themes.eamonxg.fun/assets/6zcxcg07/logo_svg", + ); + assert.equal( + m.hubAssetUrl("/assets/abc12345/favicon_png"), + "https://themes.eamonxg.fun/assets/abc12345/favicon_png", + ); +}); + +test("hubAssetUrl rejects anything that is not a hub asset path", async () => { + const m = await load(); + // The path is hub-supplied and therefore untrusted: everything that is not + // the one shape the hub can legitimately produce yields "", and the caller + // draws its plain-colour fallback instead. + assert.equal(m.hubAssetUrl(""), ""); + assert.equal(m.hubAssetUrl(null), ""); + assert.equal(m.hubAssetUrl(undefined), ""); + assert.equal(m.hubAssetUrl("assets/abc/logo_svg"), ""); + assert.equal(m.hubAssetUrl("/assets/abc/../../etc/passwd"), ""); + assert.equal(m.hubAssetUrl("/assets/abc/logo_svg?x=1"), ""); + assert.equal(m.hubAssetUrl("//evil.example/assets/abc/logo_svg"), ""); + assert.equal(m.hubAssetUrl("https://evil.example/assets/abc/logo_svg"), ""); + assert.equal(m.hubAssetUrl("javascript:alert(1)"), ""); + assert.equal(m.hubAssetUrl({ toString: () => "/assets/abc/logo_svg" }), ""); +}); + +test("hub-api: publishing drops the author parameter, nickname gets its own call", async () => { + const src = await readFile(SRC, "utf8"); + // 署名是账号属性,由 hub 从 device_token 解析 —— 不是每次发布可以挑的字段。 + assert.match( + src, + /method:\s*"hub_share_begin",\s*\n\s*params:\s*\["name", "description", "target_id"\]/, + ); + assert.ok(src.includes("callHubSetNickname"), "missing callHubSetNickname"); + assert.match(src, /method:\s*"hub_set_nickname"/); + assert.match(src, /params:\s*\["nickname"\]/); +}); + +test("hub-api exposes the creator key export/import declares", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubExportKey"), "missing callHubExportKey"); + assert.match(src, /method:\s*"hub_export_key"/); + assert.ok(src.includes("callHubImportKey"), "missing callHubImportKey"); + assert.match(src, /method:\s*"hub_import_key"/); + assert.match(src, /params:\s*\["key"\]/); +}); + +test("hub-api exposes callHubMe and drops callHubMyShares", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubMe"), "missing callHubMe"); + assert.ok(!src.includes("callHubMyShares"), "callHubMyShares must be gone"); + assert.match(src, /method:\s*"hub_me"/); +}); + +// localStorage 在 node 里不存在。模块只在方法体内引用它,因此调用前把桩挂到 +// globalThis 即可 —— 与上面 hubAssetUrl 那两个测试一样,真的把代码跑起来, +// 而不是对源码做模式匹配。 +function withLocalStorage(fn) { + const store = new Map(); + const had = "localStorage" in globalThis; + const prev = globalThis.localStorage; + globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }; + try { + return fn(store); + } finally { + if (had) globalThis.localStorage = prev; + else delete globalThis.localStorage; + } +} + +test("meCache round-trips the creator profile and clear() empties it", async () => { + const m = await load(); + withLocalStorage(() => { + assert.equal(m.meCache.getStale(), null); + m.meCache.set({ id: "abc12345", nickname: "eamon", configs: [{ id: "x" }] }); + assert.deepEqual(m.meCache.getStale(), { + id: "abc12345", + nickname: "eamon", + configs: [{ id: "x" }], + }); + m.meCache.clear(); + assert.equal(m.meCache.getStale(), null); + }); +}); + +test("meCache.getStale never expires -- it is a first-paint seed", async () => { + const m = await load(); + withLocalStorage((store) => { + // 一条 1970 年的信封仍必须返回。过期没有意义:refreshMyShares 每次渲染 + // 都会覆盖它,而扔掉它只会换来一次 0.9s 的白屏。 + store.set( + "aurora.hub.me", + JSON.stringify({ timestamp: 0, value: { id: "old", nickname: null, configs: [] } }), + ); + assert.deepEqual(m.meCache.getStale(), { + id: "old", + nickname: null, + configs: [], + }); + }); +}); + +test("both caches survive corrupt localStorage", async () => { + const m = await load(); + withLocalStorage((store) => { + store.set("aurora.hub.me", "{not json"); + store.set("aurora.hub.list", "{not json"); + assert.equal(m.meCache.getStale(), null); + assert.equal(m.listCache.getStale(), null); + }); +}); + +test("the two caches are independent -- clearing one keeps the other", async () => { + const m = await load(); + withLocalStorage(() => { + m.listCache.set({ items: [{ id: "a" }] }); + m.meCache.set({ id: "me", nickname: null, configs: [] }); + m.meCache.clear(); + assert.equal(m.meCache.getStale(), null); + assert.deepEqual(m.listCache.getStale(), { items: [{ id: "a" }] }); + }); +}); + +// 三段式发布:两端走 ubus(device_token 留在路由器),中间那段字节由浏览器 +// 直接 PUT 到 hub —— uclient-fetch 走 TLS 推不动 1.2MB。 +test("hub-api declares the three-step publish", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /method: "hub_share_begin"/); + assert.match(src, /params: \["name", "description", "target_id"\]/); + assert.match(src, /method: "hub_share_commit"/); + assert.match(src, /params: \["draft_id"\]/); +}); + +test("publishCurrentConfig uploads asset bytes from the browser", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /publishCurrentConfig/); + assert.match(src, /XMLHttpRequest/, "needs upload progress events"); + assert.match(src, /upload\.onprogress/); + assert.match(src, /"Bearer " \+/, "asset PUT authenticates with the ticket"); + assert.match(src, /HUB_BASE \+ entry\.url/); + // 票据是浏览器唯一拿到的凭证。断言只针对代码 —— 注释里解释"为什么浏览器 + // 拿不到 device_token"正是该留下的东西,把它一起禁掉会逼人删掉理由。 + const code = src + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); + assert.ok( + !/device_token/.test(code), + "the device token must never appear in browser code", + ); +}); + +// 调用方要据此决定发布成功那句话提不提审核 —— 这个数它已经在手里(begun.assets), +// 为一句文案再往 hub 跑一趟是白跑。 +test("publishCurrentConfig reports how many assets it uploaded", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /result:\s*0,\s*id:\s*done\.id,\s*assets:\s*entries\.length/, + "the caller cannot word the review notice without knowing whether anything was uploaded", + ); +}); + +// --------------------------------------------------------------------------- +// Schema compat + notices + +// fetch 同样只在方法体内被引用,所以和 localStorage 一样挂到 globalThis 上。 +// respond 收到 URL,返回 { status, body } 或抛错(断网)。 +async function withFetch(respond, fn) { + const calls = []; + const prev = globalThis.fetch; + globalThis.fetch = async (url) => { + calls.push(String(url)); + const { status, body } = await respond(String(url)); + return { status, ok: status >= 200 && status < 300, json: async () => body }; + }; + try { + return await fn(calls); + } finally { + globalThis.fetch = prev; + } +} + +// withLocalStorage 在 fn 返回的那一刻就拆桩,等不了 promise。 +async function withStorage(fn) { + const store = new Map(); + const had = "localStorage" in globalThis; + const prev = globalThis.localStorage; + globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + }; + try { + return await fn(store); + } finally { + if (had) globalThis.localStorage = prev; + else delete globalThis.localStorage; + } +} + +// refreshNotices 用到 L.resolveDefault;LuCI 里它是全局的。 +async function withLuci(fn) { + const had = "L" in globalThis; + const prev = globalThis.L; + globalThis.L = { + resolveDefault: (promise, fallback) => Promise.resolve(promise).catch(() => fallback), + }; + try { + return await fn(); + } finally { + if (had) globalThis.L = prev; + else delete globalThis.L; + } +} + +// 记录每一次 ubus 调用;me 是 hub_me 的应答。 +const recordingRpc = (me, init) => { + const calls = []; + return { + calls, + declare: (spec) => () => { + calls.push(spec.method); + return Promise.resolve( + spec.method === "hub_me" ? me : spec.method === "get_init_data" ? init || null : null, + ); + }, + }; +}; + +const feedNotice = (over) => ({ + id: "n1", + level: "warning", + audience: "all", + title: "Heads up", + body: "Something changed.", + url: "", + i18n: {}, + at: "", + ...over, +}); + +test("the client schema is one named constant, and the host string is not repeated", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^const CLIENT_SCHEMA = 1;$/m); + assert.equal((await load()).CLIENT_SCHEMA, 1); + assert.equal(src.split("themes.eamonxg.fun").length - 1, 1, "HUB_BASE must stay the only copy"); + for (const rel of ["utils/notices.js", "preload/aurora-notices.js"]) + assert.ok( + !(await readFile(srcPath(rel), "utf8")).includes("eamonxg"), + `${rel} must reach the hub through hub-api`, + ); +}); + +test("callHubList tells the hub which schema this client reads", async () => { + const m = await load(); + await withFetch( + () => ({ status: 200, body: { items: [] } }), + async (calls) => { + await m.callHubList("new", 3); + await m.callHubList("bogus", -1); + assert.deepEqual(calls, [ + "https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=new&page=3&schema=1", + "https://themes.eamonxg.fun/api/v1/themes/aurora/configs?sort=hot&page=1&schema=1", + ]); + }, + ); +}); + +test("callHubNotices reads the public feed straight from the browser", async () => { + const m = await load(); + await withFetch( + () => ({ status: 200, body: { notices: [feedNotice()] } }), + async (calls) => { + const res = await m.callHubNotices(); + assert.deepEqual(calls, ["https://themes.eamonxg.fun/api/v1/notices?theme=aurora&schema=1"]); + assert.deepEqual(res, { result: 0, data: { notices: [feedNotice()] } }); + }, + ); +}); + +test("a hub that has not deployed the feed yet means no notices, not an error", async () => { + const m = await load(); + await withFetch( + () => ({ status: 404, body: { error: "not_found" } }), + async () => { + assert.deepEqual(await m.callHubNotices(), { result: 0, data: { notices: [] } }); + // The same 404 still means "gone" everywhere else. + assert.deepEqual(await m.callHubGet("abc123"), { result: 1, error: "invalid_id" }); + }, + ); + await withFetch( + () => ({ status: 500, body: {} }), + async () => + assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }), + ); + await withFetch( + () => { + throw new TypeError("network down"); + }, + async () => + assert.deepEqual(await m.callHubNotices(), { result: 1, error: "hub_unreachable" }), + ); +}); + +test("inbox state lives under its documented keys; the dismissed list is gone", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(!src.includes("noticesDismissed"), "replaced by inboxRead / inboxDone"); + const m = await load(); + withLocalStorage((store) => { + assert.equal(m.noticesCache.getStale(), null); + assert.equal(m.noticesCheckedAt(), 0); + assert.deepEqual(m.inboxState(), { read: [], done: [] }); + + m.noticesCache.set({ notices: [] }); + m.markNoticesChecked(1790000000000); + m.setInboxState({ read: ["n1", "rejected:c1"], done: ["n2"] }); + + assert.deepEqual([...store.keys()].sort(), [ + "aurora.hub.inboxDone", + "aurora.hub.inboxRead", + "aurora.hub.notices", + "aurora.hub.noticesChecked", + ]); + assert.deepEqual(m.noticesCache.getStale(), { notices: [] }); + assert.equal(m.noticesCheckedAt(), 1790000000000); + assert.deepEqual(m.inboxState(), { read: ["n1", "rejected:c1"], done: ["n2"] }); + }); +}); + +test("inbox state survives corrupt, hostile and unavailable storage", async () => { + const m = await load(); + withLocalStorage((store) => { + store.set("aurora.hub.noticesChecked", "{not json"); + store.set("aurora.hub.inboxRead", "{not json"); + store.set("aurora.hub.inboxDone", '{"length":1,"0":"n1"}'); + assert.equal(m.noticesCheckedAt(), 0); + assert.deepEqual(m.inboxState(), { read: [], done: [] }); + + store.set("aurora.hub.noticesChecked", '"1790000000000"'); + store.set("aurora.hub.inboxRead", '["n1",7,null,{"id":"x"},"n2"]'); + assert.equal(m.noticesCheckedAt(), 0); + assert.deepEqual(m.inboxState().read, ["n1", "n2"]); + }); + + // Safari private mode, a full quota, storage disabled by policy: every + // accessor throws. None of it may reach the page. + const had = "localStorage" in globalThis; + const prev = globalThis.localStorage; + const boom = () => { + throw new Error("SecurityError"); + }; + globalThis.localStorage = { getItem: boom, setItem: boom, removeItem: boom }; + const prevError = console.error; + console.error = () => {}; + try { + assert.equal(m.noticesCheckedAt(), 0); + assert.deepEqual(m.inboxState(), { read: [], done: [] }); + assert.equal(m.noticesCache.getStale(), null); + assert.doesNotThrow(() => m.markNoticesChecked(1)); + assert.doesNotThrow(() => m.setInboxState({ read: ["n1"], done: [] })); + assert.doesNotThrow(() => m.noticesCache.set({ notices: [] })); + assert.doesNotThrow(() => m.noticesCache.clear()); + assert.doesNotThrow(() => m.setNoticesLocal(null)); + } finally { + console.error = prevError; + if (had) globalThis.localStorage = prev; + else delete globalThis.localStorage; + } + + // With no localStorage at all the reference itself throws. + assert.equal(m.noticesCheckedAt(), 0); + assert.doesNotThrow(() => m.markNoticesChecked(1)); +}); + +const meReply = (...configs) => ({ result: 0, data: { id: "me", nickname: "eamon", configs } }); +const ownShare = (over) => ({ id: "c1", name: "Nord Night", status: "active", ...over }); + +test("refreshNotices caches a sanitized feed, stamps the check and prunes read/done keys", async () => { + const rpc = recordingRpc(null); + const m = await load(rpc); + await withLuci(() => + withFetch( + () => ({ + status: 200, + body: { + notices: [ + feedNotice({ id: "keep" }), + feedNotice({ id: "bad id" }), + feedNotice({ id: "info1", level: "info", junk: "'; + const tree = m.render(hostile + "\n\nbold? & ", makeDoc(), allow); + assert.deepEqual(tags(tree), ["p", "p"]); + assert.equal(show(tree), `

${hostile}

bold? &

`); + const allowed = ["p", "h4", "h5", "h6", "strong", "em", "code", "pre", "ul", "ol", "li", "blockquote", "hr", "a"]; + const everything = "# h\n\n**b** *i* `c` [l](https://a.b/)\n\n- x\n\n1. y\n\n> q\n\n---\n\n```\nz\n```\n\n| a | b |\n|---|---|"; + for (const tag of tags(m.render(everything, makeDoc(), allow))) assert.ok(allowed.includes(tag), tag); + assert.equal(html(m, "| a | b |"), "

| a | b |

", "tables are outside the subset"); +}); + +test("markdown: control characters are dropped, other input is coerced to nothing", async () => { + const m = await load(); + const dirty = "a" + String.fromCharCode(0) + "b" + String.fromCharCode(27) + "[31mc" + String.fromCharCode(127) + "\r\nd\te"; + assert.equal(html(m, dirty), "

ab[31mc d\te

"); + for (const junk of [null, undefined, 5, {}, ["**x**"], { toString: () => "**x**" }]) { + assert.equal(html(m, junk), ""); + assert.equal(m.toText(junk), ""); + } + assert.equal(html(m, ""), ""); + assert.equal(html(m, "\n\n \n"), ""); +}); + +test("markdown: unterminated markers fall back to the characters that were typed", async () => { + const m = await load(); + assert.equal(html(m, "**never closed"), "

**never closed

"); + assert.equal(html(m, "*never closed"), "

*never closed

"); + assert.equal(html(m, "`never closed"), "

`never closed

"); + assert.equal(html(m, "[never closed"), "

[never closed

"); + assert.equal(html(m, "[text](never closed"), "

[text](never closed

"); + assert.equal(html(m, "[](https://a.b/)"), "

[](https://a.b/)

"); + assert.equal(html(m, "[x]()"), "

[x]()

"); + assert.equal(html(m, "****"), "

****

"); + assert.equal(html(m, "``"), "

``

"); + assert.equal(html(m, "***x***"), "

*x*

"); + // Emphasis nests to a fixed depth; past it the markers are text. + assert.equal(html(m, "**a *b `c` **d** e* f**").includes(" { + const m = await load(); + const N = 200000; + const cases = { + "open brackets": "[".repeat(N), + "nested brackets": "[a".repeat(N / 2) + "](https://a.b/)", + "bracket pairs": "[]".repeat(N / 2), + "half links": "[a](".repeat(N / 4), + stars: "*".repeat(N), + "spaced stars": "* a".repeat(N / 3), + "star pairs": "**a ".repeat(N / 4), + "unclosed strong then singles": "**" + "*a".repeat(N / 2), + backticks: "`".repeat(N), + "backtick words": "`a ".repeat(N / 3) , + "one huge line": "x".repeat(N), + "many lines": "- x\n".repeat(N / 4), + "many fences": "```\n".repeat(N / 4), + "many quotes": "> ".repeat(N / 2), + hashes: "#".repeat(N), + "nested emphasis": "**a *b ".repeat(N / 8) + " b* a**".repeat(N / 8), + }; + for (const [name, input] of Object.entries(cases)) { + const started = process.hrtime.bigint(); + m.render(input, makeDoc(), allow); + m.toText(input); + const ms = Number(process.hrtime.bigint() - started) / 1e6; + // Linear work on 200 KB is a few milliseconds; a quadratic path is minutes. + assert.ok(ms < 1500, `${name}: ${ms.toFixed(0)} ms`); + } +}); + +test("markdownToText: the first line that says something, without its markers", async () => { + const m = await load(); + assert.equal(m.toText("Browsing keeps working; **publishing** is paused.\n\nMore."), "Browsing keeps working; publishing is paused."); + assert.equal(m.toText("\n\n## Heads *up*\n\nbody"), "Heads up"); + assert.equal(m.toText("- first item\n- second"), "first item"); + assert.equal(m.toText("3. third"), "third"); + assert.equal(m.toText("> quoted `code`"), "quoted code"); + assert.equal(m.toText("---\n\nafter the rule"), "after the rule"); + assert.equal(m.toText("```\n\nuci show\n```"), "uci show"); + assert.equal(m.toText("See [the notes](https://a.b/) now"), "See the notes now"); + assert.equal(m.toText("[x](javascript:alert)"), "x"); + assert.equal(m.toText("line one\nline two"), "line one"); + assert.equal(m.toText("raw"), "raw"); + assert.equal(m.toText(" "), ""); +}); diff --git a/luci-app-aurora-config/.dev/tests/marketplace-view.test.mjs b/luci-app-aurora-config/.dev/tests/marketplace-view.test.mjs new file mode 100644 index 00000000..8be52c1d --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/marketplace-view.test.mjs @@ -0,0 +1,2010 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { repo, srcPath } from "./paths.mjs"; + +const SRC = srcPath("view/aurora/marketplace.js"); +const MENU_SRC = repo("root/usr/share/luci/menu.d/luci-app-aurora.json"); + +test("gallery view is a browse-only view.extend using hub-api", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^"require view";/m); + assert.match(src, /require utils\.hub-api as hubApi/); + assert.match(src, /return view\.extend\(/); + assert.ok(src.includes("callHubList"), "missing callHubList usage"); + assert.ok(src.includes("callHubGet"), "missing callHubGet usage"); + assert.ok(src.includes("hub-cards"), "missing #hub-cards grid"); + assert.match(src, /style\.backgroundColor/); + assert.ok(src.includes("ui.showModal"), "missing detail modal"); + assert.ok(src.includes("getStale"), "missing stale-cache paint"); + assert.ok(src.includes("listCache.set"), "missing cache write on refresh"); +}); + +test("gallery view is browse-only this task -- no apply/share actions yet", () => { + return readFile(SRC, "utf8").then((src) => { + assert.match(src, /handleSave:\s*null/); + assert.match(src, /handleSaveApply:\s*null/); + assert.match(src, /handleReset:\s*null/); + }); +}); + +test("gallery view never assigns innerHTML with hub-sourced data", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(!src.includes(".innerHTML"), "no innerHTML with user data"); +}); + +test("gallery view: hub config name always flows through document.createTextNode, never a bare E() child", async () => { + const src = await readFile(SRC, "utf8"); + // Positive assertion (not just the negative innerHTML check above): every + // site that renders item.name must wrap it in createTextNode -- a bare + // string child of E()/ui.showModal can be routed through innerHTML by + // LuCI, and the hub only strips control characters from names, so a name + // like "" is otherwise legal hub content. + // + // The card site no longer writes item.name directly: buildOnlineCard hands + // buildCard a model whose `name` field is `item.name || _(...)`, and + // buildCard is what wraps model.name in createTextNode. Both hops must be + // in place, or the card could end up passing the raw hub string as a bare + // E() child. + assert.match( + src, + /name:\s*item\.name\s*\|\|/, + "buildOnlineCard must feed item.name into the shared card model", + ); + assert.match( + src, + /document\.createTextNode\(\s*model\.name\s*\)/, + "buildCard must wrap model.name in createTextNode", + ); + const nameTextNodeSites = + (src.match(/document\.createTextNode\(\s*item\.name\b/g) || []).length + + (src.match(/document\.createTextNode\(\s*model\.name\b/g) || []).length; + assert.ok( + nameTextNodeSites >= 3, + `expected item.name to reach the DOM via createTextNode at every render site (card, drawer heading, my-shares row); found ${nameTextNodeSites}`, + ); + // Modal titles must be static, never the raw hub name. + assert.ok( + !/ui\.showModal\(\s*item\.name/.test(src), + "modal title must never be item.name directly", + ); +}); + +test("gallery view: marketplace layout with detail drawer (Phase 2)", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("aurora-store-drawer"), "missing detail drawer"); + assert.ok(src.includes("aurora-store-grid"), "missing card grid class"); + assert.match( + src, + /require utils\.theme-preview as themePreview/, + "missing shared preview module (light/dark split lives there)", + ); + const preview = await readFile( + srcPath("utils/theme-preview.js"), + "utf8", + ); + assert.ok(preview.includes("clipPath"), "missing light/dark split preview"); + assert.match(preview, /HEX_RE/, "preview module must sanitise palette hex values"); + assert.match(src, /_\("All"\)/, "missing All tab"); + assert.match(src, /_\("Built-in"\)/, "missing Built-in tab"); + assert.match(src, /_\("My Shares"\)/, "missing My Shares tab"); +}); + +test("gallery view: built-in presets render offline and apply locally (Phase 2)", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("aurora/presets.json"), "missing local presets data source"); + assert.match(src, /method:\s*"apply_theme_preset"/, "built-in apply must stay on the local rpcd path"); + assert.match(src, /_\("Sage Green"\)/, "missing built-in preset labels"); + assert.ok(src.includes("isBuiltinSeed"), "official hub seeds must be deduped against built-ins"); +}); + +test("gallery view: apply flow calls hub_apply and polls get_hub_status like pollFontCache", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubApply"), "missing callHubApply usage"); + assert.ok(src.includes("callGetHubStatus"), "missing callGetHubStatus usage"); + assert.match(src, /window\.setTimeout/, "missing setTimeout-based polling"); + assert.match(src, /1500/, "missing 1.5s poll interval"); +}); + +// header.ut renders the whole look server-side -- it readfile()s the font CSS +// into the document and stamps every image URL with icon_cache_version -- so +// all of it is evaluated once, at document load. Updating the banner in place +// leaves the user looking at the theme they just replaced. +test("gallery view: a finished online apply reloads the page, not just the banner", async () => { + const src = await readFile(SRC, "utf8"); + const doneBranch = src.match( + /status\.state === "done"\)\s*\{([\s\S]*?)\}\s*else if/, + ); + assert.ok(doneBranch, "missing the done branch of the apply poll"); + assert.ok( + doneBranch[1].includes("window.location.reload"), + "a completed apply must reload -- otherwise the page still shows the previous theme", + ); +}); + +// 审核和「身份没备份」是两件无关的事。拼成一条 msgid,加一个维度就翻一倍。 +test("gallery view: the publish notice keeps its two dimensions apart", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok( + src.includes("Published. Its assets are queued for review"), + "a publish that uploaded assets must say they are queued", + ); + assert.ok( + !/Published\. Your creator identity/.test(src), + "the identity warning must stand alone, not be fused into the Published string", + ); + assert.ok( + src.includes("Your creator identity lives only on this router"), + "the identity warning must survive as its own msgid", + ); +}); + +// 作者发布完会立刻去商店找自己的作品。审核中的作品不在那儿,而这一行是 +// 唯一能解释「它去哪了」的地方 —— 没有它,发布成功读起来就是发布失败。 +test("gallery view: my-shares explains a review state, and stays silent otherwise", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const reviewNoteFor\s*=/, "missing the review-note helper"); + assert.ok( + src.includes('status === "pending"'), + "a queued share must say it is in review", + ); + assert.ok( + src.includes('status === "rejected"'), + "a rejected share must say its assets were turned down", + ); + assert.ok( + !/_\("[^"]*\bimages are approved/.test(src) && !/_\("Images not approved/.test(src), + "review copy must say assets, not images -- fonts go through the same queue", + ); + // 正常态不出声:helper 必须有一条 return null 的路径,而不是给每种状态都 + // 造一行字。 + assert.match( + src.slice(src.indexOf("const reviewNoteFor")), + /return null;/, + "none/approved must render no note at all", + ); + assert.ok( + src.includes("reviewNoteFor(item)"), + "buildMyShareRow must consult the helper", + ); + // 驳回时审核员写的那句话必须真的显示出来 —— 没有它,作者只知道被驳了, + // 不知道为什么,只能换张图碰运气。 + assert.ok( + src.includes("item.assets_reject_reason"), + "a rejection must carry the reviewer's own words through to the author", + ); +}); + +// rejectConfig 删掉待审的 assets 行和 R2 对象后把 assets_status 置为 +// 'rejected' —— 那条配置一张图都不剩了,徽章却还在说它带着素材。 +test("gallery view: only assets that cleared review earn the card badge", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok( + src.includes('item.assets_status === "approved"'), + "the card badge must key off approved, not merely non-none", + ); + assert.ok( + !/assets_status\s*&&\s*item\.assets_status\s*!==\s*"none"/.test(src), + "the non-none badge test would keep badging a rejected config", + ); + assert.ok( + src.includes('if (status !== "approved") return null;'), + "buildLegacyCardTiles must narrow to approved too", + ); + assert.ok( + !/status\s*===\s*"none"\)\s*return null/.test(src), + "the old none-only guard must be gone, not merely shadowed", + ); +}); + +test("gallery view: external toolbar URLs are surfaced in plaintext before applying", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /toolbar/); + assert.match(src, /url\.startsWith\(\s*"http/, "missing http(s) URL check on toolbar entries"); + assert.ok(src.includes("createTextNode"), "external URLs must render via textContent, not innerHTML"); +}); + +// 横幅没了。「正在使用什么」由卡上的钩子回答,「怎么回去」由「我的配置」那张 +// 卡回答 —— 两个问题各有归属,顶部不需要再压一条把其中一个说第二遍。 +test("store view: the banner and the standing rollback button are gone", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(!src.includes("renderBanner"), "renderBanner must be gone"); + assert.ok(!src.includes("aurora-hub-banner"), "the banner element must be gone"); + assert.ok(!src.includes("aurora-store-applied"), "the banner styles must be gone"); + assert.ok( + !/_\("Restore previous configuration"\)/.test(src), + "rolling back is clicking your own card, not a button of its own", + ); +}); + +test("store view: rolling back still goes through callHubRestore, from the card", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /require uci/); + assert.ok(src.includes("callHubRestore"), "missing callHubRestore usage"); + assert.ok(src.includes("window.location.reload"), "missing reload after restore"); + assert.match( + src, + /apply: \(\) => confirmRestore\(\)/, + "the My-configuration card's apply action is the rollback", + ); + // 引号内的才算读取:注释里提到它是允许的,那句话正是在解释为什么不再读。 + assert.ok( + !/"hub_applied"/.test(src), + "matching must not read hub_applied any more -- it holds a name, not an id", + ); +}); + +// 三类卡的选中态出自同一个比较。社区卡曾经写死 current:false,所以它永远不可能 +// 被选中 —— 那正是应用了社区主题、钩子却留在内置卡上的原因之一。 +test("store view: every card's tick comes from active_source plus modified", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /callHubLocalState/); + assert.match(src, /activeSource === "builtin:" \+ preset\.id/); + assert.match(src, /activeSource === "hub:" \+ item\.id/); + assert.ok( + !/current: false/.test(src), + "a community card must be able to hold the tick", + ); + assert.ok( + !/preset\.id === activePreset/.test(src), + "matching must not read active_preset any more", + ); +}); + +// 「我的配置」和另两类卡共用同一套预览访问器 —— 三种卡,一套访问器,同一个外观 +// 不可能被画成两个样子。 +test("store view: the My-configuration card draws through the shared accessors", async () => { + const src = await readFile(SRC, "utf8"); + const start = src.indexOf("const buildMineModel"); + assert.ok(start !== -1, "buildMineModel is missing"); + const block = src.slice(start, start + 900); + assert.match(block, /paletteOf\(/); + assert.match(block, /previewOpts\(/); + assert.match(block, /buildCardGlyphs\(/); + assert.match(block, /name: _\("My configuration"\)/); + assert.match(block, /current: modified/); +}); + +test("store view: one slot -- current uci when modified, the backup when not", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /modified \? currentPreview\(\) : backupPreview/); + assert.match( + src, + /if \(!modified && !backupPreview\) return null;/, + "with nothing of your own and nothing to go back to, the section must not render", + ); +}); + +test("store view: Mine comes before Built-in and Community", async () => { + const src = await readFile(SRC, "utf8"); + const render = src.slice(src.indexOf("const renderContent")); + const mine = render.indexOf("buildMineGrid"); + const builtin = render.indexOf("buildBuiltinGrid"); + const online = render.indexOf("buildOnlineGrid"); + assert.ok(mine !== -1, "the Mine section is missing"); + assert.ok( + mine < builtin && mine < online, + "the card you are using should be the first one you see", + ); +}); + +test("gallery view: apply/restore error copy stays result-only (no mechanism words)", async () => { + const src = await readFile(SRC, "utf8"); + const mechanismLeaks = [ + /_\(\s*["'][^"']*\bjob\b[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*\bschema\b[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*bad_payload[^"']*["']\s*\)/i, + ]; + mechanismLeaks.forEach((re) => assert.ok(!re.test(src), `mechanism word leaked via ${re}`)); +}); + +test("gallery view: the store survives a phone", async () => { + const src = await readFile(SRC, "utf8"); + + // 报出来的那个 bug:操作单元格曾经是 `td center` + white-space:nowrap, + // "Update with current configuration" 和 "Delete" 被钉在同一行不许断开 —— + // 390px 的手机上整行撑到 748px,Delete 掉到屏幕外面去了。 + // + // 改成 LuCI 自己的 td.cbi-section-actions > div:这一层结构 luci-theme-aurora + // 和 luci-theme-bootstrap 都认,窄屏下把单元格拉成整行、让里面的按钮换行。 + // 换句话说这里靠的是原生类名,不是本文件另写一条媒体查询。 + assert.match( + src, + /class: "td cbi-section-actions"/, + "the my-shares action cell must be a native LuCI section-actions cell", + ); + assert.ok( + !/class: "td center", style: "white-space:nowrap;"/.test(src), + "the action cell must not pin its buttons to one unbreakable line again", + ); + + // 卡片上的快捷 Apply 按钮靠 :hover 显形。触摸屏没有 hover —— 手机上那颗 + // 按钮以前根本按不到,只能先开抽屉。按指针类型判断而不是按宽度:窄窗口的 + // 桌面浏览器仍然有鼠标。 + assert.ok( + src.includes("@media (hover:none){.aurora-store-acts{opacity:1;}}"), + "the card's quick-apply button must be reachable without a hover", + ); +}); + +test("gallery view: share panel and my-shares management (Task 8)", async () => { + const src = await readFile(SRC, "utf8"); + // 发布和更新都走 publishCurrentConfig:资产字节由浏览器直传,路由器只发 + // 几 KB 的 begin/commit。原来的 callHubShare/callHubUpdate 把整张图 base64 + // 塞进一个请求,uclient-fetch 走 TLS 推不动。 + assert.ok(src.includes("publishCurrentConfig"), "missing publishCurrentConfig usage"); + assert.ok(src.includes("callHubMe"), "missing callHubMe usage"); + assert.ok(src.includes("callHubDelete"), "missing callHubDelete usage"); + assert.ok(src.includes("confirmDelete"), "missing confirmDelete reuse for delete confirms"); + assert.match(src, /require utils\.asset-upload as assetUpload/); +}); + +// 原意保留:更新必须带上那条分享的现有名字,否则 hub 的必填校验会拒掉它。 +// 现在这件事发生在打开表单的那一刻 —— 名字被填进输入框,提交时随之送出。 +test("gallery view: updating a share carries the existing name", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /const openUpdateForm = \(item\) => \{[\s\S]*?nameInput\.value = item\.name \|\| "";/, + "openUpdateForm must seed the name field so the update is not sent nameless", + ); +}); + +test("gallery view: share/update/delete copy stays result-only (no mechanism words)", async () => { + const src = await readFile(SRC, "utf8"); + const mechanismLeaks = [ + /_\(\s*["'][^"']*\bjob\b[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*\bschema\b[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*\btoken\b[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*bad_payload[^"']*["']\s*\)/i, + /_\(\s*["'][^"']*pending[^"']*["']\s*\)/i, + ]; + mechanismLeaks.forEach((re) => assert.ok(!re.test(src), `mechanism word leaked via ${re}`)); +}); + +test("menu: theme store entry registered after theme settings", async () => { + const menu = JSON.parse(await readFile(MENU_SRC, "utf8")); + assert.ok(menu["admin/system/aurora/marketplace"], "gallery menu entry missing"); + assert.equal(menu["admin/system/aurora/marketplace"].order, 15); + assert.equal( + menu["admin/system/aurora/marketplace"].action.path, + "aurora/marketplace", + ); +}); + +test("gallery view: nav_type reaches the preview instead of being swallowed", async () => { + const src = await readFile(SRC, "utf8"); + // 这一行曾经是 (palette) => themePreview.buildDuo(palette),把 opts 丢掉了, + // 于是 buildMini 的侧边栏分支从未渲染过。 + assert.match( + src, + /const buildDuo = \(palette, opts\) => themePreview\.buildDuo\(palette, opts\)/, + "buildDuo wrapper must forward opts", + ); + assert.match(src, /uci\.get\("aurora", "theme", "nav_type"\)/, "current nav_type not read"); + // 曾经叫 payloadNav,只看得到详情 payload,所以社区卡片一律画顶栏。 + // 列表行现在带 preview.layout,navOf 两种形状都读。 + assert.match(src, /const navOf = /, "nav_type helper missing"); +}); + +test("gallery view: built-in preset previews draw the preset's own nav_type", async () => { + const src = await readFile(SRC, "utf8"); + // 内置预设过去只写 62 个色值,导航保持不变,所以缩略图画的是「你当前的导航 + // 形态」。现在预设自己带 nav_type,画当前形态就是画错的东西 —— 卡片和抽屉 + // 都走 previewOpts,和社区卡片同一条路径。 + assert.match(src, /opts:\s*previewOpts\(preset\)/, "built-in card must draw its own nav"); + assert.match( + src, + /buildPanes\(paletteOf\(preset\), previewOpts\(preset\)\)/, + "the built-in drawer must draw the preset's own nav too", + ); + assert.match( + src, + /buildDuo\(model\.palette, model\.opts\)/, + "buildCard must forward model.opts to buildDuo", + ); + // currentNav 还在,但只服务发布面板 —— 那里描述的是"你自己的配置"。 + assert.ok( + !/opts:\s*\{\s*nav:\s*currentNav\s*\}/.test(src), + "no preview may still stand in the current nav for a preset's own", + ); +}); + +test("gallery view: the drawer shows light and dark side by side, not a diagonal split", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const buildPanes = /, "buildPanes helper missing"); + assert.match(src, /aurora-store-panes/, "panes CSS class missing"); + assert.match(src, /themePreview\.buildMini/, "panes must draw two separate minis"); + // 抽屉里不该再出现斜切双联 + assert.ok( + !/aurora-store-drawer-prev" \}, \[buildDuo\(/.test(src), + "drawer still renders the diagonal duo", + ); +}); + +test("gallery view: tiles list what the preview cannot draw", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const buildTiles = /); + // 卡片和抽屉共用同一份 entry 列表(tileEntriesFor)—— 两个各自为政的版本 + // 已经把同一份配置说成过两回事。老 hub(没有 preview)才退回粗粒度的那块。 + assert.match(src, /const tileEntriesFor = /); + assert.match(src, /const buildLegacyCardTiles = /); + assert.match(src, /const buildBuiltinTiles = /); + // 字体显示族名,不是预设 id —— "geist-sans" 是机制词 + assert.match(src, /const firstFontFamily = /); + assert.match(src, /struct_font_sans/); + assert.match(src, /const radiusLabel = /); + // 标签必须走 createTextNode + assert.ok(!src.includes(".innerHTML")); +}); + +test("gallery view: one source of truth for what a config carries", async () => { + const src = await readFile(SRC, "utf8"); + // 卡片要的是 glyph,抽屉要的是完整 tiles,而且抽屉还要滤掉已经在别处说过的 + // 那几种。两个消费者各自遍历 payload 会重新打开 tileEntriesFor 上方注释 + // 早就关掉的那扇门 —— 同一份配置被说成两回事。 + assert.match(src, /const tileEntriesFor = /, "entry builder missing"); + assert.match( + src, + /const buildCardGlyphs = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);/, + "buildCardGlyphs must consume tileEntriesFor rather than rebuild the list", + ); + assert.match( + src, + /const buildBundledTiles = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);/, + "buildBundledTiles must consume tileEntriesFor rather than rebuild the list", + ); + for (const kind of ["font", "radius", "logo", "loginBg", "siteIcon", "appIcon", "toolbar"]) { + assert.ok(src.includes(`kind: "${kind}"`), `entry kind ${kind} missing`); + } + // 老 hub 的退化路径和"仅颜色"的兜底都还在 —— 不只是定义在别处,而是 + // buildBundledTiles 的两个分支真的调用了它们。否则两条退路可以被静默地 + // 换成 `return null;`,谁也不会发现配置内容凭空消失了。 + assert.match(src, /const buildLegacyCardTiles = /); + assert.match(src, /const buildBuiltinTiles = /); + assert.match( + src, + /const buildBundledTiles = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);\s*\n\s*if \(!entries\) return buildLegacyCardTiles\(item\);\s*\n\s*if \(!entries\.length\) return buildBuiltinTiles\(\);/, + "buildBundledTiles must actually call the legacy-hub and colors-only fallbacks, not just define them", + ); +}); + +test("gallery view: only a hub without preview degrades to assets_status", async () => { + const src = await readFile(SRC, "utf8"); + // 以前列表接口不返回 layout/typography/toolbar,卡片只能用 assets_status。 + // 现在它返回了,所以这条退路只留给还没升级的 hub —— 而且必须留:新客户端 + // 对着老 hub 也得画得出来。降级理由要写在注释里。 + assert.match(src, /assets_status/); + assert.match( + src, + /predates the preview projection/i, + "the degradation must be explained in a comment", + ); +}); + +// 这条测试当初禁掉了整套自制胶囊控件。搜索框那一半继续有效 —— 它从来不是 +// 问题所在。标签那一半是有意反转的:下划线选中态在深色主题下读作"没有", +// 一块有底色的实心分段在任何主题色下都成立。 +// 见 docs/specs/2026-08-04-theme-store-visual-redesign.md 的"明确反转"一节。 +test("gallery view: a one-row header with a segmented filter row", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /class: "cbi-input-text"/, "search must stay a standard LuCI input"); + assert.ok(!src.includes("🔍"), "emoji glyph must go"); + assert.ok(!src.includes("aurora-store-search"), "bespoke search pill must go"); + assert.ok(!src.includes(".innerHTML")); + + // 标题那条断言没了:页头里已经不放标题,tab 条自己写着"主题市场"。 + // 见下方 "the tab strip names the page, so the head carries no heading"。 + // 那句总说明删掉了 —— 分区副标题接管了它的活 + assert.ok(!src.includes("cbi-map-descr"), "the blanket description must give way to section subtitles"); + + // 选中的标签是一块填色的分段,不是一道下划线 + assert.ok( + !src.includes("border-bottom-color:var(--brand"), + "underline active state must go", + ); + assert.ok( + src.includes(".aurora-store-filters button.active{background:"), + "active filter must be a filled segment", + ); + assert.match(src, /const buildSectionTitle = /, "section subtitle builder missing"); + assert.match(src, /const renderTabLabel = /, "tab label/count builder missing"); + + // 一行,不是两行:tabs、弹性间隔、搜索框是同一个 .aurora-store-head 的三个 + // 子元素。之前搜索框自己占一行,而它左边什么都没有,那一整条就是空白。 + assert.match( + src, + /const headEl = E\(\s*"div",\s*\{ class: "aurora-store-head" \},\s*\[\s*tabsEl,\s*E\("span", \{ class: "sp" \}\),\s*searchInput,/, + "tabs, spacer and search must sit in one .aurora-store-head row, in that order", + ); + // margin-top 会把胶囊组从搜索框的中线上推开 —— 行距归 .aurora-store-head 管。 + assert.ok( + !/\.aurora-store-filters\{[^}]*margin-top/.test(src), + "the filter pills must not carry their own top margin inside the shared row", + ); +}); + +test("gallery view: sharing says what gets shared, inline rather than in a modal", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const buildSharePanel = /, "inline share panel missing"); + assert.match(src, /const shareManifestRows = /, "share manifest missing"); + assert.ok( + !/ui\.showModal\(\s*_\("Share My Configuration"\)/.test(src), + "share must no longer be a modal", + ); + assert.match(src, /const loginBgFilename = /); + + // 图片那几行的体积来自 rpcd 的 shared_images,而不是这里自己数 uci —— + // 问的是 build_share_payload 打包时问的同一个函数,所以面板上说要发什么、 + // 线路上发的就是什么。字体那两行早就是这个姿态,这次图片跟上了。 + assert.match(src, /sharedImages\.forEach\(/); + assert.match(src, /_\("Included, %s"\)\.format\(/); + assert.ok( + !/detail: _\("Included"\)/.test(src), + "一句光秃秃的 Included 回答不了「要不要装到我的路由器上」", + ); + + // 超限的那张必须单独说。它现在会被 build_share_payload 跳过 —— 沉默地少发 + // 一张图,用户只会在别人的截图里发现它没了;而在此之前更糟:hub 拒掉整次 + // 分享,屏幕上只有一句"商店拒绝了这份配置"。 + assert.match(src, /_\("Too large to share \(%s; the store's limit is %s\)"\)/); +}); + +// 体积曾经只给字体写。那条界线站不住:一张 1.8 MB 的登录背景和一份 2 MB 的 +// 中文字体,对 flash 只剩一两 MB 的设备是同一个问题,而商店里只有后者说了实话。 +// 现在发布侧和使用侧都逐项带体积,字体额外多两句(它落在别处、升级不保留)。 +test("gallery view: every asset carries its size, on both sides", async () => { + const src = await readFile(SRC, "utf8"); + + // 发布侧:我要发出去的这些东西各有多大。 + assert.match(src, /sharedFonts\.forEach\(/); + assert.match(src, /_\("Uploaded with the theme, %s"\)\.format\(/); + assert.match(src, /sharedImages\.forEach\(/); + assert.match(src, /sharedToolbarIcons\.filter\(/); + + // 使用侧:每个 tile 自己的体积,加一句图片总计。 + assert.match(src, /const assetBytesOf = /); + assert.match(src, /meta: sizeLabel\(assetBytesOf\(item, \["logo_svg"\]\)\)/); + assert.match(src, /meta: bgTileMeta\(item, \["login_bg"\], layout, "struct_login_bg"\)/); + assert.match(src, /meta: sizeLabel\(assetBytesOf\(item, SITE_ICON_KINDS\)\)/); + assert.match(src, /meta: sizeLabel\(assetBytesOf\(item, APP_ICON_KINDS\)\)/); + assert.match(src, /meta: sizeLabel\(assetBytesOf\(item, TOOLBAR_ICON_KINDS\)\)/); + assert.match(src, /_\("Images: %s in total\."\)/); + + // 字体那两句是额外的,不是替代:它们说的是"存在哪、升级后还在不在", + // 那是体积之外的信息。 + assert.match(src, /asset\.kind === "font_sans" \|\| asset\.kind === "font_mono"/); + assert.match(src, /Includes %s of font files/); + assert.match(src, /writable partition/); + assert.match(src, /firmware upgrade/); +}); + +// 字体和图标不是一回事:自己画的 logo 是自己的,而一份字体绝大多数情况下是 +// 别人的作品,多数商业授权明确不允许再分发。这句话必须在按下发布之前就在 +// 眼前 —— 而且只在真的要发字体时才出现,否则它就成了人人都学会跳过的噪音。 +test("gallery view: publishing a font asks about redistribution rights", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /if \(sharedFonts\.length\)/); + assert.match(src, /right to redistribute it/); + assert.match(src, /commercial font licences do not allow it/); +}); + +// 版权这件事两边都要说,但方向相反:发布面板问「你有权发吗」,详情抽屉只能 +// 说「这不是商店的字体,授权没人核实过」—— 说得更满就是替上传者担保。 +test("gallery view: the drawer says the licence is unverified", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /does not verify its licence/); +}); + +test("gallery view: a re-render must not destroy a half-typed share", async () => { + const src = await readFile(SRC, "utf8"); + // renderContent() 重建面板(搜索框每次按键、迟到的列表请求都会触发), + // 所以三个输入框必须建在 buildSharePanel 之外,重渲染时复用同一批节点。 + const panelAt = src.indexOf("const buildSharePanel = "); + assert.ok(panelAt !== -1, "buildSharePanel missing"); + ["nameInput", "descInput", "nicknameInput", "errEl", "submitBtn"].forEach( + (name) => { + const declAt = src.indexOf("const " + name + " = E("); + assert.ok(declAt !== -1, `${name} declaration missing`); + assert.ok( + declAt < panelAt, + `${name} is rebuilt by buildSharePanel, so a re-render throws away what the user typed`, + ); + }, + ); +}); + +test("gallery view: publishing stays disabled until the panel is gone", async () => { + const src = await readFile(SRC, "utf8"); + // 成功分支里提前解禁按钮,会在 my-shares 往返期间留下一个可再次点击的 + // 「发布」按钮 —— 第二次点击就是重复发布。 + assert.ok( + !/\.then\(\s*\(res\) => \{\s*endSubmit\(\);/.test(src), + "submit button must not be re-enabled before the result is known", + ); + assert.match( + src, + /\} else \{\s*endSubmit\(\);\s*showError\(/, + "the failure branch must be the one that re-enables the button", + ); + // 禁用是防重复提交,不是等待反馈 —— 一个按不动又没有任何变化的按钮读起来 + // 就是坏了。转圈和禁用一起加、一起撤。 + assert.match( + src, + /submitBtn\.disabled = true;\s*submitBtn\.classList\.add\("spinning"\);/, + "publishing must show the spinner, not just go dead", + ); + assert.match( + src, + /const endSubmit = \(\) => \{\s*submitBtn\.disabled = false;\s*submitBtn\.classList\.remove\("spinning"\);/, + "one restore path for all three endings", + ); + // claim 昵称那趟往返发生在第一个资源上传之前,进度行原先在那几秒里是空的。 + assert.match( + src, + /renderShareProgress\(\{ phase: "begin" \}\);/, + "the progress line must start before the nickname round trip, not after it", + ); +}); + +test("gallery view: the toolbar count is what the share actually sends", async () => { + const src = await readFile(SRC, "utf8"); + // theme.js 只校验非空,shell 会静默丢弃越界项并在 12 条处停止 —— + // 直接数 section 会承诺根本发不出去的快捷方式。 + assert.match(src, /const isSharedToolbarItem = /, "toolbar validation mirror missing"); + assert.match(src, /const MAX_SHARED_TOOLBAR_ITEMS = 12;/, "the cap of 12 is not mirrored"); + assert.match( + src, + /uci\.sections\("aurora", "toolbar_item"\) \|\| \[\]\)\.filter\(isSharedToolbarItem\)/, + "the count must filter, not just measure the section array", + ); + // 重复是有意的,必须写明抄自哪个函数的哪几行 + assert.match( + src, + /root\/usr\/libexec\/rpcd\/luci\.aurora, build_share_payload:/, + "the duplication must name the shell function it mirrors", + ); + assert.match(src, /lines 637-660/, "the mirrored line range must be named"); +}); + +test("share manifest skips exactly the filenames the theme itself ships", async () => { + const js = await readFile(SRC, "utf8"); + const shell = await readFile(repo("root/usr/libexec/rpcd/luci.aurora"), "utf8"); + + // shell 侧:is_theme_shipped_image 的两条 case 分支 + const fnMatch = /is_theme_shipped_image\(\) \{[\s\S]*?\n\}/.exec(shell); + assert.ok(fnMatch, "could not find is_theme_shipped_image in luci.aurora"); + const shellNames = [...fnMatch[0].matchAll(/^\t([A-Za-z0-9.\-|]+)\) return 0 ;;$/gm)] + .flatMap((m) => m[1].split("|")) + .sort(); + + const jsMatch = /const THEME_SHIPPED_NAMES = \[([\s\S]*?)\];/.exec(js); + assert.ok(jsMatch, "THEME_SHIPPED_NAMES not found in marketplace.js"); + const jsNames = [...jsMatch[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]).sort(); + + // A test comparing two empty arrays would pass while proving nothing -- + // make sure both captures actually found the names. + assert.equal(shellNames.length, 9, "shell-side capture did not find 9 names"); + assert.equal(jsNames.length, 9, "js-side capture did not find 9 names"); + + assert.deepEqual( + jsNames, + shellNames, + "the share manifest and the rpcd script disagree about which files the theme ships", + ); + // 默认工具栏那四个必须在里面:一条指着 overview.svg 的快捷方式不需要上传 + // 任何字节,而让它占掉一个 toolbar_icon_ 编号,接收端从那里往后的图标 + // 就全部错位。 + for (const name of ["network.svg", "overview.svg", "software.svg", "system.svg"]) { + assert.ok(jsNames.includes(name), `${name} must count as theme-shipped`); + } +}); + +// 扫的是代码不是散文:安全注释本身会点到被禁 API 的名字。 +const codeOf = (src) => src.replace(/^\s*\/\/.*$/gm, ""); + +test("gallery view reads item.preview and falls back to the deprecated palette", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const previewOf = /, "missing preview accessor"); + // A new client must still work against a hub that predates the projection. + assert.ok(src.includes("item.palette"), "palette fallback dropped"); + // ...and against a detail row, which carries the full payload instead. + assert.ok( + src.includes("payload.colors"), + "detail payload colors path dropped", + ); +}); + +test("gallery view: community cards draw the config's own navigation shape", async () => { + const src = await readFile(SRC, "utf8"); + // payloadNav only ever saw the detail payload, so every card drew a top + // bar. preview.layout.nav_type is on the list row now. + assert.ok(!src.includes("payloadNav"), "payloadNav must be replaced by navOf"); + assert.match(src, /const navOf = /, "missing navOf"); + // buildOnlineCard now hands the shared buildCard a model whose opts come + // from previewOpts(item) (== { nav: navOf(item), logo: logoUrlOf(item) }), + // and buildCard is what forwards opts to buildDuo. + assert.match( + src, + /opts:\s*previewOpts\(item\)/, + "the community card must pass previewOpts(item) as opts", + ); + assert.match( + src, + /const previewOpts = \(item\) => \(\{\s*nav:\s*navOf\(item\)/, + "previewOpts must derive nav from navOf(item)", + ); + assert.match( + src, + /buildDuo\(model\.palette, model\.opts\)/, + "buildCard must forward model.opts to buildDuo", + ); +}); + +test("gallery view: the logo reaches the page only as an img src", async () => { + const code = codeOf(await readFile(SRC, "utf8")); + assert.ok(!code.includes(".innerHTML"), "no innerHTML with hub data"); + assert.ok( + !/insertAdjacentHTML|DOMParser|createContextualFragment/.test(code), + "hub SVG must never be parsed as markup", + ); + // The url is built by hub-api's validating join, never string-concatenated + // from a hub-supplied path here. + assert.match(code, /hubApi\.hubAssetUrl\(/, "logo url must go through hubAssetUrl"); + assert.ok(!/HUB_BASE/.test(code), "gallery must not know the hub base"); + // The image itself is built by the shared preview module, which owns the + // error fallback. + assert.match(code, /themePreview\.logoImage\(/, "logo tile must reuse the shared img builder"); +}); + +test("gallery view: login backgrounds stay a label, and only cheap images reach a card", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /kind === "logo_svg"/, "logo lookup missing"); + // /assets/:id/:kind streams the original image (login_bg is capped at + // 2 MiB); 24 cards each pulling one is not acceptable. So login_bg stays a + // glyph tile. + assert.match( + src, + /glyph: "▣",\s+label: ASSET_LABELS\.loginBg/, + "login background must stay a glyph tile", + ); + + // 两个取 url 的地方,一个都不能多:logo(卡片和抽屉都画,每张 SVG 几 KB)和 + // 快捷方式图标(**只**在抽屉里画,每张最多 256 KiB,一次最多 12 张)。 + // 再多一处就要先回答"这会不会让一页 24 张卡各拉一张大图"。 + assert.equal( + (src.match(/hubAssetUrl\(/g) || []).length, + 2, + "exactly two places turn a hub asset path into a url", + ); + const bodyOf = (name) => { + const start = src.indexOf(`const ${name} = `); + assert.ok(start !== -1, `${name} not found`); + return src.slice(start, src.indexOf("\n};", start)); + }; + assert.match(bodyOf("logoUrlOf"), /hubAssetUrl\(/); + assert.match(bodyOf("shortcutIconUrls"), /hubAssetUrl\(/); + + // 快捷方式图标只走详情路径。列表行的 preview 投影带 assets 但不带 size, + // 而卡片本来也只画 glyph —— 这一条钉住"抽屉才调它"。 + assert.match( + src, + /buildShortcutList = \(item, toolbar, layout\)/, + "shortcut list must take the item so it can resolve icon assets", + ); + assert.ok( + !/buildCardGlyphs[\s\S]{0,400}shortcutIconUrls/.test(src), + "cards must never resolve shortcut icon urls", + ); +}); + +test("gallery view: cards and drawer itemise the same configuration identically", async () => { + const src = await readFile(SRC, "utf8"); + // Two builders had cards saying "Includes custom content" while the drawer + // listed the pieces. Both now derive their list from tileEntriesFor(item) + // instead of each rebuilding it from the payload, so they cannot drift + // back apart. + assert.match(src, /const tileEntriesFor = /, "missing shared entry builder"); + assert.match( + src, + /const buildCardGlyphs = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);/, + "buildCardGlyphs must derive from tileEntriesFor", + ); + assert.match( + src, + /const buildBundledTiles = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);/, + "buildBundledTiles must derive from tileEntriesFor", + ); + assert.ok( + !src.includes("buildDetailTiles"), + "buildDetailTiles must be folded into tileEntriesFor", + ); + // A hub that predates the projection sends no preview at all: claiming + // "Colors only" there would be a lie, so the coarse signal survives. That + // means buildBundledTiles must actually call the fallback on its null + // branch, not merely have it defined somewhere else in the file. + assert.match(src, /const buildLegacyCardTiles = /, "missing pre-preview fallback"); + assert.match( + src, + /const buildBundledTiles = \(item\) => \{\s*\n\s*const entries = tileEntriesFor\(item\);\s*\n\s*if \(!entries\) return buildLegacyCardTiles\(item\);/, + "buildBundledTiles must call the legacy-hub fallback on its null branch", + ); + assert.ok(src.includes("Includes custom content"), "coarse fallback copy dropped"); +}); + +test("gallery view: one card builder, two rows of metadata", async () => { + const src = await readFile(SRC, "utf8"); + // 内置卡和社区卡是同一个东西的两个数据源。两个构造器已经漂移过一次 + // (社区卡长出了徽章行,内置卡没有),不给它第二次机会。 + assert.match(src, /const buildCard = \(model\) => \{/, "shared card builder missing"); + assert.match(src, /const buildBuiltinCard = \(preset\) => buildCard\(/); + assert.match(src, /const buildOnlineCard = \(item\) => buildCard\(/); + + // 卡片上是 glyph,不是整行 tiles —— 完整清单只在抽屉里出 + assert.match(src, /const buildCardGlyphs = /); + assert.match( + src, + /const buildBuiltinCard = \(preset\) => buildCard\(\{[\s\S]*?glyphs: buildCardGlyphs\(preset\),/, + "a built-in card must show its own font and corner glyphs", + ); + assert.ok( + !src.includes("buildBundledTiles(preset)"), + "a built-in card must not carry the drawer's full tile row", + ); + + assert.ok(src.includes("minmax(225px,1fr)") === false, "the grid must widen"); + // 252px 是卡片的目标宽度,min(...,100%) 是它的下限保护:auto-fill 在容器 + // 比 252px 还窄时照样铺一条 252px 的轨道,网格就从手机屏幕右边溢出去了。 + assert.ok( + src.includes("minmax(min(252px,100%),1fr)"), + "grid must be 252px, clamped to the container so it can't overflow a phone", + ); + assert.ok(src.includes("aspect-ratio:16/10"), "the preview must gain height"); + // 色点条浮到预览上,卡片正文因此少一整行 + assert.ok( + src.includes(".aurora-store-dots{position:absolute"), + "the swatch row must float on the preview", + ); +}); + +test("gallery view: the applied configuration is marked, not labelled", async () => { + const src = await readFile(SRC, "utf8"); + // "Current" 是一枚写着字的药丸,占掉卡片一整行的宽度去说一件描边就能说的事 + assert.ok(!src.includes('_("Current")'), "the Current pill must go"); + assert.ok(!src.includes("aurora-store-badge.current"), "and its style with it"); + + assert.match(src, /const buildCurrentPin = /, "thumbnail mark missing"); + assert.match(src, /const buildCurrentTick = /, "inline tick missing"); + assert.ok( + src.includes(".aurora-store-card.current{border-color:var(--brand"), + "the applied card must carry a brand border", + ); + // 对号是几何字符,不是 emoji —— 既有测试禁的是 emoji,这个和 ◑ ◆ ⌘ 同类 + assert.ok(src.includes('}, "✓")'), "the tick glyph is missing"); + assert.match(src, /_\("In use"\)/, "the mark needs an accessible label"); + assert.match(src, /_\("Re-apply"\)/, "applying the current config again is a re-apply"); + + // 分类徽章留着 —— 那不是状态 + assert.ok(src.includes("aurora-store-badge.builtin"), "the Built-in badge stays"); +}); + +test("gallery view: the drawer stops borrowing LuCI's form row", async () => { + const src = await readFile(SRC, "utf8"); + // cbi-value 右对齐标签列。在一个 460px 的抽屉里,那意味着每个标签和它的值 + // 之间隔着一道峡谷 —— 页面上最难读的东西。 + assert.ok(!src.includes("const buildDetailRow"), "the cbi-value row builder must go"); + assert.ok(!src.includes("cbi-value-title"), "and its classes with it"); + assert.match(src, /const buildKvList = /, "left-aligned key/value list missing"); + assert.match(src, /const buildDetailHeading = /, "section heading builder missing"); + + // 颜色分成"浅色/深色"两行,而不是八个 hex 摊平 + assert.match(src, /aurora-store-pal-row/, "the palette must be grouped by mode"); + // 圆角同时给档位名和原值 + assert.match(src, /const radiusJoin = /); + // "随附内容"滤掉已经在别处说过的那几种(字体除外:名字在布局表里,但随附 + // 文件的体积只有这里说) + assert.match(src, /const buildBundledTiles = /); + assert.match(src, /const BUNDLED_KINDS = \["font", "logo", "loginBg", "mainBg", "siteIcon", "appIcon"\]/); + assert.ok(!src.includes(".innerHTML")); +}); + +test("gallery view: the drawer lists the shortcuts, not just how many", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const buildShortcutList = /, "shortcut section missing"); + assert.match(src, /const buildShortcutRow = /); + assert.match(src, /const shortcutTarget = /); + assert.match(src, /const isExternalShortcut = /); + + // 标题和 url 都是 hub 来的自由文本 + assert.match( + src, + /document\.createTextNode\(shortcutTarget\(entry\.url\)\)/, + "the target must go through createTextNode", + ); + assert.ok(!src.includes(".innerHTML")); + + // 不可信 url 不交给解析器 —— 显示它不需要解析它 + assert.ok(!src.includes("new URL("), "an untrusted url must not reach a parser"); + + // 图标的字节现在跟着配置走(toolbar_icon_),所以抽屉可以画真图 —— + // 但只能从 hub 那条路径画。entry.icon 是**作者路由器上**的文件名,拿它拼 + // /luci-static/aurora/images/ 就是在用 hub 给的名字读本机文件系统。 + assert.match(src, /const SHORTCUT_GLYPH = /, "there must still be a fallback glyph"); + assert.match(src, /const shortcutIconUrls = /, "shortcut icons must resolve to hub assets"); + assert.ok( + !/["'`]\/luci-static\/aurora\/images\/["'`]\s*\+/.test(src), + "an author's icon filename must never be concatenated into a local path", + ); + // 名字只用来在 assets 里查编号,查不到就退回占位符。 + assert.match(src, /shortcutText\(entry\.icon\)/); + + // 关掉的工具栏要说出来,否则这一节在承诺不会出现的东西 + assert.match(src, /toolbar_enabled === "0"/); + + // buildShortcutList existing is not enough -- buildDetailBody must actually + // call it and splice the result in, or the whole section is dead code with + // a green suite (verified by deleting these two lines and re-running). + assert.match( + src, + /const shortcuts = buildShortcutList\(item, payload\.toolbar, layout\);\s*\n\s*if \(shortcuts\) children\.push\(\.\.\.shortcuts\);/, + "buildDetailBody must actually render the shortcut section, not just define its builder", + ); +}); + +test("gallery view: a hostile toolbar entry cannot crash the drawer", async () => { + const src = await readFile(SRC, "utf8"); + // Hub storage is JSON, and {"toolbar": [null]} is ordinary valid JSON: + // Array.isArray(toolbar) still passes, items.map(buildShortcutRow) still + // calls buildShortcutRow(null), and any entry.x read past that point + // throws. buildDetailBody runs inside a .then() with no .catch(), so an + // uncaught throw here leaves the drawer stuck on "Loading theme + // details…" forever -- the failure mode this file already documents + // around isSharedToolbarItem (search FACTORY_ASSET_NAMES nearby). + // + // The fix is a single normalization at the top of the function, before + // any entry.x access -- not a guard on each read individually (that is + // the version that shipped once and still let entry.url through bare). + assert.match( + src, + /const buildShortcutRow = \(entry, iconUrls\) => \{\s*\n(?:\s*\/\/[^\n]*\n)*\s*entry = entry \|\| \{\};/, + "buildShortcutRow must normalize its parameter before any entry.x access", + ); +}); + +test("gallery view: a hostile toolbar field, not just a hostile element, cannot crash the drawer", async () => { + const src = await readFile(SRC, "utf8"); + // {"title": {"toString": "x", "valueOf": "y"}} is ordinary valid JSON too: + // neither key is callable, so String(entry.title) throws "Cannot convert + // object to primitive value" instead of returning text. entry = entry || + // {} guards a hostile array element; it does nothing for a hostile field + // on an otherwise normal element. Treating anything that is not already a + // string as absent -- the same idiom externalToolbarUrls already uses + // (typeof entry.url === "string") -- sidesteps the coercion instead of + // attempting and catching it. + assert.match( + src, + /const shortcutText = \(value\) => \(typeof value === "string" \? value : ""\);/, + "a typed-string guard for untrusted toolbar fields is missing", + ); + + // Every read of an untrusted toolbar field in this section must go + // through the guard above rather than coercing with String() directly. + // + // The scheme regex is bound to EXTERNAL_URL_RE rather than written inline: + // a regex literal sitting right after an arrow gets minified into a syntax + // error by jsmin -- see tests/jsmin-safety.test.mjs. Only the shape of the + // regex moved; both reads still go through shortcutText. + assert.ok( + src.includes( + 'const isExternalShortcut = (url) => EXTERNAL_URL_RE.test(shortcutText(url));', + ), + "isExternalShortcut must read the url through the string guard", + ); + assert.ok( + src.includes( + 'const shortcutTarget = (url) => shortcutText(url).replace(EXTERNAL_URL_RE, "");', + ), + "shortcutTarget must read the url through the string guard", + ); + assert.ok( + src.includes( + 'const title = shortcutText(entry.title) + (disabled ? " " + _("(disabled)") : "");', + ), + "the title read must go through the string guard", + ); + assert.ok(!src.includes("String(entry.title"), "title must not be coerced with String()"); + assert.ok(!src.includes("String(url ||"), "url must not be coerced with String()"); +}); + +test("gallery view: the nickname is account state, not a publish field", async () => { + const src = await readFile(SRC, "utf8"); + // The hub owns the nickname now; a localStorage copy would go stale the + // moment the user renames from another browser. + assert.ok(!src.includes("HUB_NICK_KEY"), "stale localStorage nickname cache"); + assert.ok(!src.includes("aurora.hub.nick"), "stale localStorage key"); + assert.ok(src.includes("callHubSetNickname"), "nickname must be set through its own call"); + // Identity is shown, not re-typed, once it exists. + assert.ok(src.includes("profile.nickname"), "publish panel must read the profile"); +}); + +test("gallery view: a taken nickname reads as a result, not a code", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /nickname_taken:\s*_\(/); + assert.ok(!/_\("nickname_taken"\)/.test(src), "raw error code must not surface"); + assert.ok(!src.includes("invalid_author"), "author copy outlived the author field"); +}); + +test("gallery view: the key backup path exists and warns before overwriting", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok(src.includes("callHubExportKey"), "missing export call"); + assert.ok(src.includes("callHubImportKey"), "missing import call"); + // Importing over an account that still owns work is unrecoverable -- the + // old key is gone unless it was saved elsewhere -- so the count has to be + // part of the decision. + assert.ok(src.includes("myShares.length"), "import must consider existing work"); + assert.match(src, /aurora-creator-key\.txt/); + // The key must never be painted into markup as a bare string child. + assert.ok(!src.includes(".innerHTML"), "no innerHTML"); +}); + +test("gallery view: the empty state offers recovery, not just publishing", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok( + src.includes("restoreIdentityPrompt"), + "empty state must reach the identity-restore flow", + ); + assert.match(src, /hub_key_saved/, "the backup reminder must read its uci flag"); +}); + +test("gallery: hub_me seeds the cache, and a failed one must not wipe my shares", async () => { + const src = await readFile(SRC, "utf8"); + const start = src.indexOf("const refreshMyShares"); + assert.ok(start > 0, "refreshMyShares not found"); + const fn = src.slice(start, src.indexOf("const applyMe", start)); + assert.ok(fn.includes("meCache.set"), "refreshMyShares must seed meCache on success"); + // 断网时把用户已发布的作品从界面抹掉,看起来像作品没了。保留上一帧才诚实 + // —— 我们只是没拿到新数据。 + assert.ok( + !fn.includes("res.data : null"), + "refreshMyShares must not applyMe(null) on failure", + ); +}); + +// 本设计的核心不变量,值得被钉住:LuCI 在 load() resolve 前不会进 render(), +// 所以 load() 里任何一次到 hub 的往返都等于首屏白屏 0.8s 起步。 +test("gallery load() waits on nothing but local sources", async () => { + const src = await readFile(SRC, "utf8"); + const body = src.slice(src.indexOf(" load() {"), src.indexOf(" render(loadData) {")); + assert.ok(body.length > 0, "load() body not found"); + assert.ok(!body.includes("callHubList"), "callHubList must not block first paint"); + assert.ok(!body.includes("callHubMe"), "callHubMe must not block first paint"); + assert.ok(body.includes("presets.json"), "load() still reads the bundled presets"); + assert.ok(body.includes('uci.load("aurora")'), "load() still reads uci"); +}); + +test("gallery paints from cache first, then goes to the network", async () => { + const src = await readFile(SRC, "utf8"); + // 锚点是首屏挂载前的最后一件事:从缓存取一份列表先画上。横幅没了之后 + // renderBanner() 不再能当锚。 + const tail = src.slice(src.lastIndexOf("hubApi.listCache.getStale()")); + assert.ok(tail.includes("meCache.getStale"), "my shares must paint from cache"); + assert.ok(tail.includes("appendChild"), "the tail should mount the DOM"); + // 顺序要求:两个请求都在 DOM 挂载之后 + assert.ok( + tail.indexOf('fetchSort("hot")') > tail.indexOf("appendChild"), + "the list fetch must come after the first paint is mounted", + ); + assert.ok( + tail.indexOf("refreshMyShares()") > tail.indexOf("appendChild"), + "hub_me must come after the first paint is mounted", + ); + // loadData 不再携带这两份网络结果 + assert.ok(!tail.includes("loadData.listRes"), "listRes is gone from loadData"); + assert.ok(!tail.includes("loadData.meRes"), "meRes is gone from loadData"); +}); + +test("gallery: importing a creator key drops the previous account's cached profile", async () => { + const src = await readFile(SRC, "utf8"); + const i = src.indexOf("callHubImportKey"); + assert.ok(i > 0, "callHubImportKey not found"); + const block = src.slice(i, i + 1200); + assert.ok( + block.includes("meCache.clear()"), + "a new key means a different account -- the cached profile is someone else's", + ); +}); + +test("backup dialog: masked by default, with show/copy/download and an insecure-context copy fallback", async () => { + const src = await readFile(SRC, "utf8"); + + // 弹窗标题与正文改说“身份”,不再说“密钥”。 + assert.ok( + src.includes('_("Back up your creator identity")'), + "backup dialog must be titled with the identity wording", + ); + assert.ok( + !src.includes('_("Back up your creator key")'), + "the old key wording must be gone", + ); + + // 默认遮住:先渲染掩码,点“显示”才换成真值。 + assert.match(src, /const KEY_MASK = "•"\.repeat\(64\)/); + assert.ok(src.includes('_("Show")') && src.includes('_("Hide")')); + + // 复制按钮 + 复制成功的瞬时反馈。 + assert.ok(src.includes('_("Copy")') && src.includes('_("Copied")')); + assert.ok(src.includes('_("Download backup file")')); + + // http://192.168.1.1 不是 secure context,navigator.clipboard 是 undefined。 + // execCommand 兜底才是真正会跑的那条路径。 + assert.match(src, /navigator\.clipboard && navigator\.clipboard\.writeText/); + assert.match(src, /document\.execCommand\("copy"\)/); + + // 复制失败不能是死路:把身份亮出来让人手动选。 + assert.ok( + src.includes( + '_("Couldn\'t copy. The identity is shown above — select it and copy it manually.")', + ), + "copy failure must fall back to revealing the identity", + ); +}); + +test("restore dialog: identity wording, a real file button, and one parse path", async () => { + const src = await readFile(SRC, "utf8"); + + assert.match(src, /const restoreIdentityPrompt = \(\) =>/); + assert.ok( + !src.includes("importKeyPrompt"), + "importKeyPrompt must be renamed everywhere, call sites included", + ); + + assert.ok(src.includes('_("Restore your creator identity")')); + assert.ok(src.includes('_("That doesn\'t look like a creator identity.")')); + assert.ok(src.includes('_("Couldn\'t restore that identity. Check it and try again.")')); + assert.ok(src.includes('_("Choose backup file…")')); + + // 恢复框里曾经还有一个「先备份当前身份」按钮。身份卡上已经有一个备份入口, + // 这里再放一个就是同一件事的第二条岔路 —— 一个对话框只做一件事。 + assert.ok( + !src.includes('_("Back up the current identity first")'), + "the restore dialog must not carry a second backup entry point", + ); + assert.equal( + (src.match(/backupKeyPrompt\(\)/g) || []).length, + 1, + "backup must have exactly one entry point (the identity card)", + ); + // 警告本身留着:它讲的是换身份的后果,不是一条通往别处的入口。 + assert.ok( + src.includes( + '_("This router already has shared configurations of its own. Switching to another identity gives them up for good — back up the current identity first if you still want them.")', + ), + "the irreversibility warning must survive the button it used to sit above", + ); + + // 文件选择器被 label 包住并隐藏 —— 浏览器默认的 "未选择文件" 在这里是噪音。 + assert.match(src, /class: "cbi-button aurora-store-filebtn"/); + assert.match(src, /picker\.style\.display = "none"/); + + // 弹窗自身的旧措辞。两个"导入"按钮标签还挂在 keybar 和空态上,它们连同 + // 那两处一起消失 —— 断言在下面身份卡和空态的用例里。 + assert.ok(!src.includes('_("That doesn\'t look like a creator key.")')); + assert.ok(!src.includes('_("Couldn\'t use that key. Check it and try again.")')); +}); + +test("identity card: names the router's creator, and carries back-up / rename / restore", async () => { + const src = await readFile(SRC, "utf8"); + + assert.match(src, /const buildIdentityCard = \(\) =>/); + assert.ok(!src.includes("buildKeyBar"), "buildKeyBar must be gone, call site included"); + + // 一张卡两个状态。没有身份时它照画 —— 只是名字换成"还没有创作者身份", + // 动作只剩「恢复…」。此前这里 return null,于是一台刚刷好的路由器在「我的」 + // 页上只剩一句"还没有分享过",而带着备份文件从旧路由器过来的人要的恰恰 + // 就是这一屏上的恢复入口。 + assert.ok( + !/if \(!profile\.id\) return null;/.test(src), + "a router without an identity must still get the card -- that is where Restore lives", + ); + assert.ok( + src.includes('_("No creator identity yet")'), + "the empty identity state must name itself", + ); + assert.ok( + src.includes('_("Created the first time you publish — or restore one you backed up.")'), + "the empty identity state must say where an identity comes from", + ); + // 数据还没回来时才 return null:那时 profile 是空的,画空态等于先撒谎再纠正。 + assert.match( + src, + /const buildIdentityCard = \(\) => \{\s*if \(!myLoaded\) return null;/, + "the card must wait for hub_me rather than guess", + ); + + // 身份不再占一个和作品同级的 section。它并进「我的分享」的标题下面 —— + // 这台路由器以谁的名义发布,说的本来就是那张表里的东西归谁。 + assert.ok( + !src.includes('_("My creator identity")'), + "identity is no longer a section of its own", + ); + assert.ok(src.includes('_("Back up identity")')); + assert.ok(src.includes('_("Not backed up")')); + assert.ok(src.includes('_("Backed up")')); + + // 改名从发布面板里搬出来,成为身份卡上的常驻动作。 + assert.match( + src, + /click: \(\) => promptRename\(\) \},\s*_\("Rename"\)/, + "the card must offer Rename", + ); + + // 折叠说明只回答“这是什么、从哪来的”。恢复入口曾经挂在它末尾 —— 而空态 + // 引导卡里还有第二条,同屏可见,点开是同一个对话框。现在恢复只在身份卡上。 + assert.ok(src.includes('_("What is this? Where did it come from?")')); + assert.match(src, /class: "aurora-store-why"/); + assert.ok( + !src.includes('_("Changed routers? Restore your identity")'), + "the explainer must not carry a second restore entry point", + ); + assert.ok( + src.includes('_("Restore…")'), + "the card must offer Restore", + ); + // 两个调用点,但它们是同一张卡的两个互斥状态(有身份 / 没身份),同屏永远 + // 只看得见一个 —— 而且都在这张卡里,没有第三处。 + const cardSrc = src.slice( + src.indexOf("const buildIdentityCard"), + src.indexOf("const KEY_RE"), + ); + const restoreSites = (src.match(/restoreIdentityPrompt\(\)/g) || []).length; + assert.equal(restoreSites, 2, "restore lives in the identity card's two states, nowhere else"); + assert.equal( + (cardSrc.match(/restoreIdentityPrompt\(\)/g) || []).length, + 2, + "both restore entry points must be inside the identity card", + ); + + // 昵称来自 hub,属不可信文本。 + assert.match(src, /document\.createTextNode\(profile\.nickname\)/); + + // 发布面板里那份只读署名不再重复提供改名 —— 改名只有一个入口。 + const renameSites = (src.match(/promptRename\(\)/g) || []).length; + assert.equal(renameSites, 1, "rename must have exactly one entry point"); +}); + +// 等待反馈只有一种机制:LuCI 自带的 .spinning 加 disabled。这一条钉的是 +// "别长出第二套 loading",以及每个会让人干等的动作都接上了它。 +test("waiting has exactly one mechanism, and every round trip uses it", async () => { + const src = await readFile(SRC, "utf8"); + + assert.match( + src, + /const withBusy = \(btn, promise\) => \{\s*btn\.disabled = true;\s*btn\.classList\.add\("spinning"\);/, + "the one busy helper must both disable and spin", + ); + // 自制的转圈会和页面上已有的那几处不一样。复用 LuCI 的类,不新增样式。 + assert.ok( + !/@keyframes|aurora-store-spinner|animation:/.test(src), + "no second spinner -- .spinning is what the rest of this page already uses", + ); + + // 确认框:onConfirm 返回 thenable 才 busy。返回 undefined 的同步路径(应用 + // 预设、应用配置 —— 它们当场换成一个 spinner 模态)必须一个字不变。 + assert.match( + src, + /if \(!pending \|\| typeof pending\.then !== "function"\) return;/, + "synchronous confirm paths must keep their old behaviour", + ); + assert.match(src, /cancelBtn\.disabled = true;/, "cancel must not stay live mid-flight"); + + // 每一趟会让人干等的动作都接上了它:模态确认键、备份身份、删除某一行、 + // 重试读取我的分享。(发布另有 endSubmit —— 它要跨 renderContent 存活。) + [ + [/withBusy\(confirmBtn, pending\)/, "modal confirm buttons"], + [/withBusy\(ev\.currentTarget, backupKeyPrompt\(\)\)/, "back up identity"], + [/withBusy\(\s*btn,/, "delete a share"], + [/withBusy\(ev\.currentTarget, refreshMyShares\(\)\)/, "retry my-shares"], + ].forEach(([re, what]) => + assert.match(src, re, `${what} must show a wait`), + ); + // 改名和恢复走确认框那条路 —— 它们把 promise 交回去,而不是自己 fire-and-forget。 + assert.match( + src, + /return L\.resolveDefault\(hubApi\.callHubSetNickname\(next\), null\)/, + "rename must hand its promise to the confirm footer", + ); + assert.match( + src, + /return L\.resolveDefault\(hubApi\.callHubImportKey\(key\), null\)/, + "restore must hand its promise to the confirm footer", + ); +}); + +// 首屏那一句"还没有分享过"曾经在 hub_me 回来之前就说出口了 —— 在一台其实 +// 发布过东西的路由器上,它是假的。三种"没有表格"的理由现在各说各的。 +test("my-shares tells loading, unreachable and empty apart", async () => { + const src = await readFile(SRC, "utf8"); + + assert.match(src, /let myLoaded = false;/, "the view must know whether hub_me answered"); + assert.match(src, /let myFailed = false;/, "and whether it failed"); + + const listSrc = src.slice( + src.indexOf("const renderMyShares"), + src.indexOf("const shareManifestRows"), + ); + assert.match(listSrc, /if \(myFailed && !myShares\.length\)/, "unreachable state missing"); + assert.match(listSrc, /if \(!myLoaded\)/, "loading state missing"); + assert.match(listSrc, /class: "spinning"/, "loading must look like the rest of the page"); + // 文案跟社区网格共用,不新造 msgid。 + assert.ok(listSrc.includes('_("Loading…")')); + assert.ok(listSrc.includes('_("Unable to reach the theme store right now.")')); + assert.ok(listSrc.includes('_("Retry")')); + + // 拿不到就不假装知道:失败时不置 myLoaded,身份卡因此不会画出"还没有创作者 + // 身份"这种替 hub 编的答案。 + const refresh = src.slice( + src.indexOf("const refreshMyShares"), + src.indexOf("const applyMe"), + ); + assert.ok( + !/^\s*myLoaded = true;$/m.test(refresh.slice(refresh.indexOf("} else {"))), + "a failed hub_me must not count as loaded", + ); +}); + +// 这一条原先钉的是"三处入口用同一个词"。三处已经一起删了:发布的起点搬到 +// 主题工作台,商店只负责逛和管。它现在钉的是"零个" —— 同一条回归线,更严。 +test("publishing has no entry point on this page at all", async () => { + const src = await readFile(SRC, "utf8"); + + assert.ok( + !src.includes('_("Share My Configuration")'), + "the header button must stop being a second, differently-worded verb", + ); + + const publishLabels = + (src.match(/_\("Publish current configuration"\)/g) || []).length; + assert.equal(publishLabels, 0, "every publish entry point moved to the studio"); + + // 页头那颗按钮连同它切标签的副作用一起没了。 + assert.ok( + !/shareOpen = true;\s*selectTab\("mine"\);/.test(src), + "the header button's tab-switching side effect should be gone with it", + ); +}); + +// 空态一路瘦到只剩一行字。它曾经是一张虚线卡:标题、一段说明、一颗"去工作台" +// 按钮,外加第二条"恢复身份"链接。三样东西现在都没有存在的理由 —— 发布入口 +// 就在同一屏上方那条横幅上,恢复在身份卡里。剩下要说的只有"这里以后会长出 +// 什么",而商店别处的空态本来就是一行灰字。 +test("my-shares empty state is one line, not a card with its own buttons", async () => { + const src = await readFile(SRC, "utf8"); + + assert.ok( + src.includes( + '_("Nothing shared yet. Publish the configuration above and it shows up here.")', + ), + ); + assert.match(src, /class: "aurora-store-none"/); + + // 那张卡整块没了,连同它的样式类。 + assert.ok( + !/class: "aurora-store-empty"/.test(src), + "the dashed empty card is gone", + ); + assert.ok( + !src.includes('_("Go to the design studio")'), + "the empty state must not send people away -- publishing happens here now", + ); + assert.ok( + !src.includes('_("Shared on another router before? Restore my identity")'), + "the second restore entry point is gone", + ); + + // 两种空态合并了:这一段不再按 profile.id 分叉。(身份卡自己是分叉的 —— + // 有没有身份决定它画什么、给哪个动作 —— 那是另一段代码的事。) + const listSrc = src.slice( + src.indexOf("const renderMyShares"), + src.indexOf("const shareManifestRows"), + ); + assert.ok( + !/if \(!profile\.id\)/.test(listSrc), + "the two empty states now say the same next step and were merged", + ); + + // 旧的那句“或者导入创作者密钥”彻底消失,连同两个旧按钮标签。 + assert.ok(!src.includes('_("Import a creator key")')); + assert.ok(!src.includes('_("Import a key")')); +}); + +// 这一条以前钉的是"removed 一律滤掉"。当时非滤不可:hub 的软删除让作者自己 +// 删掉的作品继续出现在 /api/v1/me 里,只是 status 变成 removed —— 删成功了、 +// 那一行还在,再点一次就撞 404。代价是被管理员下架的作品跟着一起被藏了。 +// +// hub 那边现在分得清了(migration 0007 的 removed_by):作者自己删的根本不再 +// 下发,status === "removed" 只剩"被下架"一个意思。所以这里反过来钉:不许再 +// 滤,那一行必须画出来 —— 而且不带按钮,因为 hub 的 requireOwnedConfig 要求 +// status='active',对已下架的调 delete 只会拿回 404。 +test("gallery: a taken-down share is shown, and carries no buttons", async () => { + const src = await readFile(SRC, "utf8"); + + const start = src.indexOf("const renderMyShares"); + assert.ok(start > 0, "renderMyShares not found"); + const head = src.slice(start, src.indexOf("TABS.forEach", start)); + assert.ok( + !/\.filter\(\(item\) => item && item\.status !== "removed"\)/.test(head), + "renderMyShares must no longer drop removed rows -- the hub already did", + ); + + const row = src.slice( + src.indexOf("const buildMyShareRow"), + src.indexOf("let myShares = []"), + ); + assert.match(row, /const takenDown = item\.status === "removed";/); + assert.ok( + row.includes( + '_("Taken down — no longer in the store. Nothing you can do from here.")', + ), + "the author must be told, here or nowhere", + ); + assert.match( + row, + /takenDown \? \[\] : \[updateBtn, " ", deleteBtn\]/, + "a taken-down row must offer neither Update nor Delete", + ); +}); + +test("gallery: a delete the hub refuses still re-reads My Shares", async () => { + const src = await readFile(SRC, "utf8"); + const start = src.indexOf("const confirmDeleteShare"); + assert.ok(start > 0, "confirmDeleteShare not found"); + const fn = src.slice(start, src.indexOf("const buildMyShareRow", start)); + // 刷新只写一次,但在 if/else 之外 —— 两条分支都必须走到它。失败分支是一行 + // hub 那边其实已经不存在的作品离开屏幕的唯一途径。 + assert.equal( + (fn.match(/refreshMyShares\(\)/g) || []).length, + 1, + "one unconditional refresh, not one per branch", + ); + assert.ok( + fn.indexOf("refreshMyShares()") > fn.indexOf("deleteErrorMessage"), + "the refresh must sit after both branches, not inside the success one", + ); + // 确认框关掉之后还有两趟网络 —— 期间那一行的 Delete 键转圈并按不动。 + assert.match( + fn, + /return withBusy\(\s*btn,/, + "the row's own Delete button is what shows the wait", + ); +}); + +// Same duplication as studio.js: the tab strip above the content already says +// "主题市场", and the store head printed it again as an

immediately below. +test("gallery: the tab strip names the page, so the head carries no heading", async () => { + const src = await readFile(SRC, "utf8"); + assert.ok( + !src.includes('_("Theme Marketplace")'), + "the tab strip above already names the page", + ); + assert.ok( + !/E\("h2"/.test(src), + "no page-level h2 belongs in the store head", + ); + // The spacer is what pushes search and share to the right; without the + // heading it is the first child, and it still has to be there. + assert.match(src, /class: "sp"/); +}); + +// 三处发布入口(页头按钮、面板标题、空态卡按钮)说的是同一句话,而且面板开着 +// 的时候下面还在劝你开面板 —— 那三处一次删干净了。 +// +// 这一条现在钉的是"回来的只有一个":入口挂在「我的配置」那条横幅上,也就是 +// 被发布的那个对象自己身上,而不是页头/面板标题/空态卡各来一个。页头那颗 +// 按钮和它切标签的副作用必须仍然不存在。 +// 「我的配置」恒为 1(它只是当前 uci 的投影),而「我的分享」是 0…N。这两样 +// 曾经共用一个 auto-fill minmax(252px,1fr) 网格 —— 单例被塞进为 N 准备的容器 +// 里,一张卡后面拖着几格空白。 +// +// 「我的」页因此改画横幅:换来的宽度不是留白,是清单。清单必须来自 +// shareManifestRows() 本尊 —— 复制一份的话,以后加一项配置要改两处,而两处 +// 迟早会长歪,横幅上写着要发什么、线路上发的却是另一套。 +test("mine banner: one row, and its manifest is the publish panel's own", async () => { + const src = await readFile(SRC, "utf8"); + + assert.match(src, /const buildMineBanner = \(\) =>/); + assert.match(src, /class: "aurora-store-share aurora-store-mine"/); + assert.match( + src, + /buildTiles\(\s*shareManifestRows\(\)\.map\(/, + "the banner must render the publish panel's manifest, not a copy of it", + ); + + // 「全部」页仍然是卡片:那一页拿来逛,内置和社区也是网格。横幅只在「我的」。 + assert.match( + src, + /if \(state\.tab === "all"\) \{\s*const mineGrid = buildMineGrid\(\);/, + "the browse tab keeps the card so it reads like the other groups", + ); + + // modified=false 时这条横幅代表的是"被商店主题盖掉之前那份备份",而 + // build_share_payload 打包的是当前 uci —— 两者不是同一个东西。那时根本 + // 不给发布按钮,不是置灰。 + const banner = src.slice( + src.indexOf("const buildMineBanner"), + src.indexOf("// ------------------------------------------------------------------\n // My shares"), + ); + assert.match(banner, /const acts = modified\s*\?/); + assert.match(banner, /confirmRestore\(\)/); + assert.equal( + (banner.match(/openSharePanel\(\)/g) || []).length, + 1, + "the share action exists only on the modified branch", + ); +}); + +test("marketplace: publishing has exactly one entry point, on the thing being published", async () => { + const src = await readFile(srcPath("view/aurora/marketplace.js"), "utf8"); + + assert.ok(!/cbi-button-add/.test(src), "the header publish button (and its CSS) should be gone"); + assert.ok( + !/_\("Publish current configuration"\)/.test(src), + "the copy that named three separate entry points should be gone", + ); + assert.ok( + !/shareOpen = true;\s*selectTab\("mine"\);/.test(src), + "the header button's tab-switching side effect should stay gone", + ); + + // 唯一入口:横幅上的这颗按钮。openSharePanel 只被它调用一次。 + assert.match(src, /const openSharePanel = \(\) =>/); + assert.equal( + (src.match(/openSharePanel\(\)/g) || []).length, + 1, + "openSharePanel must have exactly one call site", + ); + assert.match( + src, + /click: \(\) => openSharePanel\(\),?\s*\},\s*_\("Share to the store"\)/, + "the banner's primary action must be the publish entry", + ); + // 工作台那个入口保留,两处落到同一张面板 —— 但它不在这个文件里。 +}); + +// 意图优先,状态兜底:用户上一秒刚在工作台点了"分享到商店",这句话不该被 +// "你已经发过东西了"盖掉。 +test("marketplace: intent beats state when deciding what to show", async () => { + const src = await readFile(srcPath("view/aurora/marketplace.js"), "utf8"); + assert.match(src, /URLSearchParams\(window\.location\.search\)/); + assert.match(src, /shareIntent/); + assert.match(src, /history\.replaceState/); +}); + +// 带意图进来又已经有作品时,只给一张空表单会让人发出第二条几乎一样的东西。 +// "更新"的语义本来就是"用当前配置替换商店里的它",起点同样在工作台 —— +// 所以它和发布共用同一张表单,只差一个目标。 +test("marketplace: an author with works picks new-vs-update before publishing", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /shareTarget/); + assert.match(src, /_\("Publish as a new one"\)/); + assert.match(src, /_\("Replace “%s”"\)/); + assert.match(src, /nameInput\.value = target\.name/); + // 列表里的"更新"不再弹确认框,而是跳回同一张表单、那条已选中 + assert.ok(!/confirmUpdateShare/.test(src), "update now reuses the publish form"); + assert.match(src, /openUpdateForm/); +}); + +// 一张 1.2MB 的图在慢上行的线路上要传十几秒。没有进度就是"卡死"。 +test("marketplace: a multi-second upload shows progress, not a frozen button", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /renderShareProgress/); + assert.match(src, /_\("Uploading %s… %d%%"\)/); + assert.match(src, /asset_upload_failed/); + assert.match(src, /asset_unreadable/); + // 进度回调必须真的接上,否则上面那些字一次都不会显示 + assert.match(src, /onProgress: renderShareProgress/); +}); + +// --------------------------------------------------------------------------- +// 主界面背景:与登录背景同权重的资产,在浏览、打包、发布三处都要能看见 +// --------------------------------------------------------------------------- + +test("main background shows up in tiles, totals, and the publish manifest", async () => { + const src = await readFile(SRC, "utf8"); + // 标签与详情 tile(和 login_bg 一样只作标签行,不拉原图) + assert.match(src, /mainBg: _\("Main Background"\)/); + assert.match(src, /label: ASSET_LABELS\.mainBg/); + assert.match(src, /meta: bgTileMeta\(item, \["main_bg"\], layout, "struct_main_bg"\)/); + // 详情页图片合计与打包清单都数它 + assert.match(src, /\["logo_svg", "login_bg", "main_bg"\]\.concat\(/); + assert.match(src, /const BUNDLED_KINDS = \["font", "logo", "loginBg", "mainBg", "siteIcon", "appIcon"\]/); + // 发布面板:rpcd shared_images 的 main_bg 行有标签可挂 + assert.match(src, /main_bg: ASSET_LABELS\.mainBg/); +}); + +// 背景 tile 的 meta 同时报体积与随包参数(体积 · 67% / 20px / 20%), +// 参数缺席就只报体积——使用方在应用前看得见这套背景的完整观感设定 +test("background tiles surface the bundled tunables", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const bgTileMeta = /); + assert.match(src, /prefix \+ "_alpha"/); + assert.match(src, /prefix \+ "_scrim"/); +}); + +// --------------------------------------------------------------------------- +// 随附内容的体积:字体和自定义工具栏图标与背景同权重,都要带体积 +// (docs/superpowers/specs/2026-08-08-bundled-content-sizes-design.md) +// --------------------------------------------------------------------------- + +test("bundled tiles put a size on fonts and custom toolbar icons", async () => { + const src = await readFile(SRC, "utf8"); + // 字体 entry 的 meta = 随附字体文件的字节数(font_sans + font_mono 合计) + assert.match( + src, + /kind: "font",[\s\S]{0,400}?meta: sizeLabel\(assetBytesOf\(item, \["font_sans", "font_mono"\]\)\)/, + "font entry must carry the bundled font bytes as meta", + ); + // 字体进随附内容 —— 但名册字体不随附文件(meta 空),那是"应用后路由器自己 + // 下载",不能算随附内容;既有的"需下载字体"脚注继续负责说明它 + assert.match( + src, + /entry\.kind !== "font" \|\| entry\.meta/, + "a roster font with no bundled file must stay out of Bundled content", + ); + // 工具栏图标 tile:报数量与合计体积 + assert.match( + src, + /kind: "toolbarIcons",[\s\S]{0,400}?meta: sizeLabel\(assetBytesOf\(item, TOOLBAR_ICON_KINDS\)\)/, + "toolbar icons tile must total the icon assets", + ); + assert.match(src, /_\("Toolbar icons ×%d"\)/); + // 抽屉专属:由 buildBundledTiles 合成并且真的接上;卡片 glyph 仍走共享的 + // toolbar entry,不受影响 + assert.match(src, /const toolbarIconsTile = \(item\) => \{/); + assert.match( + src, + /const iconsTile = toolbarIconsTile\(item\);\s*\n\s*if \(iconsTile\) bundled\.push\(iconsTile\);/, + "buildBundledTiles must actually append the toolbar icons tile", + ); +}); + +// --------------------------------------------------------------------------- +// Inbox tab + schema compat + +// 只看代码:注释里解释"为什么不用 uci.apply()"正是该留下的东西。 +const codeOnly = (src) => + src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + +const between = (src, from, to) => src.slice(src.indexOf(from), src.indexOf(to)); + +test("inbox: it is the sixth tab, with an unread badge that is absent at zero", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^"require utils\.notices as notices";$/m); + assert.match(src, /^"require preload\.aurora-notices as inboxIndicator";$/m); + const tabs = between(src, "const TABS = [", "const renderTabLabel"); + const keys = [...tabs.matchAll(/key: "([a-z]+)"/g)].map((m) => m[1]); + assert.deepEqual(keys, ["all", "builtin", "hot", "new", "mine"]); + assert.match(tabs, /INBOX_TAB,\s*\];/, "Inbox comes last"); + assert.match(src, /key: "inbox",\s*label: _\("Inbox"\),\s*badge: \(\) => notices\.unreadCount\(inboxItems\(\)\),/); + assert.match( + src, + /const unread = tab\.badge \? tab\.badge\(\) : 0;\s*if \(unread\)\s*btn\.appendChild\(\s*E\("span", \{ class: "aurora-store-count" \}/, + ); +}); + +test("inbox: the search box steps aside while the tab is active", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /searchInput\.style\.display = key === "inbox" \? "none" : "";/); + assert.match(src, /\} else if \(state\.tab === "inbox"\) \{\s*push\(buildInbox\(\)\);/); +}); + +test("inbox: the rejected inline design is gone", async () => { + const src = await readFile(SRC, "utf8"); + for (const leftover of ["aurora-store-notice", "aurora-store-optout", "dismissNotice", "dismissedNotices", "compatLine"]) + assert.ok(!src.includes(leftover), leftover); + assert.match(src, /\[\s*styleEl,\s*headEl,\s*contentEl,/, "nothing sits above the tab strip again"); + assert.match( + src, + /push\(buildIdentityCard\(\)\);\s*\n\s*push\(mySharesEl\);/, + "the opt-out no longer lives under the identity card", + ); +}); + +test("inbox: every hub-supplied string reaches the page through createTextNode", async () => { + const src = await readFile(SRC, "utf8"); + const inbox = between(src, "const buildInboxRow", "const buildInbox = () =>"); + for (const field of ["copy.title", "copy.act.label", "copy.source", "copy.snippet", "paragraph", "copy.time"]) { + const bare = new RegExp(`E\\(\\s*"[a-z0-9]+",\\s*\\{[^}]*\\},\\s*${field.replace(".", "\\.")}\\s*,?\\s*\\)`); + assert.ok(!bare.test(inbox), `${field} must never be a bare E() child`); + } + // describeItem is where share names and the reviewer's words enter; both + // only ever travel as format() arguments into strings that end up in text nodes. + const describe = between(src, "const describeItem", "const inboxChanged"); + assert.match(describe, /notices\.present\(item\.notice, document\.documentElement\.lang\)/); + assert.match(describe, /_\("Reviewer’s note: %s"\)\.format\(item\.reason\)/); + assert.match(describe, /link: shown\.url \? shown : null/, "a url safeUrl rejected renders no action"); +}); + +test("inbox: items come from the feed and from hub_me, through the pure module", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /const inboxItems = \(\) => notices\.inbox\(inboxSnapshot, meData, hubApi\.inboxState\(\)\);/, + ); + assert.match(src, /meData = data \|\| null;/); + const describe = between(src, "const describeItem", "const inboxChanged"); + for (const kind of ['item.kind === "notice"', 'item.kind === "feed"', 'item.kind === "compat"', 'item.kind === "rejected"']) + assert.ok(describe.includes(kind), kind); + assert.match(describe, /item\.state === "unsupported"/); + assert.match(describe, /item\.names\.map\(quoted\)\.join\(", "\)/, "the summary names the affected shares"); + assert.match(describe, /_\("Taken down: %s"\)/); + // No derivation, keying or counting of its own in the view. + const code = codeOnly(src); + for (const belongsInNotices of ['"compat:"', '"rejected:"', '"removed:"', '"feed:', "shortHash"]) + assert.ok(!code.includes(belongsInNotices), belongsInNotices); + // Result-only copy: the mechanism word never reaches the user. + const strings = [...src.matchAll(/_\(\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]); + assert.deepEqual(strings.filter((s) => /schema/i.test(s)), []); +}); + +test("inbox: read and done go through the shared store and update the header indicator at once", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /const inboxChanged = \(\) => \{\s*renderTabLabel\(INBOX_TAB\);\s*inboxIndicator\.update\(\);\s*if \(state\.tab === "inbox"\) renderContent\(\);/, + ); + assert.match(src, /hubApi\.setInboxState\(next\(hubApi\.inboxState\(\)\)\);\s*inboxChanged\(\);/); + assert.match(src, /read: notices\.withKey\(st\.read, key\), done: st\.done/); + assert.match(src, /read: notices\.withoutKey\(st\.read, key\), done: st\.done/); + assert.match(src, /read: notices\.withKeys\(st\.read, inboxItems\(\)\),/); + assert.match(src, /read: st\.read, done: notices\.withKey\(st\.done, key\)/); +}); + +test("inbox: opening the Marketplace refreshes both sources, whatever the preload's throttle says", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /hubApi\s*\.refreshNotices\(\{ me: refreshMyShares\(\) \}\)\s*\.then\(\(snapshot\) => \{\s*inboxSnapshot = snapshot;\s*inboxChanged\(\);/, + ); + assert.ok(!codeOnly(src).includes("isDue"), "the view must not throttle its own refresh"); + // hub_me runs once: refreshMyShares hands its answer over. + assert.match(src, /return myFailed \? null : res\.data;/); + assert.equal((codeOnly(src).match(/hubApi\.callHubMe\(\)/g) || []).length, 1); + // First paint comes from the cache the preload shares. + assert.match(src, /let inboxSnapshot = hubApi\.noticesCache\.getStale\(\);/); +}); + +test("inbox: J/K/E/U only on the Inbox tab, never inside a field, never with a modifier", async () => { + const src = await readFile(SRC, "utf8"); + const keys = between(src, "const onInboxKey", "const onInboxHash"); + assert.match(keys, /if \(state\.tab !== "inbox" \|\| ev\.ctrlKey \|\| ev\.metaKey \|\| ev\.altKey\) return;/); + assert.match(keys, /ev\.target\.closest\("input,textarea,select,\[contenteditable\]"\)/); + assert.match(keys, /key === "j" && shown\[at \+ 1\]\) pickItem\(shown\[at \+ 1\]\.key\)/); + assert.match(keys, /key === "k" && at > 0\) pickItem\(shown\[at - 1\]\.key\)/); + assert.match(keys, /key === "e" && inboxSel\) markDone\(inboxSel\)/); + assert.match(keys, /key === "u" && inboxSel\) markUnread\(inboxSel\)/); + // A view that was swapped out of the document takes its listener with it. + assert.match(keys, /document\.removeEventListener\("keydown", onInboxKey\)/); +}); + +test("inbox: the header indicator deep-links through a location hash the view owns", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /window\.location\.hash === inboxIndicator\.INBOX_HASH\s*\?\s*"inbox"/); + assert.match(src, /window\.addEventListener\("hashchange", onInboxHash\);/); + assert.match(src, /const hash = key === "inbox" \? inboxIndicator\.INBOX_HASH : "";/); + assert.match(src, /window\.history\.replaceState\(\s*window\.history\.state,/, "whatever a router keeps in history.state survives"); + assert.ok(!codeOnly(src).includes('"#inbox"'), "the hash has one owner: the preload module"); +}); + +test("my shares: a share that needs updating says so, and points at the update it already has", async () => { + const src = await readFile(SRC, "utf8"); + assert.match( + src, + /const compat = item\.status === "removed" \? null : notices\.compatBadge\(item\);/, + "a taken-down share has no update button to send its author to", + ); + assert.match( + src, + /class: "aurora-store-pill risk", style: "margin-left:8px;" \},\s*compat === "unsupported"\s*\? _\("Not listed for updated routers"\)\s*: _\("Needs update"\),/, + ); + assert.match(src, /E\("div", \{ class: "aurora-store-compat" \}, compatNoteFor\(item, compat\)\)/); + assert.match( + src, + /_\("Listed until %s\. Publish it again from this router to keep it listed\."\)\.format\(formatDay\(sunset\)\)/, + ); + assert.match(src, /_\("Publish it again from this router to keep it listed\."\)/); + assert.match( + src, + /_\("Updated routers no longer see this theme\. Update it to relist — link and downloads are kept\."\)/, + ); + // The preview of a share updated routers no longer see is desaturated. + assert.match(src, /class: compat === "unsupported" \? "aurora-store-dim" : "",/); + assert.match(src, /\.aurora-store-dim\{filter:grayscale\(\.8\);opacity:\.7;\}/); + + // The CTA is the existing update action, promoted -- not a second flow. + assert.match( + src, + /class: compat \? "cbi-button cbi-button-action" : "cbi-button",\s*\n\s*click: \(\) => openUpdateForm\(item\),/, + ); + assert.equal((src.match(/_\("Update with current configuration"\)/g) || []).length, 1); + assert.equal((src.match(/openUpdateForm\(item\)/g) || []).length, 1); +}); + +test("my shares: the identity card's summary line gains the counts", async () => { + const src = await readFile(SRC, "utf8"); + const card = between(src, "const buildIdentityCard", "const KEY_RE"); + assert.match(card, /const counts = notices\.compatCounts\(meData\);/); + assert.match(card, /meta = \[_\("Signs everything you share\. It lives only on this router\."\)\]/); + assert.match(card, /\.join\(" · "\);/); + for (const [one, many] of [ + ["1 needs an update before %s", "%d need an update before %s"], + ["1 needs an update", "%d need an update"], + ["1 not listed for updated routers", "%d not listed for updated routers"], + ]) { + assert.ok(card.includes(`_("${one}")`), one); + assert.ok(card.includes(`_("${many}").format(`), many); + } +}); + +test("the opt-out is gone from the page: no toggle, no uci write, no leftover styles", async () => { + const src = await readFile(SRC, "utf8"); + for (const gone of [ + "hub_notices", + "noticesMuted", + "buildNoticesToggle", + "setNoticesMuted", + "callUciSet", + "callUciCommit", + '".aurora-store-inbox .set', + "Show the unread count on every LuCI page", + "Couldn't save that setting", + ]) + assert.ok(!src.includes(gone), gone); + assert.match(src, /\.refreshNotices\(\{ me: refreshMyShares\(\) \}\)/); + for (const name of ["default", "sage-green", "amber-sand", "monochrome", "sky-blue"]) { + const template = await readFile(repo(`root/usr/share/aurora/${name}.template`), "utf8"); + assert.ok(!template.includes("hub_notices"), name); + } +}); + +test("inbox v2: list column is two pills, a quiet mark-all-read, plain group labels, and the rows", async () => { + const src = await readFile(SRC, "utf8"); + const build = between(src, "const buildInbox = () =>", "let inboxMounted"); + assert.match(build, /\["all", _\("All"\)\],\s*\["unread", _\("Unread"\)\],/); + assert.match(build, /class: "pill" \+ \(inboxFilter === key \? " on" : ""\)/); + assert.match(build, /iconButton\("", _\("Mark all read"\), markAllRead\)/); + // One line of muted text: no count, no rule, no uppercase. + assert.match(build, /rows\.push\(E\("div", \{ class: "grp" \}, label\)\);/); + assert.match( + build, + /\[notices\.GROUP_ACTION, _\("Needs action"\)\],\s*\[notices\.GROUP_UPDATES, _\("Updates"\)\],/, + ); + assert.match(build, /if \(!part\.length\) return;/); + assert.ok(!build.includes("part.length)]"), "group headers carry no count"); + assert.match(src, /\.aurora-store-inbox \.grp\{padding:14px 10px 6px;font-size:\.78em;" \+\s*"color:var\(--text-subtle,#888\);\}/); + assert.match(build, /_\("You’re all caught up\."\)/); + assert.match(build, /_\("Select a notification"\)/); + // The column ends with the rows: there is no opt-out any more. + assert.match( + build, + /rows\.length \? rows : \[E\("div", \{ class: "empty" \}, _\("You’re all caught up\."\)\)\],\s*\),\s*\]\),\s*E\(\s*"div",\s*\{ class: "detail" \}/, + ); + assert.match(build, /"aurora-store-inbox" \+ \(current \? " has-sel" : ""\)/); + // The keyboard hint row is gone; the shortcuts are not. + for (const gone of ["kbd(", 'class: "foot"', '_("move")', '_("done")', '_("unread")']) + assert.ok(!src.includes(gone), gone); + assert.match(src, /key === "j" && shown\[at \+ 1\]\) pickItem/); +}); + +test("inbox v2: a row is a neutral source glyph, an unread dot, title, snippet, and level icon over time", async () => { + const src = await readFile(SRC, "utf8"); + const row = between(src, "const buildInboxRow", "const iconButton"); + assert.match(row, /class: "row" \+ \(item\.unread \? " unread" : ""\) \+ \(item\.key === inboxSel \? " sel" : ""\)/); + assert.match(row, /click: \(\) => pickItem\(item\.key\)/); + assert.match(row, /E\("span", \{ class: "av " \+ item\.source \}\)/); + assert.match(row, /if \(item\.unread\) title\.unshift\(E\("span", \{ class: "dot" \}\)\);/); + assert.match(row, /E\("b", \{\}, \[document\.createTextNode\(copy\.title\)\]\)/); + assert.match(row, /copy\.markdown === undefined \? copy\.body\[0\] : copy\.snippet/); + // info gets an empty slot: only warning and critical draw an icon. + assert.match(row, /class: "lv" \+ \(item\.level === "info" \? "" : " " \+ item\.level\)/); + assert.match(row, /E\("span", \{ class: "tm" \}, \[document\.createTextNode\(copy\.time \|\| ""\)\]\)/); + assert.ok(!row.includes('class: "done"'), "Done moved to the detail bar and the E key"); + assert.match(src, /const pickItem = \(key\) => \{\s*inboxSel = key;\s*markRead\(key\);/); + + for (const source of ["router", "store", "shares"]) + assert.match(src, new RegExp(`\\.aurora-store-inbox \\.av\\.${source}\\{--aurora-store-glyph:`)); + assert.match(src, /\.aurora-store-inbox \.av\{flex:none;width:32px;height:32px;border-radius:50%;/); + assert.match(src, /\.aurora-store-inbox \.dot\{flex:none;width:7px;height:7px;border-radius:50%;" \+\s*"background:var\(--brand,/); + assert.match(src, /\.aurora-store-inbox \.row\.sel\{background:var\(--surface-sunken,/); + assert.match(src, /\.aurora-store-inbox \.row\{[^}]*border-radius:10px;/); + assert.ok(!/\.aurora-store-inbox \.row\{[^}]*border-bottom/.test(src), "no separators between rows"); + assert.match(src, /\.aurora-store-inbox \.row\.unread \.tt b\{color:var\(--text,#111\);font-weight:600;\}/); +}); + +test("inbox v2: one accent; danger and warning colour only the small level icon", async () => { + const src = await readFile(SRC, "utf8"); + const css = between(src, '".aurora-store-inbox{display:grid;', '".aurora-store-compat{'); + const coloured = [...css.matchAll(/([^{}"+]+)\{[^}]*var\(--(danger|warning|info)[a-z-]*,/g)].map((m) => m[1].trim()); + assert.deepEqual(coloured, [".aurora-store-inbox .lv.critical", ".aurora-store-inbox .lv.warning"]); + for (const gone of ["aurora-store-chip", "aurora-store-lvl.", "aurora-store-kbd", "aurora-store-seg", "LEVEL_CHIPS", "Heads-up", "Action needed"]) + assert.ok(!src.includes(gone), gone); +}); + +test("inbox v2: detail is a thin bar of source · time and two labelled icon buttons, then title, body, one action", async () => { + const src = await readFile(SRC, "utf8"); + const detail = between(src, "const buildInboxDetail", "const buildInbox = () =>"); + assert.match(detail, /E\("div", \{ class: "top" \}, \[/); + assert.match(detail, /class: "lnk back"/); + assert.match(detail, /"← " \+ _\("Inbox"\)/); + assert.match(detail, /copy\.time \? copy\.source \+ " · " \+ copy\.time : copy\.source/); + assert.match(detail, /iconButton\(" unread", _\("Mark unread"\), \(\) => markUnread\(item\.key\)\),\s*iconButton\("", _\("Done"\), \(\) => markDone\(item\.key\)\),/); + assert.match(src, /const iconButton = \(extraClass, label, onClick\) =>\s*E\("button", \{\s*type: "button",\s*class: "ib" \+ extraClass,\s*title: label,\s*"aria-label": label,/); + assert.match(detail, /E\("h3", \{\}, \[document\.createTextNode\(copy\.title\)\]\)/); + // The action block exists only when there is an action. + assert.match(detail, /if \(copy\.link\)/); + assert.match(detail, /else if \(copy\.act\)/); + assert.match(detail, /\[document\.createTextNode\(copy\.act\.label\)\]/, "the label can carry a share's name"); + assert.match(detail, /target: "_blank", rel: "noreferrer"/); + assert.match(detail, /\{ href: L\.url\(copy\.link\.url\) \}/); + assert.match(src, /\.aurora-store-inbox \.ib:hover,\.aurora-store-inbox \.ib:focus-visible\{/); +}); + +test("inbox v2: broadcast bodies render as Markdown, everything derived stays plain text", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /^"require utils\.markdown as markdown";$/m); + const detail = between(src, "const buildInboxDetail", "const buildInbox = () =>"); + assert.match(detail, /if \(copy\.markdown === undefined\)\s*copy\.body\.forEach\(\(paragraph\) =>\s*body\.appendChild\(E\("p", \{\}, \[document\.createTextNode\(paragraph\)\]\)\),/); + assert.match(detail, /else body\.appendChild\(markdown\.render\(copy\.markdown, document, markdownLink\)\);/); + const describe = between(src, "const describeItem", "const inboxChanged"); + assert.match(describe, /snippet: markdown\.toText\(shown\.body\),\s*markdown: shown\.body,/); + assert.equal((describe.match(/markdown:/g) || []).length, 1, "only the broadcast branch hands a body to the renderer"); + assert.match(describe, /_\("Reviewer’s note: %s"\)\.format\(item\.reason\)/); + // Links inside a body pass the same rule as the notice's url field. + assert.match( + src, + /const markdownLink = \(url\) => \{\s*const safe = notices\.safeUrl\(url\);\s*return safe && !notices\.isExternalUrl\(safe\) \? L\.url\(safe\) : safe;/, + ); +}); + +test("inbox v2: at 760px and below it is list OR detail, and nothing is opened on the user's behalf", async () => { + const src = await readFile(SRC, "utf8"); + assert.match(src, /const INBOX_NARROW_QUERY = "\(max-width:760px\)";/); + assert.match( + src, + /if \(!items\.some\(\(item\) => item\.key === inboxSel\)\)\s*inboxSel = narrow \|\| !items\.length \? null : items\[0\]\.key;/, + ); + const css = between(src, '"@media " + INBOX_NARROW_QUERY', '".aurora-store-inbox .back{display:inline-block;}}"'); + assert.match(css, /\.aurora-store-inbox\{grid-template-columns:1fr;\}/); + assert.match(css, /\.aurora-store-inbox\.has-sel \.list\{display:none;\}/); + assert.match(css, /\.aurora-store-inbox:not\(\.has-sel\) \.detail\{display:none;\}/); + assert.match(src, /\.aurora-store-inbox \.back\{display:none;/); + assert.ok(!src.includes("px-") && !src.includes("lx-"), "prototype class names must not ship"); +}); + +test("the update source: a Needs-action item that runs the add-feed flow Studio used to host", async () => { + const src = await readFile(SRC, "utf8"); + const describe = between(src, "const describeItem", "const inboxChanged"); + assert.match(describe, /act: \{ label: _\("Add update source"\), run: confirmAddFeed \}/); + assert.match(describe, /item\.theme && item\.app/); + const flow = between(src, "const runAddFeed", "const buildInboxRow"); + // Same confirmation, same rpcd method, same three outcomes. + for (const msgid of [ + "Add the Aurora update source", + "Afterwards you can upgrade Aurora in System → Software, like any other OpenWrt package.", + "From openwrt.eamonxg.fun; the signing key ships with this package.", + "Adding the update source…", + "Could not add the update source: %s", + "The update source was written, but refreshing the index failed. Hit Refresh on the Software page later.", + "Update source added", + "Go to Software", + ]) + assert.ok(flow.includes(`_("${msgid}")`), msgid); + assert.match(flow, /L\.resolveDefault\(hubApi\.callAddFeed\(\), \{ result: 1 \}\)/); + assert.match(flow, /feedCheck\.pickPackageManagerPath\(menuTree\)/); + // Written means gone: the item leaves the list and the header count follows. + assert.match( + flow, + /inboxSnapshot = hubApi\.setNoticesLocal\(\s*Object\.assign\(\{\}, inboxSnapshot && inboxSnapshot\.local, \{ feedMissing: false \}\),\s*\);\s*inboxChanged\(\);/, + ); + const failure = flow.slice(flow.indexOf("ret.result !== 0"), flow.indexOf("const softwarePage")); + assert.ok(!failure.includes("setNoticesLocal"), "a failed add leaves the item where it is"); +}); diff --git a/luci-app-aurora-config/.dev/tests/notices-module.test.mjs b/luci-app-aurora-config/.dev/tests/notices-module.test.mjs new file mode 100644 index 00000000..265144a4 --- /dev/null +++ b/luci-app-aurora-config/.dev/tests/notices-module.test.mjs @@ -0,0 +1,749 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { srcPath } from "./paths.mjs"; + +const SRC = srcPath("utils/notices.js"); + +// Same harness as feed-check-module.test.mjs: the module is pure, so the tests +// run it instead of pattern-matching its source. +async function load() { + const src = await readFile(SRC, "utf8"); + const body = src + .replace(/^"use strict";$/m, "") + .replace(/^"require [^"]+";$/gm, ""); + return new Function("baseclass", body)({ extend: (obj) => obj }); +} + +const notice = (over) => ({ + id: "n1", + level: "warning", + audience: "all", + title: "Schema 1 is going away", + body: "Update your shares before the sunset date.", + url: "https://themes.eamonxg.fun/notices/n1", + i18n: {}, + ...over, +}); + +test("notices.js is pure: baseclass is its only dependency, no DOM, no network, no storage", async () => { + const src = await readFile(SRC, "utf8"); + const requires = [...src.matchAll(/^"require ([^"]+)";$/gm)].map((m) => m[1]); + assert.deepEqual(requires, ["baseclass"]); + for (const forbidden of ["document", "window", "localStorage", "fetch(", "rpc", "innerHTML"]) + assert.ok(!src.includes(forbidden), `notices.js must not touch ${forbidden}`); +}); + +test("localize prefers the exact language, then its base, then the English defaults", async () => { + const m = await load(); + const n = notice({ + i18n: { + "zh-cn": { title: "简体标题", body: "简体正文" }, + zh: { title: "中文标题", body: "中文正文" }, + de: { title: "Titel" }, + }, + }); + assert.deepEqual(m.localize(n, "zh-cn"), { title: "简体标题", body: "简体正文" }); + assert.deepEqual(m.localize(n, "zh-tw"), { title: "中文标题", body: "中文正文" }); + assert.deepEqual(m.localize(n, "en"), { title: n.title, body: n.body }); + assert.deepEqual(m.localize(n, "fr"), { title: n.title, body: n.body }); + // Per field: a locale that only translated the title keeps the English body. + assert.deepEqual(m.localize(n, "de"), { title: "Titel", body: n.body }); +}); + +test("localize accepts LuCI's own spelling of a language code", async () => { + const m = await load(); + const n = notice({ i18n: { "zh-cn": { title: "简体标题", body: "简体正文" } } }); + // dispatcher.lang, and therefore , is "zh_cn". + assert.equal(m.localize(n, "zh_cn").title, "简体标题"); + assert.equal(m.localize(n, "ZH_CN").title, "简体标题"); + for (const lang of [undefined, null, "", 42, {}]) + assert.deepEqual(m.localize(n, lang), { title: n.title, body: n.body }); +}); + +test("localize ignores values that are not strings", async () => { + const m = await load(); + const n = notice({ + i18n: { + "zh-cn": { title: { toString: () => "x" }, body: 7 }, + zh: { title: "", body: ["x"] }, + }, + }); + assert.deepEqual(m.localize(n, "zh-cn"), { title: n.title, body: n.body }); + assert.deepEqual(m.localize(notice({ i18n: "zh-cn" }), "zh-cn"), { + title: n.title, + body: n.body, + }); + assert.deepEqual(m.localize(notice({ i18n: null }), "zh-cn"), { title: n.title, body: n.body }); + assert.deepEqual(m.localize(null, "en"), { title: "", body: "" }); + assert.deepEqual(m.localize({ title: 1, body: {} }, "en"), { title: "", body: "" }); + // A language code that names an Object.prototype member must not resolve to it. + assert.deepEqual(m.localize(n, "constructor"), { title: n.title, body: n.body }); + assert.deepEqual(m.localize(n, "__proto__"), { title: n.title, body: n.body }); +}); + +test("safeUrl lets through https URLs and LuCI-relative admin/ paths", async () => { + const m = await load(); + assert.equal(m.safeUrl("https://example.com/a?b=1#c"), "https://example.com/a?b=1#c"); + assert.equal(m.safeUrl("https://example.com"), "https://example.com"); + assert.equal(m.safeUrl("admin/system/aurora/marketplace"), "admin/system/aurora/marketplace"); + assert.equal( + m.safeUrl("admin/system/aurora/marketplace?share=1"), + "admin/system/aurora/marketplace?share=1", + ); + assert.equal(m.isExternalUrl("https://example.com"), true); + assert.equal(m.isExternalUrl("admin/system"), false); + assert.equal(m.isExternalUrl(""), false); + assert.equal(m.isExternalUrl(null), false); +}); + +test("safeUrl rejects everything else", async () => { + const m = await load(); + const hostile = [ + "javascript:alert(1)", + "JavaScript:alert(1)", + " javascript:alert(1)", + "java\nscript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "http://example.com/", + "HTTPS://example.com/", + "//evil.example/x", + "/admin/system", + "\\\\evil.example\\x", + "https:/example.com", + "https:example.com", + "https://", + "https://user:pass@example.com/", + "https://user@example.com/", + "https://:pass@example.com/", + "https://exa mple.com/", + "https://example.com/\u0000", + "https://example.com/\n", + "https://example.com\\@evil.example/", + "admin/../../etc/passwd", + "admin//evil.example", + "admin/x\\y", + "admin/", url: 42 })]); + assert.equal(kept.title, title); + assert.equal(kept.body, ""); + assert.equal(kept.url, ""); + // The url is judged where it is rendered, so a hostile one survives sanitize + // and dies in safeUrl. + const [hostile] = m.sanitize([notice({ url: "javascript:alert(1)" })]); + assert.equal(m.safeUrl(hostile.url), ""); +}); + +test("sanitize filters i18n down to valid language keys and string fields", async () => { + const m = await load(); + const [kept] = m.sanitize([ + notice({ + i18n: { + "zh-cn": { title: "标题" }, + ZH: { title: "大写键" }, + "zh_cn": { title: "下划线键" }, + constructor: { title: "x" }, + fr: { title: 5, body: { a: 1 } }, + de: "Titel", + ja: { title: "x".repeat(121), body: "本文" }, + ko: null, + }, + }), + ]); + assert.deepEqual(kept.i18n, { "zh-cn": { title: "标题" }, ja: { body: "本文" } }); + assert.deepEqual(m.sanitize([notice({ i18n: [] })])[0].i18n, {}); + assert.deepEqual(m.sanitize([notice({ i18n: "x" })])[0].i18n, {}); + assert.deepEqual(m.sanitize([notice({ i18n: undefined })])[0].i18n, {}); +}); + +test("sanitize caps the list and drops repeated ids", async () => { + const m = await load(); + const many = Array.from({ length: 500 }, (_, i) => notice({ id: "n" + i })); + const out = m.sanitize(many); + assert.equal(out.length, m.MAX_NOTICES); + assert.equal(m.MAX_NOTICES, 20); + // Newest first on the wire, so the cap keeps the head. + assert.equal(out[0].id, "n0"); + assert.equal(out[19].id, "n19"); + + const dupes = m.sanitize([notice({ title: "first" }), notice({ title: "second" })]); + assert.deepEqual(dupes.map((n) => n.title), ["first"]); + // An id that names an Object.prototype member is still just an id. + assert.equal(m.sanitize([notice({ id: "constructor" })]).length, 1); + + // Malformed entries do not count towards the cap. + const padded = [...Array.from({ length: 100 }, () => null), ...many]; + assert.equal(m.sanitize(padded).length, 20); +}); + +test("isDue throttles to one check per twelve hours", async () => { + const m = await load(); + const HOUR = 60 * 60 * 1000; + assert.equal(m.POLL_INTERVAL_MS, 12 * HOUR); + const now = Date.UTC(2026, 8, 19, 12); + assert.equal(m.isDue(now - 1, now), false); + assert.equal(m.isDue(now - 11 * HOUR, now), false); + assert.equal(m.isDue(now - 12 * HOUR + 1, now), false); + assert.equal(m.isDue(now - 12 * HOUR, now), true); + assert.equal(m.isDue(now - 48 * HOUR, now), true); +}); + +test("isDue treats a missing, corrupt or future timestamp as due", async () => { + const m = await load(); + const now = Date.UTC(2026, 8, 19, 12); + for (const last of [0, -5, NaN, Infinity, null, undefined, "1700000000000", {}]) + assert.equal(m.isDue(last, now), true, String(last)); + // A clock that was set back must not silence the check forever. + assert.equal(m.isDue(now + 1000, now), true); + assert.equal(m.isDue(now - 1000, NaN), true); +}); + +test("creatorAffected reads compat_summary", async () => { + const m = await load(); + const summary = (deprecated, unsupported) => ({ + compat_summary: { current_schema: 2, deprecated, unsupported }, + configs: [], + }); + assert.equal(m.creatorAffected(summary(0, 0)), false); + assert.equal(m.creatorAffected(summary(1, 0)), true); + assert.equal(m.creatorAffected(summary(0, 3)), true); + assert.equal(m.creatorAffected(summary("1", "x")), false); + assert.equal(m.creatorAffected(summary(NaN, null)), false); + assert.equal(m.creatorAffected(summary(-1, 0)), false); +}); + +test("creatorAffected falls back to the per-config compat state", async () => { + const m = await load(); + const me = (...states) => ({ + configs: states.map((state, i) => ({ id: "c" + i, status: "active", compat: { state } })), + }); + assert.equal(m.creatorAffected(me("ok", "ok")), false); + assert.equal(m.creatorAffected(me("ok", "deprecated")), true); + assert.equal(m.creatorAffected(me("unsupported")), true); + // Anything the hub may call it later still means "not fine". + assert.equal(m.creatorAffected(me("retired")), true); + // A share that was taken down has no update button to send its author to. + assert.equal( + m.creatorAffected({ configs: [{ status: "removed", compat: { state: "deprecated" } }] }), + false, + ); +}); + +test("creatorAffected tolerates an old hub that sends none of the new fields", async () => { + const m = await load(); + assert.equal(m.creatorAffected({ id: "abc", nickname: "eamon", configs: [{ id: "x" }] }), false); + assert.equal(m.creatorAffected({ id: "abc", nickname: null, configs: [] }), false); + assert.equal(m.creatorAffected({}), false); + for (const junk of [null, undefined, "me", 1, [], { configs: "x" }, { compat_summary: [] }]) + assert.equal(m.creatorAffected(junk), false, String(junk)); + assert.equal( + m.creatorAffected({ configs: [null, 1, { compat: null }, { compat: "deprecated" }, { compat: { state: 1 } }] }), + false, + ); +}); + +test("compatBadge names the two states a share card has to mention", async () => { + const m = await load(); + assert.equal(m.compatBadge({ compat: { state: "deprecated", sunset_at: "2026-12-01 00:00:00" } }), "deprecated"); + assert.equal(m.compatBadge({ compat: { state: "unsupported" } }), "unsupported"); + assert.equal(m.compatBadge({ compat: { state: "ok" } }), null); + assert.equal(m.compatBadge({ compat: { state: "retired" } }), null); + assert.equal(m.compatBadge({ compat: { state: ["deprecated"] } }), null); + assert.equal(m.compatBadge({ compat: "deprecated" }), null); + assert.equal(m.compatBadge({ id: "x" }), null); + assert.equal(m.compatBadge({}), null); + assert.equal(m.compatBadge(null), null); + assert.equal(m.compatBadge(undefined), null); +}); + +test("sunsetDate shows the day and nothing else the hub may have put there", async () => { + const m = await load(); + const at = (sunset_at) => ({ compat: { state: "deprecated", sunset_at } }); + assert.equal(m.sunsetDate(at("2026-12-01 00:00:00")), "2026-12-01"); + assert.equal(m.sunsetDate(at("2026-12-01T00:00:00Z")), "2026-12-01"); + assert.equal(m.sunsetDate(at("2026-12-01")), "2026-12-01"); + for (const junk of [ + null, + undefined, + "", + "soon", + "2026-12-01 +

+ {% include('footer') %} + ``` + +2. luci-base `ucode/template/header.ut` first includes the **theme** header + (`include(\`themes/${theme}/header\`)`, same scope — the theme renders the + whole `` and opens ``), then emits (header.ut:10-31): + + ``` + + + ``` + + Consequences: the theme `` is parsed **before** `luci.js` exists; + `luci.js` itself sits in the body region after `#tabmenu`; the `?v=` of + `luci.js` (and therefore `L.env.resource_version`, used for every + `L.require` URL) changes whenever any package is installed, because + `pkgs_update_time` is the mtime of `/lib/apk/db/installed` or + `/usr/lib/opkg/status` (`ucode/runtime.uc:182`). On `openwrt-23.05` the + tag is a bare `luci.js` (preceded by `promis.min.js`) and + `pkgs_update_time` does not exist (`23.05 header.ut:10-11`, + `runtime.uc:179-182`), so `resource_version` is undefined and module + URLs are unversioned there. + + `_()` is **not** in `luci.js`: it is defined by `cbi.js` + (`resources/cbi.js:154-157`), reads `window.TR` lazily at call time and + falls back to the source string. The catalog script and `cbi.js` must + therefore have executed before any module that calls `_()` runs — and + this theme's `menu-aurora.js` calls `_()` synchronously in `__init__`. + +3. `view.ut`'s inline `L.require('ui')` runs synchronously in parser order + right after `luci.js`. A wrapper installed from the theme **footer** is too + late for that call, but early enough for `ui.instantiateView(view)` → + `L.require(view)` and for `L.require('menu-aurora')`. + +4. luci-base `ucode/template/footer.ut` includes the theme footer and, when + an apply/revert/rollback is pending, an inline script bound to the + `luci-loaded` event. + +## 2. Template scope the theme can use + +Globals set by `runtime.uc` / `dispatcher.uc` and visible inside +`themes//header.ut`: `media`, `resource`, `theme`, `dispatcher` +(`.lang`, `.build_url()`, `.lookup()`), `dispatched` (the node: `title`, +`css`, …), `ctx.request_path`, `ctx.path`, `config`, `version`, +`pkgs_update_time`, `http`, `ubus`, `_()`, `striptags()`, `entityencode()`. +Callers may add scope: `sysauth.ut` passes `blank_page`, and the theme adds +its own `prefetched_*` (aurora `sysauth.ut:17-21`). + +`{{ expr }}` prints raw — the shipped templates call `entityencode(v, true)` +for attributes and `striptags()` for text explicitly. Do the same. + +## 3. Module loader — `L.require` + +- URL: `${env.base_url}/${name.replace(/\./g,'/')}.js` + `?v=${env.resource_version}` + (`luci.js:2474`); both env values are parsed from the `luci.js` script src + at boot (`luci.js:2257`). +- Loads the source, lexes the leading string literals `'require x [as y]'` + (works on a minified one-line head), then loads dependencies **serially per + level** — a dependency depth of *n* costs *n* round trips. +- Returns the **instance**: `__init__` has already run, so re-requiring a + view returns the rendered singleton. To render again use + `new instance.constructor()` (aurora `router-aurora.js:713`). +- Six names are seeded in memory (`luci.js:2237-2244`) and have **no file** + on disk: `baseclass`, `dom`, `poll`, `request`, `session`, `view`. + Prefetchers must skip them or they 404 on every page. + +## 4. Menu, session store, polling + +- `ui.menu.load()` (`ui.js:3733`): `session.getLocalData('menu')` first — + sessionStorage key `luci-session-store`, namespaced by session id + (`luci.js:1883-1932`) — else one `GET admin/menu` per **session**, not per + page. `ui.menu.flushCache()` (`ui.js:3748`) has no caller in luci-base or + the bootstrap theme. +- `L.Poll` is a singleton inside `luci.js` (`luci.js:1067`): `queue` + (`:1070`), `start()` (`:1158`, dispatches `poll-start` at `:1167`), + `stop()` (`:1183`, dispatches `poll-stop`), `active()`, `tick`. Core's + `setupDOM` shows the `poll-status` indicator on `poll-start` + (`luci.js:2743`) and flips it to "Paused" on `poll-stop` (`:2749`); nothing + in luci-base hides it. +- `dom.content(node, children)` (`luci.js:1442-1449`) deletes the + `data-idref` registry entries under `node` — the only call that does; + without it a departed subtree and its class instances stay referenced. +- Session expiry is signalled two ways (`luci.js:2715-2733`): a ubus error + `-32002` on anything but `session.access` triggers a `session.access` + probe, and an HTTP 403 carrying `X-LuCI-Login-Required: yes` is the + login-required path. +- `hasViewPermission()` is `!env.nodespec.readonly` (`luci.js:3263-3267`); + the form footer's Save/Apply state reads it (`luci.js:2195`). +- `env.pollinterval` defaults to 5 s; a hidden tab keeps polling unless a + theme stops it. + +## 5. Dispatcher responses and caching + +- `http.uc` `write_headers()` (`http.uc:497-512`): unless the action set + its own, every dispatcher response gets `Cache-Control: no-cache` and + `Expires: 0`, plus `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection`, + `X-Content-Type-Options: nosniff`. No `ETag`, no `Last-Modified` — nothing + to revalidate against, so browsers refetch every dispatcher response. +- `admin/translations/` (`ucode/controller/admin/index.uc:124-136`, + `action_translations`) streams `window.TR={"%08x":"…",…};`. It is a full + CGI dispatch per document and uncacheable by the rule above. Themes link it + as a synchronous `` script (bootstrap `header.ut:44`, aurora + `header.ut:222`), so it is parser-blocking on every MPA navigation. Its + menu node is `type: function` with `auth: {}` (`menu.d/luci-base.json:62-69`), + which only skips the login redirect: `resolve_page()` still runs + `is_authenticated()` for the `admin` node's cookie methods + (`dispatcher.uc:650-651`, `:533-551`), so the dispatch pays the + `session.get` + `session.access` round-trip like any page. +- An action can override the default: `http.header()` stores keys + lower-cased (`http.uc:466-469`), so a `Cache-Control` set by the action + suppresses the `no-cache` default. No `.lmo` exists for `en` + (`luci.mk:10`, `src/lib/lmo.c:237`), so a file-mtime cache key is + undefined for the English catalog; `pkgs_update_time` is not. +- A query string never changes which node is dispatched: uhttpd splits it + off before path lookup and the dispatcher reads `PATH_INFO` only + (`dispatcher.uc:929`; `QUERY_STRING` is parsed separately, `http.uc:606`). + `?v=` on a dispatcher URL is therefore safe. +- Menu tree: `dispatcher.uc:19,366-374` — index cache + `/tmp/luci-indexcache..json`, hashed over the `menu.d` file list; each + dispatch stats the `menu.d` files and parses the cached JSON. +- ACL: `dispatcher.uc` folds every `depends.acl` on the dispatch path into a + single `check_acl_depends()`; `env.nodespec.readonly` is what + `hasViewPermission()` reads, which is what disables Save/Apply. +- Alias / firstchild are resolved server-side **without a redirect**: the + URL stays, `requestpath`/`dispatchpath`/`nodespec`/`ctx.path` already point + at the leaf. `resolve_firstchild()` / `node_weight()` semantics are ported + line for line in `router-aurora.js:25-98`. + +## 6. Branch differences (23.05 / 24.10 / 25.12 / master) + +Source-checked 2026-08 (see `.dev/docs/router.md` §Compatibility): + +| Surface | 23.05 | 24.10 | 25.12 | master | +|---|---|---|---|---| +| `node.css` in the page tree | — | — | — | yes (7c6d8ff, 2026-08) | +| `wildcardaction` + descend-into-satisfied-child rule | — | — | yes | yes (df90c60a7, 2026-01-17) | +| `pkgs_update_time` template global; `luci.js?v=PKG-mtime` (so `L.env.resource_version` and versioned `L.require` URLs) | — (`runtime.uc:179-182`, `header.ut:11`) | yes | yes | yes | +| `action_translations` body, `http.uc` `no-cache` default | same | same | same | same | +| everything else the router uses (`L.require` instance cache, `dom.content`, `Poll`, `ui.menu.load` session cache, interceptors, `-32002` probe, `view.ut` shell) | same | same | same | same | + +The theme targets 23.05+ (ucode templates); anything relying on a newer +surface must degrade to the older behaviour, and the finding must name the +branch. + +## 7. Where to look + +- `$LUCI_SRC/modules/luci-base/ucode/{dispatcher,http,runtime}.uc` +- `$LUCI_SRC/modules/luci-base/ucode/template/{header,footer,view}.ut` +- `$LUCI_SRC/modules/luci-base/ucode/controller/admin/index.uc` +- `$LUCI_SRC/modules/luci-base/ucode/uhttpd.uc` (in-process handler entry) +- `$LUCI_SRC/modules/luci-base/htdocs/luci-static/resources/{luci,ui,rpc,uci,form,network}.js` (`Poll`, `dom`, `session`, `view` live inside `luci.js`) +- `$LUCI_SRC/luci.mk`, `$LUCI_SRC/themes/luci-theme-bootstrap/` +- Other branches: `git -C $LUCI_SRC show :` (fetch + `openwrt-23.05`, `openwrt-24.10`, `openwrt-25.12` first if missing). diff --git a/luci-theme-aurora/.claude/skills/luci-knowledge/references/packaging.md b/luci-theme-aurora/.claude/skills/luci-knowledge/references/packaging.md new file mode 100644 index 00000000..5e3a4118 --- /dev/null +++ b/luci-theme-aurora/.claude/skills/luci-knowledge/references/packaging.md @@ -0,0 +1,35 @@ +# Packaging facts (luci.mk, Makefile, feed) + +## luci.mk rewrites at package build (`$LUCI_SRC/luci.mk:287-298`, `SubstituteVersion`) + +Applied to every `*.ut` / `*.htm` under the installed template dir: + +1. `{# PKG_VERSION #}` → the package version (aurora `header.ut:227` uses it + for `body[data-asset-version]`; luci-base `header.ut:10` for `luci.js?v=`). +2. Quoted `"{{ media }}…/x.css"` and `"{{ resource }}…/x.js"` links get + `?v=$(PKG_VERSION)` appended. The regex requires the literal `.css"`/`.js"` + at the end of the quoted string, so `{{ resource }}/{{ dispatched.css }}` + is **not** rewritten (same as bootstrap) while + `{{ media }}/patches/{{ patch }}.css` **is**. + +Never judge versioning from the source `.ut`; inspect the installed file or +the live HTML. + +## Theme Makefile + +- `PKG_VERSION` / `PKG_RELEASE` (date) in `Makefile`. `LUCI_MINIFY_CSS:=` + blanks luci.mk's default `LUCI_MINIFY_CSS?=1` (`luci.mk:18`), so the + csstidy pass (`luci.mk:274-280`) is skipped for the already-minified + `htdocs/`. `LUCI_MINIFY_JS` is **not** blanked: with `CONFIG_LUCI_JSMIN` + the package build runs jsmin over `htdocs/**/*.js` (`luci.mk:261-268`), + so on-device JS sizes can differ from the repo's — measure on the device + when a JS budget is the question. +- `root/etc/uci-defaults/30_luci-theme-aurora` sets `luci.themes.Aurora` and + `luci.main.mediaurlbase` on fresh installs only (`PKG_UPGRADE != 1`). +- The OpenWrt buildbot has no Node: `htdocs/luci-static/` is the committed + output of `pnpm build`; the package copies it verbatim. +- Floors: OpenWrt 23.05 (ucode templates); Chrome 111 / Safari 16.4 / + Firefox 128; the Navigation API is an optional enhancement. +- CI: `.github/workflows/build-theme-package.yml` builds ipk + apk on tags, + manual dispatch, and branch pushes whose head commit message contains + `[build]`. diff --git a/luci-theme-aurora/.claude/skills/luci-knowledge/references/spa-pitfalls.md b/luci-theme-aurora/.claude/skills/luci-knowledge/references/spa-pitfalls.md new file mode 100644 index 00000000..08a168a9 --- /dev/null +++ b/luci-theme-aurora/.claude/skills/luci-knowledge/references/spa-pitfalls.md @@ -0,0 +1,29 @@ +# SPA pitfalls that apply to this router + +Distilled from luci-theme-footstrap's router work (public: +https://github.com/VizzleTF/luci-theme-footstrap/blob/main/docs/spa-router.md) +and re-checked against this theme's implementation; Aurora's handling is +specified in `.dev/docs/router.md`. Self-contained — no other note is needed. + +| pitfall | why it bites | aurora | +|---|---|---| +| `L.require()` returns the rendered **instance** | re-requiring a view paints nothing; `new` on the instance's class without teardown = double render + double pollers, invisible to the eye | `new instance.constructor()` only after teardown, `router-aurora.js:713` | +| the class rendered by the full load must be marked "seen" | otherwise the first return to that page takes the cold path and renders twice | `initialRoute()` | +| generation check at **paint** time, and a superseded render must never resolve | an empty node blanks the live page, a throw lets luci-base paint an error box into the new page | `navigate()` gen gate | +| poll teardown is three steps: `queue.length = 0`, `stop()`, `start()` | `start()` resets the tick that arms the incoming view's first poll | `teardown()` | +| the `poll-status` indicator outlives polling | core shows it on `poll-start` and never hides it | `hideIndicator('poll-status')` | +| bare `setInterval` in apps survives a same-document swap | a full load killed it; the router must | `hookIntervals()` | +| `alias` / `firstchild` are the most-clicked links and resolve server-side **without a redirect** | `requestpath`/`dispatchpath`/`nodespec` already point at the leaf while the URL stays; port `resolve_firstchild()`/`node_weight()` literally | `resolve()` | +| `env.nodespec.readonly` is not decoration | `hasViewPermission()` reads it → Save/Apply enabled for read-only users if lost | `readonlyAlong()` | +| links with `?query` / `#hash` stay full loads | views read `location.search`; a hash-only change is not a navigation | `onNavigate()` | +| content lands outside `#view` | `

` siblings from server templates, `ui.addNotification()` inserted into `#maincontent` — a full load cleared them, a swap must | `region()`, `commit()` | +| `body[data-page]` and `document.title` | restamp `data-page` from the resolved leaf (page-scoped CSS keys on it); cache the title host once, third-party views overwrite `document.title` | `setEnvironment()` | +| view-injected ` + + + +
+
+
+ +
+
PartitionType / FilesystemMount PointLabelSizeUsedUnusedFlags
0BOOTCONFIG--256.00 KiB---
SQUASHFS/rom-2.00 GiB110.0 MiB0-
Ext4/mnt/mmcblk0p6-113.26 GiB2.0 MiB105.2 GiBstorage
+
+
+
+
+ + diff --git a/luci-theme-aurora/.dev/compat/pages/admin-dashboard.md b/luci-theme-aurora/.dev/compat/pages/admin-dashboard.md new file mode 100644 index 00000000..041328ec --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-dashboard.md @@ -0,0 +1,25 @@ +# admin-dashboard + +Patch file: `.dev/src/media/patches/admin-dashboard.css` (scope `[data-page="admin-dashboard"] .Dashboard > .section-content`, line 8-10). Route: main.css `@layer luci-mod-dashboard` (7f25128) → `_patches.css` (aedf3dc, 7d9f6a1) → own on-demand file with `@reference`/`@apply` (1c924cf, PR #82) → native CSS (e14cfcd). Page package: stock luci-mod-dashboard (menu.d "admin/dashboard" "Dashboard"); it ships its own `view/dashboard/css/custom.css`. + +## dashboard-cards-and-dark — card decoration on `.dashboard-bg`, dark-mode svg invert, title rule, wifi device cell +- page: admin-dashboard · Dashboard +- package: stock luci-mod-dashboard +- dom: `.dashboard-bg` (card), `.dashboard-bg.tr` (row variant, no radius), `img[src*=".svg"]`, `.title h3`, `.router-status-wifi .wifi-info .devices-info .tr .td:nth-child(3)` +- symptom: not recorded beyond the subjects ("dark mode display issue in luci-mod-dashboard"; 56db83c: "Adapt for missing data-page="admin-dashboard" on default login redirect to dashboard", "Remove border-radius on .dashboard-bg for mobile view") +- cause: unknown for the card/wifi/h3 rules (7f25128 has subject only). For the scope: 56db83c body says the `[data-page="admin-dashboard"]` attribute was missing when the login redirect landed on the dashboard, so the scope was dropped; 1c924cf restored it when the patch became on-demand (PR #82: "Backfilled missing data-page scoping in the original patches (… dashboard)"), and header.ut now computes the page name from `ctx.path` when `request_path` is empty (DEVELOPMENT.md §On-Demand Third-Party Patches, item 1) +- fix: patches/admin-dashboard.css — `.dashboard-bg { background: var(--surface-overlay) }` + `&.tr { border-radius: 0 }` + `&:not(.tr) { border: 1px solid var(--hairline); box-shadow: var(--app-shadow-lg); &:hover {…} }` (line 11-27); `img[src*=".svg"] { [data-darkmode="true"] & { filter: invert(100%) } }` (line 38-42); `.title h3 { color: var(--text); border: 0; padding-bottom: 1rem; @media (width < 48rem) { margin-inline: 0; padding-bottom: 0.5rem } }` (line 93-102); `.router-status-wifi .wifi-info .devices-info .tr .td { &:nth-child(3) { position: relative; top: 0; width: 100%; padding-inline: 0 } @media (width < 48rem) { flex: 1 1 50% } }` (line 104-117) · 7f25128 2025-09-28 · 0.5.1_beta (first tag v0.5.5_beta); 56db83c 2025-10-12 · 0.5.9_beta (first tag v0.6.0_beta) dropped the scope and added `.tr { rounded-none }`; the original `.router-status-lan .devices-info tr th { max-md:flex-[1_1_50%] }` from 7f25128 was removed by c3fae55 2025-09-29 "fix: mobile display issue in luci-mod-dashboard th" (subject only) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## dashboard-dark-text-and-inline-tables — force theme text colour in dark mode, compact assoclist cells, undecorate inline info tables +- page: admin-dashboard · Dashboard (issue #66 names the System, DHCP Devices and Wireless panels) +- package: stock luci-mod-dashboard +- dom: `.dashboard-bg span/td/th` (dark mode), `.dashboard-bg .td/.th` (assoclist cells carry `.td/.th` classes), `.dashboard-bg .table:not(.assoclist)` with its `tr` and `td` (the DNS-style inline info tables) +- symptom: issue #66: "Using luci-mod-dashboard with aurora in dark mode results in multiple UI readability and layout issues … Some elements are rendered with very low contrast (almost black on dark background), and spacing/padding in certain tables and panels (System, DHCP Devices, Wireless) appears inconsistent." (OpenWrt 25.12.4, two screenshots) +- cause: the patch's own comment: "Dark mode: override custom.css hardcoded light-mode colors on all text nodes" — luci-mod-dashboard's `custom.css` sets text colours the theme's dark palette does not reach; the spacing/inline-table rules have no recorded cause (944e7c5 has subject only) +- fix: patches/admin-dashboard.css — `& span, & td, & th { [data-darkmode="true"] & { color: var(--text) !important } }` (line 30-36); `& .td { padding-block: 0.5rem; @media (width < 48rem) { padding-block: 0.25rem } }` and `& .th { padding-block: 0.5rem }` (line 45-54); `& .table:not(.assoclist) { margin-bottom: 0; border-radius: 0; border: 0; background: transparent; box-shadow: none; & tr { &:hover { background: transparent } @media (width < 48rem) { display: table-row; border: 0; padding: 0 } } & td { border: 0; padding: 0.125rem 0; @media (width < 48rem) { display: table-cell } &:first-child { padding-right: 0.5rem; white-space: nowrap; opacity: 0.7 } } }` (line 57-91) · 944e7c5 2026-05-31 (in `_patches.css`) · 0.11.10 (first tag v0.12.0) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: #66 (still open) · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-modem-modemdata-modempreview.md b/luci-theme-aurora/.dev/compat/pages/admin-modem-modemdata-modempreview.md new file mode 100644 index 00000000..7737b130 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-modem-modemdata-modempreview.md @@ -0,0 +1,12 @@ +# admin-modem-modemdata-modempreview + +## modemdata-badge-width — let signal badges inside info cards size to content +- page: admin-modem-modemdata-modempreview · Modem → Modem Data → (preview) (menu path inferred from the page name; not in the local LuCI checkouts) +- package: luci-app-modemdata (third-party; named in the patch header and PR #80) +- dom: `.ifacebox-body .ifacebadge` on the "General Information" cards (PR #80 text) +- symptom: PR #80 body: "Aurora's default ifacebox styles spread modemdata rows apart, wrap signal badges vertically, and leave cards at uneven heights." +- cause: unknown beyond the PR wording — the merged diff (910c85d, 12 lines) contains only the badge width override; the row-spacing and card-height parts of the PR description have no rule in the commit +- fix: patches/admin-modem-modemdata-modempreview.css line 8-12 `[data-page="admin-modem-modemdata-modempreview"] { & .ifacebox-body .ifacebadge { width: auto !important; } }` · 910c85d 2026-06-28 (author CJay D Acopra, PR #80) · 1.0.2 (first tag v1.1.0) — added to `_patches.css` as `@apply w-auto!`, split into its own file by 1c924cf, native CSS by e14cfcd +- verified: not measured (no record) +- fixture: none (backfill) +- issue: PR #80 · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-modem-qmodem.md b/luci-theme-aurora/.dev/compat/pages/admin-modem-qmodem.md new file mode 100644 index 00000000..3cdb0249 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-modem-qmodem.md @@ -0,0 +1,12 @@ +# admin-modem-qmodem + +## qmodem-theming — colours, borders, hover states, tab and table styling across the QModem pages +- page: admin-modem-qmodem (prefix; covers `-overview`, `-sms`, `-sms-conversation-`, `-sms_sim`, `-config_advanced`, `-settings`, `-network_config`) · Modem → QModem → … (menu paths inferred from the page names; the app is not in the local LuCI checkouts) +- package: luci-app-qmodem (third-party; named in the patch header and PR #92) +- dom: `[data-page^="admin-modem-qmodem"]`: `.copyright-section`, `fieldset.cbi-section > legend`, `fieldset.cbi-section`, `.cbi-section-table-titles.named::before` (line 9-42); `[data-page^="admin-modem-qmodem-sms-conversation"]`: `#sms-messages-area`, `.sms-message-bubble`, `.sms-message-content`, `.sms-message-meta` (line 44-64); `[data-page="admin-modem-qmodem-config_advanced"]`: `.cbi-tabmenu li` and `.cbi-tab` (line 66-96; the file's comment: "targets
  • directly (not li > a) because qmodem's tab markup has no inside tabs"); `[data-page="admin-modem-qmodem-sms"]`: `.td > div`, `.td > span[style*="margin-left"]`, `.sms-conversations` (+ `> .cbi-section`, `fieldset.cbi-section`, `.table.cbi-section-table`), `.cbi-section-table-row.tr` (+ hover), `.sms-unread-conversation`, `.sms-unread-badge` (line 98-175); `[data-page="admin-modem-qmodem-sms_sim"]`: `.cbi-section-table-row.tr` (+ hover) (line 177-197); `[data-page="admin-modem-qmodem-settings"]`: `.cbi-section-table-descr.named::before` (line 201-206) +- symptom: not recorded per rule. PR #92 body: "Styles qmodem overview, SMS, conversation, advanced config, network config, and settings pages with consistent theming (borders, colors, hover states, table formatting, and tab styling)." +- cause: unknown (commit 7066351 has subject only; the PR body lists scope, not what each page did wrong) +- fix: patches/admin-modem-qmodem.css (whole file) · 7066351 2026-07-25 (author shiraneko, PR #92 by cjayacopra) · 1.1.2 (first tag v1.2.0) — written with `@reference`/`@apply`, rewritten natively by e14cfcd 2026-07-27 (1.1.8; radii became `calc(var(--radius-base) * n)`) +- verified: not measured (no record) +- fixture: none (backfill; DOM snapshots exist in `.dev/mocks/admin-modem-qmodem*.html` — overview, sms, sms_sim, config_advanced, settings, network_config) +- issue: PR #92 · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-network-network.md b/luci-theme-aurora/.dev/compat/pages/admin-network-network.md new file mode 100644 index 00000000..e16eeed0 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-network-network.md @@ -0,0 +1,47 @@ +# admin-network-network + +## bridge-vlan-descr-hidden — hide the table description row on mobile +- page: admin-network-network · Network → Interfaces → Devices → Bridge VLAN Filtering (efa07d8 body: "Network -> Interfaces -> Devices -> Bridge VLAN Filtering") +- package: stock luci-mod-network +- dom: `.table tr.cbi-section-table-descr` — global selector, added for this page +- symptom: not recorded beyond "to improve layout" (efa07d8 body) +- cause: unknown (efa07d8 body states the action, not what the descr row did wrong) +- fix: components/_table.css `&.table-titles, &.cbi-section-table-titles, &.cbi-section-table-descr { max-md:hidden }` (line 58-62) — `.cbi-section-table-descr` was added to the existing list · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## ifacebox-cells-full-row — interface status/box cells stack full-width on mobile +- page: admin-network-network · Network → Interfaces (menu.d title "Interfaces") +- package: stock luci-mod-network +- dom: `td[data-name="_ifacebox"]`, `td[data-name="_ifacestat"]` — DummyValue cells of the `interface` GridSection (interfaces.js:296-297 query `[data-name="_ifacestat"] > div` and `[data-name="_ifacebox"] .ifacebox-body`) +- symptom: not recorded (bec6830 subject: "feat: enhance ifacebox display in td") +- cause: unknown (commit bec6830 has subject only) +- fix: components/_table.css `[data-page="admin-network-network"] & { &[data-name="_ifacebox"], &[data-name="_ifacestat"] { … } }` nested under `& td, & .td` (line 214-219) `max-md:basis-[100%]` · bec6830 2025-09-23 · 0.4.3_alpha (first tag v0.5.0_beta). The same commit added the `td &` variants of `.ifacebox` that 397e46b later moved under `#cbi-network-interface` (next entry) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## interface-cards-floor — equal-width interface cards, horizontal card layout on mobile +- page: admin-network-network · Network → Interfaces +- package: stock luci-mod-network +- dom: `#cbi-network-interface` (id of `form.Map('network')` + GridSection `'interface'`, interfaces.js:497-501) — five descendant rules on `.ifacebox`, `.ifacebox-head`, `.ifacebox-body`, `.ifacebox-body > div`, `.ifacebox-body small` +- symptom: 397e46b body: "The interfaces overview sizes every card by its content, so a bridge carrying seven device icons rendered far wider than a single-port one." +- cause: 397e46b body: "The shared `.ifacebox` min-width had been masking half of that until 1.2.7 lowered it to stop the port status grid overlapping. The floor now sits on the overview's own section instead — `#cbi-network-interface`, the scope luci-theme-bootstrap already uses for this page — at 152px, and the five `td &` rules that only ever applied there move under it too rather than reaching every `.ifacebox` inside any table cell." +- fix: components/_card.css `#cbi-network-interface &` at line 72-74 (`.ifacebox`: `max-md:flex-row md:min-w-38`), 83-85 (`.ifacebox-head`: `max-md:flex max-md:w-auto max-md:shrink-0 max-md:items-center max-md:justify-center max-md:rounded-l-3xl max-md:rounded-tr-none max-md:border-r max-md:border-b-0`), 107-109 (`.ifacebox-body`: `max-md:flex-row max-md:items-center max-md:rounded-r-3xl max-md:rounded-bl-none max-md:py-2 max-md:pr-2 max-md:pl-4`), 144-146 (`.ifacebox-body > div`: `max-md:w-auto max-md:flex-1 max-md:space-y-1`), 163-165 (`.ifacebox-body small`: `max-md:mt-0`) · 397e46b 2026-08-30 · 1.2.10 (first tag v1.3.0). The mobile rules were first written as `td &` in bec6830 (2025-09-23) +- verified: no before/after measurement of the card widths recorded; the body states the size changes it made: "icons 24px -> 20px, the gap between adjacent ones 8px -> 4px, card padding 16px -> 12px and the label line spacing 16px -> 4px", floor 152px, `.ifacebox-body > img` / badge icons "intrinsic 32px" → 20px, zone swatch 33px tall → vertical padding 2px +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## bridge-vlan-dropdown-clip — let the Bridge VLAN table's dropdowns escape their wrapper on mobile +- page: admin-network-network · Network → Interfaces → Devices → Configure → Bridge VLAN filtering (path from efa07d8's wording of the same table; c5a2dff itself only says "bridge-vlan table") +- package: stock luci-mod-network +- dom: `[data-name="bridge-vlan"] > div` — the SectionValue option `bridge-vlan` wrapping the Bridge VLAN TableSection (tools/network.js:1354); patch rule under `@media (width < 48rem)` +- symptom: dropdown clipped on mobile (c5a2dff subject: "fix(mobile): resolve clipped dropdown in bridge-vlan table") +- cause: unknown (commit c5a2dff has subject only; the diff shows only the override, not which rule clipped the dropdown) +- fix: patches/admin-network-network.css line 8-12 `@media (width < 48rem) { [data-name="bridge-vlan"] > div { overflow: visible !important; } }` · c5a2dff 2025-10-19 · 0.6.1_beta (first tag v0.7.0_beta) — written as `@layer patches > admin-network-network { … @apply max-md:!overflow-visible }` in main.css, moved to `_patches.css` by aedf3dc, split into its own on-demand file by 1c924cf (#82, `@reference` + `@apply`), rewritten as native CSS by e14cfcd +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +See also: e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`. diff --git a/luci-theme-aurora/.dev/compat/pages/admin-network-wireless.md b/luci-theme-aurora/.dev/compat/pages/admin-network-wireless.md new file mode 100644 index 00000000..d5a0eec5 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-network-wireless.md @@ -0,0 +1,14 @@ +# admin-network-wireless + +## assoclist-full-row-cells — Associated Stations cells take a full row on mobile +- page: admin-network-wireless · Network → Wireless, "Associated Stations" table (wireless.js:2667 creates `#wifi_assoclist_table`, h3 at :2680). The same id is also created by luci-mod-status 60_wifi.js:243 on Status → Overview; 1dbd95a does not say which page it was written against +- package: stock luci-mod-network (and luci-mod-status for the overview copy) +- dom: `#wifi_assoclist_table td`, `#wifi_assoclist_table .td` (class `table assoclist`); rule also moves the `data-title` ::before label (`before:max-md:mb-1.5`) +- symptom: not recorded (1dbd95a subject: "Ensure each table cell in Associated Stations takes up a full row on mobile") +- cause: unknown (commit 1dbd95a has subject only) +- fix: components/_table.css `#wifi_assoclist_table &` nested under `& td, & .td` (line 156-158) `max-md:flex-[0_0_100%] max-md:px-3 max-md:py-2 before:max-md:mb-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +See also: 50c2b84 lists "Network => Wireless host: Fix overflow when td text is too long" as one motivation for `.td.cbi-value-field { break-all }` — that lineage is recorded in `admin-services-dockerman.md` (the rule's current form comes from a2d110a). e86b70f lists "Network => (Interfaces, Wireless, Firewall)" for the `.cbi-section-actions` mobile rule — recorded in `admin-status-overview.md`. diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-dockerman.md b/luci-theme-aurora/.dev/compat/pages/admin-services-dockerman.md new file mode 100644 index 00000000..d77fe8f1 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-dockerman.md @@ -0,0 +1,13 @@ +# admin-services-dockerman + +## info-table-entry-column — value cells break only words that do not fit + +- page: admin-services-dockerman-overview · Services → Dockerman JS → Overview +- package: luci-app-dockerman (openwrt/luci, the JS rewrite) +- dom: `.cbi-section.cbi-tblsection > table.table.cbi-section-table > tr.cbi-section-table-row > td.td.cbi-value-field[data-name=entry|value]` — `docker info` rendered as two-column TableSections; the value column holds multi-KB JSON with no spaces (Runtimes, Plugins) +- symptom: the entry column collapsed to ~42px and showed one character per line (`MemoryLimit` over 7–8 lines) beside the JSON values +- cause: `.td.cbi-value-field` carried `word-break: break-all` at every width (added in 50c2b84 as an overflow guard). `break-all` drops every cell's min-content to about one character; auto table layout then hands spare width out by max-content, so the JSON took nearly all of it. bootstrap sets no word-break on desktop cells — its labels stay whole but the JSON stretches the table to ~3.5k px and the page scrolls sideways. +- fix: `components/_table.css` `& td, & .td { &.cbi-value-field { wrap-anywhere md:max-w-prose } }` — `wrap-anywhere` keeps break-all's min-content (same overflow guard) but only breaks a word that does not fit; `md:max-w-prose` caps each cell's intrinsic contribution so one huge value cannot starve the other columns (the used width may still exceed it: 944px measured) · a2d110a · 1.3.2. Lineage: 50c2b84 2025-10-12 (0.5.8_beta) added `&.cbi-value-field { break-all }` for "System => (Software, Mount Points): Fix overflow when td text is too long" and "Network => Wireless host: Fix overflow when td text is too long", replacing the global `td max-w-94` it removed for passwall (see `admin-services-passwall.md`); 031283c 2026-07-09 added the mobile-card utilities on the same rule (`max-md:mb-1 max-md:basis-full max-md:self-start max-md:text-left` and the `[data-widget=…] max-md:basis-1/2` sub-rule) +- verified: device, Chromium, Environment table before → after: entry column 42 → 260px at 1440, 42 → 196 at 1024, 41 → 152 at 800; short words split across lines 41 → 0 / 0 / 2; mobile cards unchanged; section height 8301 → 3797px at 1440; firewall zone and rule GridSections kept identical column widths at 1440/1024/800/390; a file:// fixture gave the same result in Chromium, WebKit and Firefox. Side effect noted, not measured: on admin-system-mounts the GridSection value cells take the 65ch cap instead of the page's `max-w-104` (same specificity, later rule). +- fixture: fixtures/admin-services-dockerman-overview--info-table.html (entry column `nowrap` at 1440/1024/390 — at 800 two short words still split, so that width is not asserted) +- issue: #96 · mirror: shadcn f4d7219 · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-mini-diskmanager.md b/luci-theme-aurora/.dev/compat/pages/admin-services-mini-diskmanager.md new file mode 100644 index 00000000..c5f9a11d --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-mini-diskmanager.md @@ -0,0 +1,25 @@ +# admin-services-mini-diskmanager + +## partition-table-font — the table's own font-size reaches its cells + +- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager +- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager) +- dom: `table#partition-table- > tr.tr > td.td` — the `` carries inline `table-layout:fixed; width:100%; font-size:12px`; first `th` 180px, the other seven `width:12.5%; min-width:90px`; the type cell is ` NAME` +- symptom: type/filesystem and mount-point cells wrap — swatch on one line, name below; long names break mid-word (`0BOOTCONFIG` / `1`, `/mnt/mmcblk0` / `p6`) +- cause: `td, .td` set `text-base` (16px) directly, so the table's inline 12px never reached the cells; fixed columns sized for 12px got 16px text and `wrap-break-word` split what did not fit. bootstrap puts its font size on `.table` and lets cells inherit. +- fix: `components/_table.css` `table.table, .table` `text-base max-md:text-sm` (moved off `td, .td`, which now inherit) · dd7c64b · 1.3.3 +- verified: device 192.168.8.1, CDP before/after in one page load — cells 16 → 12px; wrapped cells 5 → 1 at 1280 (the remaining `0BOOTCONFIG1` wraps under bootstrap at that width too), 5 → 0 at 1920, 0 → 0 at 390; eleven stock pages (overview, routes, processes, interfaces, firewall zones/rules, DHCP, wireless, mounts, startup, SSH keys) showed zero font-size/line-height changes on any text-bearing table element at 1280 and 390 +- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html +- issue: #65 · mirror: shadcn 4f1c7f4 · status: active + +## partition-table-gutter — the stable scrollbar gutter leaves the type cell 0.9px short at 1280 + +- page: admin-services-mini-diskmanager-minidiskmanager · Services → Disk Manager +- package: luci-app-mini-diskmanager (4IceG/luci-app-mini-diskmanager) +- dom: `table#partition-table-` (inline `table-layout:fixed; width:100%`); type cell ` 0BOOTCONFIG` +- symptom: with classic scrollbars at a 1280px viewport the swatch and `0BOOTCONFIG` split onto two lines; overlay scrollbars are unaffected +- cause: `_elements.css` `html` `[scrollbar-gutter:stable]` (added so short pages and open modals stop shifting the layout) reserves the 11px thin scrollbar on every page, which is what a scrolling page already paid; the #65 fit had 0.6px to spare +- fix: none on the page; the fixture pins `html { scrollbar-gutter: auto }` because its 1153px width was measured without a scrollbar, so it keeps guarding #65's inherited 12px +- verified: headless Chrome, fixture at 1280 — table 1152.7 → 1142.1px, cell content 114.9 → 113.4px against a 114.3px line (fits → wraps); `pnpm test` compat fails without the pin and passes with it +- fixture: fixtures/admin-services-mini-diskmanager-minidiskmanager--partition-table.html +- issue: — · mirror: shadcn 50c53a9 · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-config.md b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-config.md new file mode 100644 index 00000000..cad46a72 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-config.md @@ -0,0 +1,12 @@ +# admin-services-openclash-config + +## openclash-subinfo-icons — fix the subscription-info icon size +- page: admin-services-openclash-config · Services → OpenClash → Config Manage (controller openclash.lua:84 entry title "Config Manage") +- package: luci-app-openclash (vernesong; the ImmortalWrt feed copy was checked) +- dom: `.sub_div img` — `.sub_div` comes from `luasrc/view/openclash/sub_info_show.htm`, the template used by config.lua:191 +- symptom: not recorded (87522d8 subject: "feat: add openclash patch and remove btn spacing") +- cause: unknown (commit 87522d8 has subject only) +- fix: patches/admin-services-openclash-config.css line 8-13 `[data-page="admin-services-openclash-config"] { .sub_div img { height: 1.25rem; width: 1.25rem; } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — written as `@layer patches > luci-app-openclash { [data-page=…] .sub_div img { @apply h-5 w-5 } }` in main.css; `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-log.md b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-log.md new file mode 100644 index 00000000..dedf9066 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-log.md @@ -0,0 +1,12 @@ +# admin-services-openclash-log + +## openclash-log-dark-bg — dark page background on the Server Logs page +- page: admin-services-openclash-log · Services → OpenClash → Server Logs (controller openclash.lua:85 entry title "Server Logs") +- package: luci-app-openclash (vernesong) +- dom: `[data-page="admin-services-openclash-log"]` (the body element itself) +- symptom: not recorded (87522d8 subject only) +- cause: unknown (commit 87522d8 has subject only) +- fix: (historic, main.css `@layer patches > luci-app-openclash`) `[data-page="admin-services-openclash-log"] { @apply dark:bg-aurora-night-teal; }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: n/a · status: removed 30f922d (2025-11-09 "chore: remove patch styles and adjust DOM indentation" — body: "Removed temporary patch styles … Test case: openclash → Server Logs (dark mode background)"; 0.7.9_beta, first tag v0.8.0_beta) diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-settings.md b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-settings.md new file mode 100644 index 00000000..a93392b4 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-openclash-settings.md @@ -0,0 +1,23 @@ +# admin-services-openclash-settings + +## openclash-settings-select-full — selects fill the value field on mobile +- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings (controller openclash.lua:71 entry title "Plugin Settings") +- package: luci-app-openclash (vernesong) +- dom: `.cbi-value-field .cbi-input-select` under `@media (width < 48rem)` +- symptom: not recorded (a667f02 subject: "feat: add openclash cbi-input-select style patch") +- cause: unknown (commit a667f02 has subject only; its diff is a whole-file rewrite of main.css, the rule as first written was `[data-page="admin-services-openclash-settings"] .cbi-value-field .cbi-input-select { @apply max-md:!w-full }`) +- fix: patches/admin-services-openclash-settings.css line 9-13 `@media (width < 48rem) { .cbi-value-field .cbi-input-select { width: 100% !important; } }` · a667f02 2025-09-26 · 0.4.6_alpha (first tag v0.5.0_beta) — `_patches.css` via aedf3dc; own file via 1c924cf; native via e14cfcd +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## openclash-diag-grid — stack the Debug Logs diagnostic controls in one column on mobile +- page: admin-services-openclash-settings · Services → OpenClash → Plugin Settings → Debug Logs tab (settings.lua:81 tab "debug", :1308 DummyValue rendering `debug.htm`) +- package: luci-app-openclash (vernesong) +- dom: `.diag-style` and `.diag-style > div` — from `luasrc/view/openclash/debug.htm` (which itself styles `.diag-style` for `[data-darkmode="true"]` at its line 12-17) +- symptom: not recorded (87522d8 subject only) +- cause: unknown (commit 87522d8 has subject only) +- fix: patches/admin-services-openclash-settings.css line 15-33 `.diag-style { & > div { display: flex; flex-direction: column; align-items: center; gap: 0.5rem } @media (width < 48rem) { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 1rem; & > div { width: 100% !important; gap: 0.25rem } } }` · 87522d8 2025-09-17 · 0.3.6_alpha (first tag v0.4.0-alpha) — first written unscoped under `@layer plugins > luci-app-openclash` (applied on every page); 7d9f6a1 merged `_plugins.css` into `_patches.css`; 1c924cf scoped it to this page (PR #82: "openclash `.diag-style` → settings"); native via e14cfcd +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-passwall.md b/luci-theme-aurora/.dev/compat/pages/admin-services-passwall.md new file mode 100644 index 00000000..85bba9fd --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-passwall.md @@ -0,0 +1,14 @@ +# admin-services-passwall + +## td-global-max-width — a global `max-w-94` on every table cell +- page: admin-services-passwall-node_list · Services → PassWall → Node List (controller passwall.lua:39 entry title "Node List") — the page that got the rule removed; the page it was added for is not recorded +- package: luci-app-passwall (xiaorouji; the ImmortalWrt feed copy was checked) +- dom: `.table td, .table .td` (global) — on Node List the `td.cbi-section-actions` cell holds the row's action buttons +- symptom: on removal — 50c2b84 subject: "Services => passwall => Node List cbi-section-actions: Fix overflow beyond td max width" (the action buttons ran past the capped cell). On addition — not recorded +- cause: addition: unknown (efa07d8's body describes two other changes — "Removed conflicting styling that prevented setting `overflow-auto`, which was necessary to maintain the sticky positioning of `th` elements" and hiding `.cbi-section-table-descr` on the Bridge VLAN page — and says nothing about `max-w-94`, which the diff adds to td alongside `max-md:max-w-full` and col caps). Removal: the fixed cap could not hold the Node List action cell (50c2b84 subject); d276fd3 later restates the lesson: "the fix avoids applying a global maximum width to table data cells (`td`) to prevent regressions in CBI section actions (e.g., in Services -> passwall -> Node List)" +- fix: (historic, main.css `& td, & .td`) `max-w-94` · efa07d8 2025-09-18 · 0.3.7_alpha (first tag v0.4.0-alpha) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: n/a · status: removed 50c2b84 (2025-10-12 "fix: table content overflow", 0.5.8_beta, first tag v0.6.0_beta). The same commit added `.td.cbi-value-field { break-all }` as the replacement overflow guard for "System => (Software, Mount Points)" and "Network => Wireless host" — see `admin-services-dockerman.md`; per-page caps came back scoped: `[data-page="admin-system-mounts"]` (d276fd3, `admin-system-mounts.md`) and `[id*="status_leases"]` (80be54c, `admin-status-overview.md`) + +See also: bbb355b 2025-10-26 "fix: resolve tooltip flicker and add footer spacing" — body: "added top margin to the footer to avoid overlap with main content in plugins that lack proper view containers (e.g., PassWall)"; a global `footer` rule (currently _layout.css line 644), not traced further here. diff --git a/luci-theme-aurora/.dev/compat/pages/admin-services-samba4.md b/luci-theme-aurora/.dev/compat/pages/admin-services-samba4.md new file mode 100644 index 00000000..d283801c --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-services-samba4.md @@ -0,0 +1,23 @@ +# admin-services-samba4 + +## samba4-cell-padding — tighter th/td padding inside the Network Shares form +- page: admin-services-samba4 · Services → Network Shares (title from luci-app-samba4 menu.d; 1dbd95a writes it as "NAS => Network Shares => Shared Directories") +- package: stock luci-app-samba4 +- dom: `#cbi-samba4` (id of `form.Map('samba4')`, samba4.js:23) containing the `sambashare` TableSection "Shared Directories" (samba4.js:93); rule hits every `th/.th` and `td/.td` under it +- symptom: not recorded (1dbd95a subject: "Reduce cell padding in NAS => Network Shares => Shared Directories") +- cause: unknown (commit 1dbd95a has subject only; no symptom or measurement) +- fix: components/_table.css `#cbi-samba4 &` (nested under `& th, & .th`, line 114-116, and under `& td, & .td`, line 148-150) `px-0.5 py-1.5` · 1dbd95a 2025-10-17 · 0.5.14_beta (first tag v0.6.0_beta) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## samba4-section-overflow — horizontal scroll on the samba4 card +- page: admin-services-samba4 · Services → Network Shares (menu path inferred from the page name) +- package: stock luci-app-samba4 +- dom: `#cbi-samba4 .cbi-section` +- symptom: not recorded (2e1d18a subject: "table overflow issue on samba4, package, and autoreboot pages") +- cause: unknown (commit 2e1d18a has subject only). The diff shows the section went from `overflow-x-auto` to `overflow-visible` globally and got `#cbi-samba4 & { @apply overflow-x-auto; }` back for this page only, alongside dropping `table-fixed` from `.table` +- fix: (historic, main.css `.cbi-section`) `#cbi-samba4 & { @apply overflow-x-auto; }` · 2e1d18a 2025-08-31 · 0.1.3_alpha (first tag v0.2.0-alpha) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: n/a · status: removed 6b9b328 (2025-09-03 "feat: make dropdown fill based on content, simplify table" — `.cbi-section` went back to global `overflow-x-auto`, so the per-page override was deleted with it) diff --git a/luci-theme-aurora/.dev/compat/pages/admin-statistics-graphs.md b/luci-theme-aurora/.dev/compat/pages/admin-statistics-graphs.md new file mode 100644 index 00000000..a2cfa2ae --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-statistics-graphs.md @@ -0,0 +1,12 @@ +# admin-statistics-graphs + +## rrd-graphs-dark — invert and hue-rotate the rendered graph images in dark mode +- page: admin-statistics-graphs · Statistics → Graphs (menu.d title "Graphs"; edf378d: "Affected pages: Statistics => Graphs") +- package: stock luci-app-statistics +- dom: `[data-plugin] img` — the per-plugin tab containers built in graphs.js:75 (`'data-plugin': plugin`) holding the rrdtool PNGs; dark scope is `[data-darkmode="true"]` on `` +- symptom: not recorded beyond "Adapt img background color for dark mode" (edf378d body) +- cause: edf378d body: "Adapt img background color for dark mode" — the graphs are server-rendered images whose light background does not follow the theme; nothing further recorded +- fix: patches/admin-statistics-graphs.css line 8-10 `[data-darkmode="true"] [data-page="admin-statistics-graphs"] [data-plugin] img { filter: hue-rotate(150deg) invert(100%); }` · edf378d 2025-10-17 · 0.5.16_beta (first tag v0.6.0_beta) — written as `@layer luci-app-statistics { [data-page=…] [data-plugin] img { @apply dark:hue-rotate-150 dark:invert } }` in main.css; `_plugins.css` after aedf3dc, merged into `_patches.css` by 7d9f6a1, own file via 1c924cf, native via e14cfcd (the e14cfcd body cites this file: "the two-rule statistics patch alone went 1,609 B → 110 B") +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-status-logs.md b/luci-theme-aurora/.dev/compat/pages/admin-status-logs.md new file mode 100644 index 00000000..2b9e35ca --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-status-logs.md @@ -0,0 +1,12 @@ +# admin-status-logs + +## log-viewer — parse the read-only `#syslog` textarea into a column-aligned, colour-coded view +- page: admin-status-logs (prefix; covers `admin-status-logs-syslog`, `admin-status-logs-dmesg` and the bare `/logs` alias) · Status → System Log / Kernel Log (menu.d: "admin/status/logs" "System Log" alias → "…/syslog" "System Log", "…/dmesg" "Kernel Log") +- package: stock luci-mod-status +- dom: `textarea#syslog` inside `#content_syslog`; the patch inserts `div.syslog-view` (rows `.log-row.lv-*` with `.log-time/.log-sev/.log-tag/.log-msg`) after the textarea and adds `.log-enhanced` to the container. Styling lives in components/_syslog.css in main.css, not in a patch +- symptom: none — this is an enhancement, not a breakage fix (a5b44da: "The first payload reworks the System/Kernel Log pages") +- cause: a5b44da body: "the read-only #syslog textarea stays in place as LuCI's polled data source while a dirty-checked viewer parses each line (classic logread and the bracketed RPC format, whitelist-gated) into aligned time/level/source columns with per-severity colors, whole lines scrolling sideways, and a flash-free takeover via an insertion observer. Unknown formats trip a parse gate and the stock textarea simply shows again. One admin-status-logs name covers both tabs on every supported release — 23.05, 24.10 and master all mount the pages under admin/status/logs/*." Why the CSS is not a patch: "The viewer's CSS is core-page styling, not third-party compat, so it lives in components/_syslog.css inside main.css where the full utility pipeline applies" +- fix: src/resource/patches/admin-status-logs.js (whole file; router lifecycle `window.luciPatches["admin-status-logs"] = { mount, unmount }` at line 292-295, `mount()` at line 296) · a5b44da 2026-07-27 · 1.1.7 (first tag v1.2.0). Follow-ups: ee16953 2026-07-29 (1.1.9) "contain select-all, follow the tail, leave dmesg unsplit" — Ctrl+A grabbed the whole page, tail-follow when pinned to the bottom, dmesg rendered verbatim because "kernel lines have no uniform tag grammar"; e681ecb 2026-07-29 (1.1.10) "render every log token verbatim, copies byte-identical" — timestamps/colons were being rewritten, now "joining the rendered tokens with single spaces reproduces the source line byte for byte, and a test pins that round-trip"; 664c795 2026-08-16 (1.2.1) and e748f7c 2026-08-26 (1.2.6) wired the mount/unmount lifecycle for the same-document router (subjects only read) +- verified: a5b44da: "Node tests pin the parser against each format family and the payload's home" (`.dev/tests/log-patch.test.js`); e681ecb: round-trip test. No rendering measurement recorded +- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-status-logs-syslog.html`) +- issue: — · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-status-overview.md b/luci-theme-aurora/.dev/compat/pages/admin-status-overview.md new file mode 100644 index 00000000..bf369a08 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-status-overview.md @@ -0,0 +1,34 @@ +# admin-status-overview + +## leases-td-max-width — cap DHCP lease cells so DUID/IAID columns cannot widen the table +- page: admin-status-overview · Status → Overview, "Active DHCP Leases" / "Active DHCPv6 Leases" tables (luci-mod-status 40_dhcp.js:110 `#status_leases`, :169 `#status_leases6`; `[id*=]` matches both). The commit subject says "DHCP > Leases"; the ids exist only in this status include +- package: stock luci-mod-status +- dom: `.table[id*="status_leases"] .td` (class `table leases` / `table leases6`, div-based `.td` cells) +- symptom: table overflow from the DUID/IAID columns (80be54c subject) +- cause: unknown (commit 80be54c has subject only; no DOM or widths recorded) +- fix: components/_table.css `&[id*="status_leases"] { & .td { … } }` (line 137-141) `max-w-62 max-md:max-w-full` · 80be54c 2025-12-12 · 0.9.1_beta (first tag v0.10.0) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## section-actions-mobile — action-button cell spans the mobile card, buttons share the row +- page: admin-status-overview · Status → Overview → Active DHCP Leases; also System → Mount Points and Network → Interfaces / Wireless / Firewall (all five listed in e86b70f) +- package: stock luci-mod-status / luci-mod-system / luci-mod-network (per the pages listed) +- dom: `.table td.cbi-section-actions` (`> div` wrapper, `> div > .cbi-button`) and `.table td > .cbi-button` +- symptom: not recorded (e86b70f subject: "cbi-button display on mobile for cell view") +- cause: unknown (commit e86b70f has subject only) +- fix: components/_table.css `&.cbi-section-actions` nested under `& td, & .td` (line 190-200) `max-md:flex-[0_0_100%]`, `& > div` `max-md:w-full`, `& > div > .cbi-button` `max-md:flex-1`; plus `& > .cbi-button` (line 202-204) `max-md:w-full` · e86b70f 2025-10-17 · 0.5.15_beta (first tag v0.6.0_beta) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: pending · status: active + +## section-actions-mobile-v0 — original scaffold rule for the actions cell on mobile +- page: unknown (076281f is the initial scaffold commit; no page recorded) +- package: unknown +- dom: `.table td.cbi-section-actions` (`& div`, `& .btn, & .cbi-button`) and a sibling `.td.cbi-section-actions { p-2 max-md:p-1 … }` block in the button layer +- symptom: not recorded +- cause: unknown (076281f "chore: setup luci-theme-aurora with Vite and TailwindCSS" has subject only) +- fix: (historic, main.css) `&.cbi-section-actions { @apply max-md:mt-3 max-md:border-t max-md:pt-3; & div { max-md:flex max-md:flex-wrap max-md:justify-start max-md:gap-2 } & .btn, & .cbi-button { max-md:min-w-0 max-md:flex-1 … } }` · 076281f 2025-08-25 · 0.1.0 (first tag v0.2.0-alpha) +- verified: not measured (no record) +- fixture: none (backfill) +- issue: — · mirror: n/a · status: removed 386dec5 (2025-08-30 "feat: optimize mobile display for table" rewrote the mobile table block and dropped it; the `.td.cbi-section-actions` button-layer block was dropped by 4c99ac8 2025-09-05 "chore: adjust button nesting style structure", which replaced it with the generic `.cbi-section-actions > div { flex flex-row … }`). e86b70f re-added a different rule seven weeks later — see the entry above diff --git a/luci-theme-aurora/.dev/compat/pages/admin-system-diskman.md b/luci-theme-aurora/.dev/compat/pages/admin-system-diskman.md new file mode 100644 index 00000000..496a2102 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-system-diskman.md @@ -0,0 +1,12 @@ +# admin-system-diskman + +## diskman-partition-bar — keep the injected partition bar inside the card on small screens, clip segment labels +- page: admin-system-diskman (prefix; also `-partition-`, `-btrfs-`) · System → Disk Man (controller diskman.lua:24 entry title "Disk Man", alias to `disks`; issue #58 calls it the storage area on the home page) +- package: luci-app-diskman (lisaac; the ImmortalWrt feed copy was checked) +- dom: `td[colspan]:has(> div[title])` and `td[colspan] > div[title]`, `.tr > div[style*="nowrap"] > div[title]` — disk_info.htm:34/88 emit `
    ` and :36 `
    `, with one inline-block `div[title]` per partition sized in percent +- symptom: issue #58: "首页的存储区域会超出宽度,导致页面可以左右滑动" (the storage area exceeds the width; page scrolls sideways), Safari 26 / iOS 26, OpenWrt 24.10.5, theme 0.11; 49fff90 body: "drags the page ~300px wide" +- cause: 49fff90 body: "luci-app-diskman injects the partition bar as a full-span row whose cell carries an inline white-space:nowrap. Once .tr becomes a flex row on mobile, that cell's automatic minimum size is its min-content — the whole unwrapped bar — so it cannot shrink and drags the page ~300px wide (reported in #58 with the page DOM). … clip each segment so its label stops spilling onto the neighbouring one (visible on desktop too)". The file adds: "The segments are inline-blocks sized in percent: any whitespace between them adds a word space on top of the 100% they already sum to" (hence `font-size: 0` on the cell) +- fix: patches/admin-system-diskman.css line 9-35 `[data-page^="admin-system-diskman"] { td[colspan]:has(> div[title]) { flex: 0 0 100%; min-width: 0; overflow: hidden; border-radius: var(--radius-base); font-size: 0 } td[colspan] > div[title], .tr > div[style*="nowrap"] > div[title] { overflow: hidden; font-size: 0.75rem; line-height: 1.5rem; text-overflow: ellipsis } }` · 49fff90 2026-08-02 · 1.1.13 (first tag v1.2.0) +- verified: not measured (the body states the before width, "~300px wide", from the reporter's DOM; no after-number recorded) +- fixture: none (backfill; DOM snapshot exists in `.dev/mocks/admin-system-diskman.html`) +- issue: #58 · mirror: pending · status: active diff --git a/luci-theme-aurora/.dev/compat/pages/admin-system-filemanager.md b/luci-theme-aurora/.dev/compat/pages/admin-system-filemanager.md new file mode 100644 index 00000000..aa7dc893 --- /dev/null +++ b/luci-theme-aurora/.dev/compat/pages/admin-system-filemanager.md @@ -0,0 +1,12 @@ +# admin-system-filemanager + +## filemanager-container-scroll — scrollable file-manager container, flat status bar +- page: admin-system-filemanager · System → File Manager (menu.d title "File Manager") +- package: luci-app-filemanager (in openwrt/luci applications) +- dom: `#file-manager-container` (the app's own ` + {% endif %} + + {% if (favicon_png): %} + + {% endif %} + + + + + + + + + {% if (dispatched?.css): %} + + {% endif %} + {% if (css): %} + + {% endif %} + {% if (token_css_light || token_css_dark): %} + + {% endif %} + {% if (!blank_page): %} + + + {% endif %} + + + + {% if (!blank_page): %} + {% if (nav_type == 'sidebar'): %} + {# First in body: once anything resolves body's style (the page-bg script does), the column would ease shut on load. #} + + {% endif %} + {% if (tokens.struct_main_bg): %} + + + {% endif %} +
    +
    + + {{ hostname }} + + {% if (nav_type == 'sidebar'): %} +
      + {% endif %} + +
      +
      + + {% if (nav_type == 'mega-menu'): %} + {% + /* boardinfo fields are not guaranteed on every platform; the + summary tolerates a missing model/firmware line (hostname is + always set by the kernel). */ + const board_model = boardinfo.model ?? boardinfo.system ?? boardinfo.board_name; + const board_firmware = boardinfo.release?.description ?? + trim(`${boardinfo.release?.distribution ?? ''} ${boardinfo.release?.version ?? ''}`); + -%} +
      + {# + Curtain-reveal layer pair: .desktop-menu-sheet is the opaque + surface that slides down (translateY -100% → 0) and clips its + content; .desktop-menu-canvas counter-translates (100% → 0) so + the panels menu-aurora.js reparents into it appear static while + the sheet edge wipes over them — see _layout.css. + #} +
      + +
      +
      + +
      + {% else %} + + {% endif %} + + {% if (nav_type == 'sidebar'): %} + + {% endif %} + + +
      + +
      + + {% if (toolbar_enabled && length(toolbar_items)): %} +
      +
      + {% for (let i = 0; i < length(toolbar_items); i++): %} + {% let item = toolbar_items[i]; %} + + + + {% endfor %} +
      + + {# + The tooltip is a short state-dependent verb — "Hide" while the + shortcuts are shown, "Show" once collapsed. Both strings ship + in luci-base's po (the theme carries none of its own); the + verbose static label stays on aria-label only. + #} + +
      + + + {% endif %} + +
      + {% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %} +
      +

      {{ _('No password set!') }}

      +

      {{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}

      + {% if (dispatcher.lookup("admin/system/admin")): %} + + {% endif %} +
      + {% endif %} + + {% if (boardinfo.rootfs_type == "initramfs"): %} +
      +

      {{ _('System running in recovery (initramfs) mode.') }}

      +

      {{ _('No changes to settings will be stored and are lost after rebooting. This mode should only be used to install a firmware upgrade') }}

      + {% if (dispatcher.lookup("admin/system/flash")): %} + + {% endif %} +
      + {% endif %} + + + + + {% endif %} diff --git a/luci-theme-aurora/ucode/template/themes/aurora/sysauth.ut b/luci-theme-aurora/ucode/template/themes/aurora/sysauth.ut new file mode 100644 index 00000000..121e4add --- /dev/null +++ b/luci-theme-aurora/ucode/template/themes/aurora/sysauth.ut @@ -0,0 +1,90 @@ +{# + luci-theme-aurora: + Copyright 2025 eamonxg + This theme is derived from luci-theme-bootstrap UT template. + luci-theme-bootstrap: + Copyright 2022 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. +-#} + +{% +import { cursor } from 'uci'; +const boardinfo = ubus.call('system', 'board') ?? {}; +const themeTokens = cursor().get_all('aurora', 'theme') || {}; +const loginLogoSvg = themeTokens.logo_svg || 'logo.svg'; +const loginIconVer = themeTokens.icon_cache_version || '0'; +-%} +{% include('header', { + blank_page: true, + prefetched_boardinfo: boardinfo, + prefetched_tokens: themeTokens +}) %} + +{% if (themeTokens.struct_login_bg): %} + +{% endif %} + +
      + + + +
      + + + +{% include('footer', { blank_page: true }) %} diff --git a/luci-theme-footstrap/.claude/agents/developer.md b/luci-theme-footstrap/.claude/agents/developer.md new file mode 100644 index 00000000..bfde19db --- /dev/null +++ b/luci-theme-footstrap/.claude/agents/developer.md @@ -0,0 +1,75 @@ +--- +name: developer +description: The one writer of a task. Edits inside the card's file list, runs the T0 gates itself, returns a files-and-gates block. Never commits, never reviews its own work. +model: sonnet +tools: Read, Edit, Write, Grep, Glob, Bash +maxTurns: 200 +--- + +You implement one task card and nothing beside it. Protocol: `docs/crew.md`. + +## First turn + +1. `Read` the card the prompt names (`../tmp/task-/card.json`). If it names a `handoff.md`, + read that next: it is the previous developer's state, and the diff in the worktree is theirs. +2. `Read` every file in `card.rules`. You inherit no conversation, so a rule that is not read does + not exist for you. The map, if the card left it empty: + +| Files touched | Rules file | +|---|---| +| `styles/**`, `build-css.sh` | `.claude/rules/css.md` | +| `htdocs/luci-static/**/*.js`, `tests/**` | `.claude/rules/js.md` | +| `*.ut`, `fs-*.js`, `menu-footstrap*.js` | `.claude/rules/chrome.md` | +| `Makefile`, `root/**`, `po/**`, `install.sh` | `.claude/rules/package.md` | +| `CHANGELOG*.md` | `.claude/rules/changelog.md` | + +## Boundaries + +- Edit only paths in `card.files`. A change that needs a file outside the list goes into + `not_done` with the path and the reason; the lead widens the card, you do not. +- `cascade.css` is generated; never touch it. Never `git commit`, `git push`, `git stash`, + `git checkout --`. Never edit a test to make it pass. +- Comments follow CLAUDE.md: the reason, one line, with the number that was measured. + +## Before returning + +1. T0, foreground, nothing over 60 s: `npm test`; the one `node tools/.mjs` the rules file + names for what you touched; `ucode -T -c -o /dev/null ` for every `.ut`; + `npm run lint:js` or `npm run lint:css` for the area. A red gate is yours to fix before you + return; a gate you cannot turn green is a `BLOCKED` with its last 5 lines quoted. +2. `git add -- `. Staging marks the end of the round: the tester reads + round 1 as `git diff HEAD` and every later round as `git diff` against the index. +3. `diff_hash=$(git diff HEAD | git hash-object --stdin)`. +4. If `not_done` is not empty, or you are near your turn limit, write + `../tmp/task-/handoff.md` with five headings: done, files, decisions, dead-ends, next. + +## Voice + +Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone: +a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and +filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add +a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships. + +Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error +text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it +saves. The block's own field names are the ones the schema below prints, unchanged. + +What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays +normal English prose: it is read by people who never saw this session. + +## Return block + +At most 25 lines. Paths, not contents. No diff, no code, no narration of the attempt. + +``` +STATUS: DONE | PARTIAL | BLOCKED +files: + — +gates: + → exit +diff_hash: +staged: yes +not_done: + — +handoff: | none +``` diff --git a/luci-theme-footstrap/.claude/agents/researcher.md b/luci-theme-footstrap/.claude/agents/researcher.md new file mode 100644 index 00000000..d303531f --- /dev/null +++ b/luci-theme-footstrap/.claude/agents/researcher.md @@ -0,0 +1,40 @@ +--- +name: researcher +description: Answers one question from sources and ranks them. For library, API, spec and upstream-LuCI questions; never for anything the repository's own docs/ already settle. Writes no code. +model: sonnet +tools: WebSearch, WebFetch, Read, mcp__context7__resolve-library-id, mcp__context7__query-docs +disallowedTools: Edit, Write, NotebookEdit +maxTurns: 20 +--- + +Answer the question the prompt asks, then show what the answer rests on. Read the source; do not +answer from memory. An unknown stays unknown. + +Rank every source, highest first: spec text > upstream source or commit > official docs > issue +thread > blog. Where ranks disagree, the higher rank wins and the disagreement is stated. For a +library or CLI, Context7 first, the web after. + +## Voice + +Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone: +a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and +filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add +a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships. + +Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error +text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it +saves. The block's own field names are the ones the schema below prints, unchanged. + +What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays +normal English prose: it is read by people who never saw this session. + +## Return block + +At most 25 lines. + +``` +ANSWER: +sources: + +unknown: +``` diff --git a/luci-theme-footstrap/.claude/agents/security.md b/luci-theme-footstrap/.claude/agents/security.md new file mode 100644 index 00000000..5bd8815f --- /dev/null +++ b/luci-theme-footstrap/.claude/agents/security.md @@ -0,0 +1,46 @@ +--- +name: security +description: Runs the security-review skill over one task's diff and reports. Never fixes. Called per task except CSS-only or one-line diffs; the Opus pass before a release or an upstream PR is /security-review in the main thread and is not replaced by this. +model: sonnet +tools: Read, Grep, Glob, Bash +disallowedTools: Edit, Write, NotebookEdit +skills: + - security-review +maxTurns: 25 +--- + +You review the diff the prompt names (`git diff HEAD`, or the path to a patch) with the +`security-review` skill and return findings. You change nothing: a finding goes to the human, and +the human decides whether it becomes a card. + +The surface that matters in this repository, from CLAUDE.md: the installer and its signature chain +(`install.sh`), any shell that runs over a build tree (`tools/*.sh`, `luci-theme-footstrap/*.sh`), +the login template (that page is unauthenticated), sinks in the browser JS (`innerHTML`, `eval`, +URL handling in `fs-*.js`), and the packaging pipeline (`Makefile`, `postinst`, `postrm`, +`uci-defaults`). A diff that touches none of these still gets the skill's pass; say so. + +## Voice + +Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone: +a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and +filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add +a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships. + +Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error +text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it +saves. The block's own field names are the ones the schema below prints, unchanged. + +What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays +normal English prose: it is read by people who never saw this session. + +## Return block + +At most 25 lines. Paths and lines, not contents. + +``` +RESULT: CLEAN | FINDINGS +skill: loaded | unavailable +findings: + : +surface_touched: +``` diff --git a/luci-theme-footstrap/.claude/agents/tester.md b/luci-theme-footstrap/.claude/agents/tester.md new file mode 100644 index 00000000..4a633cfe --- /dev/null +++ b/luci-theme-footstrap/.claude/agents/tester.md @@ -0,0 +1,104 @@ +--- +name: tester +description: Read-only verifier of a task card. Runs the full cycle the card asks for — T0, the two browser gates, both owlab stands, owlab test for release-grade work, the hardware router only when the card says so — and returns PASS or NEEDS_WORK with a command and its output behind every finding. +model: opus +tools: Read, Grep, Glob, Bash +disallowedTools: Edit, Write, NotebookEdit +maxTurns: 140 +--- + +You judge; you do not fix. Asked to fix, answer `read-only` and return `NEEDS_WORK`. Read-only is +a rule you keep, not a fence around you: `Edit` and `Write` are absent from your tools, but `Bash` +would let you write, stage or delete, and none of that is yours to do. You see the +diff and the card, never the developer's reasoning, and that is the point: a judge that watched the +work grades the work it watched. Protocol: `docs/crew.md`; the stands: `docs/development.md`. + +## Inputs + +- `../tmp/task-/card.json`: goal, `files`, `acceptance[]` (command + expected output), `live[]` + (gate + flags), `grade` (`task` | `release`), `hardware`, `round`. +- Round 1 reads `git diff HEAD`. Round N+1 reads `git diff` (worktree against the index, which + holds the previous round) plus the previous findings the prompt carries. Never re-read the full + diff on a later round. + +## Runbook + +Every `tools/bg.sh` call below is paired, in the same turn, with `tools/bg-wait.sh ` started +as a **background** command. A waiter in the foreground blocks past the 5-minute limit and is killed, +which is the failure the pairing exists to prevent. Read every waiter's result before you return: +one still running when you finish wakes you again afterwards and re-sends your verdict as noise. + +1. Classify the diff: css-only / js / template / package-or-build (`Makefile`, `root/**`, + `build-css.sh`, `tools/stage.sh`, `strip-*`). `scope_ok` is `git diff HEAD --name-only` being a + subset of `card.files`. +2. T0, foreground: `npm test`; every `acceptance[].cmd`; `ucode -T -c -o /dev/null ` for each + `.ut`; `npm run lint:js` or `npm run lint:css` by area. +3. `npm run smoke`; `npm run computed-diff`. A css-only diff whose number surprises you gets + `npm run computed-diff -- --control`, which must print 0 before the first number means anything. +4. Stands: `docker ps --format '{{.Names}}\t{{.Ports}}'`. If `owrt2512` and `owrt2410` are not up, + detach: `tools/bg.sh sh -c 'export PATH=$HOME/go/bin:$PATH; owlab up'`, then the waiter. +5. `owlab sync owrt2512 && owlab sync owrt2410`. Prove the bytes arrived: `md5sum` of a touched + resource on the host against `owlab exec owrt2410 -- md5sum /www/luci-static/resources/`. + opkg answers `up to date` to a matching version and installs nothing; the checksum is the proof. +6. Reset the syslog before any live gate (`banip/processing_log` prints it as page content): + `for c in owrt2512 owrt2410; do docker exec owlab-luci-theme-footstrap-$c /etc/init.d/log restart; done`. +7. Every gate in `card.live`, one call per gate per stand, detached: + `tools/bg.sh sh -c 'export PATH=$HOME/go/bin:$PATH; node tools/.mjs --only owrt2512 '` + then `tools/bg-wait.sh `; the same with `--only owrt2410`. Never `npm run live -- --all`: + npm hands the flags to the last gate only. A Playwright stack with `log: []` is a run killed by + the 5-minute limit: report it as `not_run`, never re-run with a larger timeout. +8. Release-grade only (`card.grade == release`, or the diff is package-or-build): + `./tools/stage.sh && owfeed build`; `owlab down` first, because `owlab test` binds host port 2222 + and removes an existing stand; then one detached run per format with the five assertions from + `docs/development.md` "Proving it on a router", verbatim. Say in the verdict that the stands are + down. +9. Hardware only when `card.hardware` is true, which the lead sets on the maintainer's explicit + word for this task; `ssh` and `dev-sync.sh` prompt the human and that prompt is the gate. + Pre-check, and abort if either path is missing: + `ssh 'ls -d /www/luci-static/bootstrap /usr/share/ucode/luci/template/themes/bootstrap/header.ut && uci get luci.main.mediaurlbase'`. + Then `luci-theme-footstrap/dev-sync.sh `; then over ssh + `for f in /usr/share/ucode/luci/template/themes/footstrap/*.ut /usr/share/ucode/luci/template/themes/footstrap/partials/*.ut; do ucode -T -c -o /dev/null "$f" || echo FAIL $f; done`; + then `curl -s -o /dev/null -w '%{http_code}' http:///cgi-bin/luci/` expecting 200 (an + admin path answers 403 unauthenticated and proves nothing). On any failure print the rollback + line — `uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci` — and stop; do + not run it unasked. + +## Findings + +- A finding is `blocking` when it breaks correctness, the card's acceptance, a gate, or + security; everything else is `blocking: false` and never triggers a round on its own. +- Ids are stable across rounds: `::`. The same id twice is what the lead + calls a stall, so do not rename a finding you are repeating. +- Evidence is the command and its output, trimmed to the lines that show it. A finding without a + command is an opinion and does not go in the block. + +## Voice + +Work in caveman full, the mode the lead runs in — every line you emit, not the return block alone: +a question back to the lead, a `BLOCKED` line, a note on a gate that did not run. Drop articles and +filler, one idea per line, fragments over sentences, the short synonym over the long one. Never add +a word to sound caveman: where plain wording is already shorter, it is the plain wording that ships. + +Exact and untouched: commands, flags, paths, `file:line`, numbers with their units, quoted error +text, and every `not`, `no`, `only`, `except` — a dropped negation costs more than every token it +saves. The block's own field names are the ones the schema below prints, unchanged. + +What you leave behind on disk — code, comments, a changelog line, a handoff, an issue draft — stays +normal English prose: it is read by people who never saw this session. + +## Verdict block + +At most 25 lines. No diff, no code. + +``` +VERDICT: PASS | NEEDS_WORK +round: scope_ok: yes | no +gates: + → exit +findings: + blocking: true|false + evidence: → +covered: t0, smoke, computed_diff, owrt2512: [], owrt2410: [], owlab_test: apk|ipk|none, hardware: yes|no +not_run: + — +``` diff --git a/luci-theme-footstrap/.claude/hooks/precommit-gate.sh b/luci-theme-footstrap/.claude/hooks/precommit-gate.sh new file mode 100755 index 00000000..0fd15ff1 --- /dev/null +++ b/luci-theme-footstrap/.claude/hooks/precommit-gate.sh @@ -0,0 +1,91 @@ +#!/bin/sh +# PreToolUse/Bash: hold the changelog contract on `git commit` mechanically. +# +# "NO COMMIT LANDS WITHOUT ITS CHANGELOG ENTRY … in the same commit, in BOTH files" was a rule the +# model had to remember; a rule remembered is a rule broken eventually. This denies the commit +# instead. The two exemptions are exactly the ones CLAUDE.md already names: CLAUDE.md itself, and a +# commit that touches nothing but the changelog (a fix to an [Unreleased] entry already written). +# +# settings.json gates this with `if: Bash(git commit *)`, but the guard below repeats the test: an +# older Claude Code with no `if` support would otherwise pay node's startup on every bash call. +set -eu + +# Without jq the hook cannot read the command at all. Say so and stand aside: a hook that exits +# non-zero here blocks nothing anyway, it only prints a shell error before every bash call. +if ! command -v jq >/dev/null 2>&1; then + echo "precommit-gate: jq not found, the changelog contract is NOT being enforced" >&2 + exit 0 +fi + +IN=$(cat) +CMD=$(printf '%s' "$IN" | jq -r '.tool_input.command // ""') + +# Fast path. Anything that is not a commit leaves without touching git or node. +case "$CMD" in + *'git commit'*) ;; + *) exit 0 ;; +esac + +# Resolved BEFORE the `cd` below, or a hook invoked by a relative path would resolve `$0` against +# the wrong directory. settings.json passes an absolute `${CLAUDE_PROJECT_DIR}/...`, so this only +# matters when the hook is run by hand — which is how it gets tested. +HOOK_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P) + +CWD=$(printf '%s' "$IN" | jq -r '.cwd // ""') +[ -n "$CWD" ] && cd "$CWD" + +# The contract belongs to THIS repository, and a session commits in others: owfeed-packages, and +# the openwrt/luci fork behind an upstream PR. Neither tree has a CHANGELOG.md, so the hook denied +# every commit there over a rule those repositories never agreed to — measured on a package removal +# in owfeed-packages, which could not be committed at all until this test existed. The hook ships +# inside .claude/, so the repository holding that directory is the one it speaks for; anywhere else +# it stands aside without a word. +GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) +[ "$GIT_ROOT" = "$HOOK_ROOT" ] || exit 0 + +deny() { + jq -n --arg r "$1" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $r + } + }' + exit 0 +} + +# `git commit -a` stages tracked changes as it runs, so the staged index is not what the commit +# will hold. Compare against HEAD in that case, and against the index otherwise. +# +# Only the part before the first quote is searched: the message is always quoted, and scanning the +# whole line made `git commit -m "add -a flag"` read as `-a` — which compares against HEAD, sees +# every dirty file in the tree, and denies a commit that holds none of them. +FLAGS=${CMD%%[\"\']*} +case "$FLAGS" in + *' -a'*|*' --all'*) FILES=$(git diff --name-only HEAD 2>/dev/null || true) ;; + *) FILES=$(git diff --cached --name-only 2>/dev/null || true) ;; +esac + +# An empty set is `git commit --amend` with no staged change, or a commit that will fail on its own. +[ -n "$FILES" ] || exit 0 + +# Substantive = everything except the two exemptions. Documentation, CI and packaging are IN, which +# is CLAUDE.md's rule verbatim: "This covers documentation, benchmarks, CI and packaging too". +SUBSTANTIVE=$(printf '%s\n' "$FILES" | grep -v -x -e 'CLAUDE.md' -e 'CHANGELOG.md' -e 'CHANGELOG_ru.md' || true) + +if [ -n "$SUBSTANTIVE" ]; then + HAS_EN=$(printf '%s\n' "$FILES" | grep -c -x 'CHANGELOG.md' || true) + HAS_RU=$(printf '%s\n' "$FILES" | grep -c -x 'CHANGELOG_ru.md' || true) + if [ "$HAS_EN" -eq 0 ] || [ "$HAS_RU" -eq 0 ]; then + deny "Changelog contract: this commit changes $(printf '%s\n' "$SUBSTANTIVE" | wc -l | tr -d ' ') file(s) and stages CHANGELOG.md=$HAS_EN CHANGELOG_ru.md=$HAS_RU. Both must be in the SAME commit, under ## [Unreleased], as '- **one-line effect.** then the rationale'. An entry written afterwards is written from the diff, and the diff is what does not know why. Exempt: CLAUDE.md alone, or a fix to an [Unreleased] entry already written. Files: $(printf '%s' "$SUBSTANTIVE" | tr '\n' ' ')" + fi +fi + +# The mechanical half — sections, order, dates, compare links, RU mirror parity, the mandatory bold +# lead. A bullet with no bold lead is silently dropped from the release page, which is why this runs +# before the commit rather than at tag time. +if ! OUT=$(node tools/changelog.mjs 2>&1); then + deny "node tools/changelog.mjs failed, so this commit would land a broken changelog: $OUT" +fi + +exit 0 diff --git a/luci-theme-footstrap/.claude/hooks/session-start.sh b/luci-theme-footstrap/.claude/hooks/session-start.sh new file mode 100755 index 00000000..7c5575d5 --- /dev/null +++ b/luci-theme-footstrap/.claude/hooks/session-start.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# SessionStart: say out loud when the session was started somewhere the project's rules do not load. +# +# The checkout's parent holds luci-fork/ and tmp/ and is not a git repository. A session started +# there loads no CLAUDE.md, every `owlab` call exits with "no owlab.yaml found", and the failure +# reads as a broken owlab rather than a wrong directory. The marker is owlab.yaml, which exists at +# the repo root and nowhere above it. +set -eu + +NOTES='' +add() { NOTES="${NOTES}${NOTES:+ +}$1"; } + +[ -f owlab.yaml ] || add "No owlab.yaml in $(pwd): this session was NOT started at the repo root, so CLAUDE.md, .claude/rules/ and .claude/settings.json are not loaded and every owlab command will exit with 'no owlab.yaml found'. The repo root is the workspace; the shipped package is luci-theme-footstrap/ one level down. Ask the user to restart from the root rather than working around it." + +[ -n "$NOTES" ] || exit 0 + +jq -n --arg c "$NOTES" '{ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: $c + } +}' diff --git a/luci-theme-footstrap/.claude/rules/changelog.md b/luci-theme-footstrap/.claude/rules/changelog.md new file mode 100644 index 00000000..23a6c1d4 --- /dev/null +++ b/luci-theme-footstrap/.claude/rules/changelog.md @@ -0,0 +1,41 @@ +--- +paths: + - "CHANGELOG*.md" +description: The changelog contract — sections, the bold lead, and the bilingual mirror. +--- + +# The changelog + +**NO COMMIT LANDS WITHOUT ITS CHANGELOG ENTRY.** It goes under `## [Unreleased]`, in the **same +commit as the code**, and in **BOTH `CHANGELOG.md` AND `CHANGELOG_ru.md`** — never one now and its +mirror later. An entry written afterwards is written from the diff, and the diff is exactly what +does not know why. This covers documentation, benchmarks, CI and packaging too, not just code: if +the commit is worth making, it is worth one line saying what changed. The only things that skip it +are `CLAUDE.md` and a fix to an `[Unreleased]` entry already written. + +Enforced by `.claude/hooks/precommit-gate.sh`, which denies a `git commit` whose staged set has +substantive files and not both changelog files, and then runs `npm run changelog`. The hook is the +defence; this page is the reason, and the prose below is the half no gate can check. + +- Sections are `Added / Changed / Deprecated / Removed / Fixed / Security / Performance`, one of + each per version, in that fixed order — append into the section that already exists in its + canonical slot, never add a second `### Changed` on top. +- Each entry is `- **one-line effect.** then the rationale`. The bold lead **is** the release note + (`release-notes.sh` emits leads only), so it must read on its own — **a bullet with no bold lead + is silently dropped from the release**. Write the effect, not the diff; keep the measurement. +- **Keep the number the change was made for.** "20 KB of font for 227 labels", "312 of 336 elements + recoloured by a hostile `:root`", "1.69:1 where AA wants 4.5" — the measurement is the difference + between a changelog and a marketing blurb. +- **Do not cite commits** or retell a file's history. **Edit the existing entry; do not append a + second one.** Merge related commits into one entry. +- The two files are **edited in one commit**. Same section set and order, same numbers, versions and + `compare` links — only the prose may differ, never the facts. The English file is the primary + source for the release-note generator. + +`npm run changelog` holds the mechanical half: the set, order and uniqueness of sections, empty +sections, dates, `compare` links, mirror parity and the mandatory bold lead. It exists because +`[Unreleased]` once accumulated a **duplicate `### Changed`** over several commits, each of which +looked fine on its own, and the notes are generated at tag time when the tag is already pushed. + +The full contract, its sources and the anti-patterns: `docs/releasing.md`. Cutting a release is the +`/release` skill. diff --git a/luci-theme-footstrap/.claude/rules/chrome.md b/luci-theme-footstrap/.claude/rules/chrome.md new file mode 100644 index 00000000..fab081aa --- /dev/null +++ b/luci-theme-footstrap/.claude/rules/chrome.md @@ -0,0 +1,46 @@ +--- +paths: + - "**/*.ut" + - "**/fs-*.js" + - "**/menu-footstrap*.js" +description: The one renderer, the three ownership zones, and the fence that keeps a foreign app out of the chrome. +--- + +# The chrome, and sharing a document with third-party apps + +- **One theme entry, one template dir, one renderer.** Layout is a **client** axis + (`:root[data-layout]`, always an explicit value) — never write a `:not([data-layout=…])` guard and + never add a second renderer. The bar is the base; the vertical sidebar is one guarded override + that wins on specificity. +- Three zones: **ours** (`fs-*`, `--fs-*`, `[data-fs-chrome]`), **shared LuCI** (`.cbi-*`, `#view` — + where an app is *entitled* to win on specificity), **theirs**. Check who owns a name before + "fixing" a collision: `.left`/`.right`/`.center` and `ul.nav` are LuCI's. +- The chrome is defended by **not matching**: the mark in `header.ut`, the fence in `fs-sheets.js`, + the pin in `theme/10-chrome.css` (inherited properties, roots alone). `npm run chrome-fence` + derives the mark from the markup and compares whole canonical strings — a token-wise check once + passed on a fence that was the exact inverse of one. +- **A view's CSS is never deleted** — a sheet imported at module eval never comes back. An invasive + sheet makes the next navigation a full load instead; only a byte-identical duplicate may be + dropped. +- Every Appearance axis is implemented twice (pre-paint in `partials/head.ut`, live in + `fs-prefs.js`) and **the custom property is set BEFORE the attribute** — reversed, a reload paints + one frame in the previous hue. `npm run axes` derives the contract from the JS; `npm run smoke` + watches the two writes actually happen in that order on a live `:root`. + +## Templates + +Syntax-check the way LuCI does, on every `.ut` touched this session — that is T0: + +```sh +ucode -T -c -o /dev/null