00Overview
What a theme is
A theme changes how the NII Iznanka desktop client looks and sounds.
- Since version 6.0 themes are not built into the client
- Each theme sits in the catalog as a separate package
.nftheme - The client downloads a theme only when it is selected
- One base theme remains in the build: NII IZNANKA
Anyone can make a theme. A package is a plain zip: manifest.json, images, sounds and, if needed, your own HTML, CSS and JS. You upload it in the studio, a moderator reviews it, the server signs the version, and the theme appears in everyone’s catalog.
Just manifest.json: colors, fonts, background, sounds, music, intro, your own wording, window size. No code.
Plus theme.css on top of the client window: any shape of buttons, panels and dialogs, animations based on channel state.
Your own window entirely: a page in HTML, CSS and JS. It receives state from the client and controls it via the SDK.
Levels stack. A scene can also have tokens and theme.css: they color the client dialogs that open on top of the scene.
What you can change
| What | With | Level |
|---|---|---|
| All window and dialog colors | tokens | palette |
| Fonts: body, headings, monospace | fonts | palette |
| Background: an image or video, dimming, blur | background | palette |
| Sounds for connect, disconnect, error, clicks | sounds | palette |
| Background music | music | palette |
| An intro when the theme is selected, with subtitles | intro | palette |
| Wording: “CONNECT” → “ENTER THE SPHERE” | words | palette |
| Window size, rounding and shape, down to a torn edge | window | palette |
| Window and tray icon | icon | palette |
| Any window elements: shape, layout, animations | theme.css | style |
| The whole window: your own button, node list and screens | scene | scene |
| A live background behind the regular window | scene.mode: backdrop | scene |
| Your own theme settings in the client settings window | settings | any |
A theme’s journey
- 01Authornftheme pack
- 02Studioupload to the website
- 03Moderationtheme and code
- 04Signaturestudio Ed25519
- 05Catalogiznanka.tech
- 06Clientthe CATALOG tab
01Quick start
A theme in 10 minutes
You will need:
- Node.js 18 or newer
- the author kit nftheme-kit.zip: the
nfthemetool, the SDK, three templates and a preview
Unpack the archive. Nothing to install, no dependencies.
# 1. template: palette, style or scene (default) node nftheme-kit/bin/nftheme.js init my-theme --level scene --name "MY THEME" # 2. preview in the browser, edits are picked up automatically node nftheme-kit/bin/nftheme.js preview my-theme # 3. validation, the same as in the catalog node nftheme-kit/bin/nftheme.js check my-theme # 4. package for upload node nftheme-kit/bin/nftheme.js pack my-theme
You get the file my-theme-1.0.0.nftheme.
- Check it in the client: THEMES → AUTHORS → “Open theme folder”
- Everything works? Drag the package into the studio
Minimal manifest.json
{
"format": 1,
"id": "night-owl",
"name": "NIGHT OWL",
"version": "1.0.0",
"author": "your_nick",
"tokens": { "accent": "#ffd23f", "bg": "#07060a" }
}Put preview.webp (or .png, .jpg) next to it: a 16:9 screenshot of the theme, ideally 1280×720. That is enough, such a package will pass validation.
02Package
Package .nftheme
A package is a zip archive. manifest.json sits at the root; arrange the rest however you like. Paths in the manifest and in CSS are relative to the package root.
- Zipped the whole folder? The outer folder is stripped automatically
- System junk (
__MACOSX,.DS_Store,Thumbs.db) is skipped
my-theme.nftheme ├── manifest.json required ├── preview.webp required: the catalog card, 16:9 ├── icon.png window and tray icon, 256×256 ├── theme.css the “style” level ├── scene/index.html the “scene” level ├── scene/scene.js └── assets/ images, sound, video, fonts
File names and types
- Names: Latin letters, digits, dot, hyphen, underscore. No spaces or Cyrillic
- Two files that differ only in case will not pass: on Windows they are the same file
- The
__nf/folder is reserved by the client - The type is checked by content. An image renamed to .mp3 will not pass
| Kind | Extensions |
|---|---|
| text and code | json css js mjs html txt md vtt, in UTF-8 |
| images | png jpg jpeg webp gif avif svg |
| fonts | woff2 woff ttf otf |
| sound | mp3 ogg opus m4a wav |
| video | mp4 webm |
Limits
| What | Limit |
|---|---|
| whole package | 64 MB |
| unpacked package | 160 MB |
| a single file | 48 MB |
| files in the package | 600 |
| text file | 2 MB |
| image | 8 MB, preview up to 4 MB, icon up to 1 MB |
| file path | 160 characters, up to 8 levels |
| manifest.json | 64 KB |
03Reference
manifest.json
- The first five fields are required, the rest are optional
- Text can be a string or per language:
"name": { "ru": "НОЧЬ", "en": "NIGHT" } - The client uses the user’s language, then Russian, then English
| Field | Type | What it is |
|---|---|---|
format | number | always 1 |
id | string | 3-40 characters: lowercase Latin letters, digits, hyphen. It is yours forever. Studio theme names are taken |
name | text up to 40 | the name in the catalog and in the client |
version | string | 1.0.0. Every new upload must have a higher number |
author | string up to 40 | the author in the package. The catalog shows your nickname from the studio |
description | text up to 400 | the description in the catalog |
tags | up to 6 strings | optional. The client and the catalog no longer show them |
accent | #rrggbb | the card color in the catalog |
minClient | string | minimum client version, at least 6.0.0 |
preview | path | the preview. By default preview.webp, .png or .jpg |
screenshots | up to 8 paths | screenshots for the theme page |
icon | path to a png | window and tray icon |
license | string up to 60 | the license, for example CC-BY-4.0 |
credits | up to 12 strings | credits: whose art, music, fonts |
homepage | https://… | the author’s link on the theme page |
window | object | window size and shape, section 07 |
tokens | object | colors and sizes, section 04 |
fonts | object | fonts, section 04 |
background | object | background, section 05 |
sounds | object | event sounds, section 05 |
music | path or object | background music, section 05 |
intro | path or object | intro, section 05 |
words | object | your own wording, section 06 |
css | path | stylesheet, section 08 |
scene | path or object | scene, section 09 |
settings | array | theme settings, section 11 |
{
"format": 1,
"id": "night-signal",
"name": { "ru": "НОЧНОЙ СИГНАЛ", "en": "NIGHT SIGNAL" },
"version": "1.2.0",
"author": "iznanka",
"description": { "ru": "Серая панель, сигнал вместо кнопки, голос на подъём канала." },
"accent": "#c42b47",
"license": "CC-BY-4.0",
"credits": ["art and sound: fawn", "font Overused Grotesk: OFL"],
"screenshots": ["shots/on.webp", "shots/servers.webp"],
"icon": "icon.png",
"window": { "width": 1280, "height": 720 },
"tokens": { "bg": "#000", "panel": "#a0a0a0", "text": "#fff", "accent": "#c42b47" },
"fonts": { "body": "assets/grotesk.woff2", "display": "assets/pixel.woff2" },
"background": { "image": "assets/field.webp", "dim": 0.2 },
"sounds": { "connect": "assets/up.mp3", "disconnect": "assets/down.mp3", "click": "assets/tick.mp3" },
"music": { "src": "assets/menu.ogg", "volume": 0.35 },
"intro": { "video": "assets/briefing.mp4", "subtitles": "assets/briefing.vtt" },
"words": { "ru": { "connect": "ВЫСАДКА", "disconnect": "ЭВАКУАЦИЯ", "servers": "СФЕРЫ" } },
"css": "theme.css",
"scene": { "entry": "scene/index.html", "mode": "full" },
"settings": [
{ "id": "glitch", "type": "toggle", "label": "Glitch", "default": true }
]
}04Palette
Colors and fonts
Tokens are the variables the client uses to color the window and all dialogs.
- Colors:
#hex,rgb(),rgba(),hsl(),transparent - Sizes:
px,em,rem,% - In CSS and in the scene they are available as
var(--nf-name)
| Token | In the base theme | What it colors |
|---|---|---|
bg | #000000 | the window background beneath everything else |
surface | #0b0b12 | the glow in the center of the window |
panel | rgba(12,10,22,.78) | the background of panels, menus and dialogs |
line | #23232e | borders and dividers |
text | #e9e9f2 | body text |
muted | #6f6f80 | captions and secondary text |
accent | #a855f7 | the main color: connect button, selected node, headings |
accent2 | #7c3aed | the second accent: hover, gradients |
ok | #4ade80 | channel up, “PROTECTED” |
warn | #f97316 | maintenance, warnings |
danger | #ef4444 | errors, channel drop |
glow | rgba(168,85,247,.45) | the glow around accents |
shadow | rgba(0,0,0,.6) | dialog shadows |
overlay | rgba(0,0,0,.72) | the dimming under a dialog |
radius | 0px | the corner rounding of buttons and panels |
border | 1px | border thickness |
gap | 14px | the spacing between panels |
Try it
Tweak the colors on the right. Below is a ready snippet for manifest.json.
Fonts
Three roles. The client loads the file and sets --nf-font-body, --nf-font-display, --nf-font-mono.
body: all textdisplay: large labels and the buttonmono: numbers, the log, addresses
"fonts": {
"body": "assets/fonts/grotesk.woff2",
"display": "assets/fonts/pixel.woff2",
"mono": "assets/fonts/mono.woff2"
}05Palette
Background, sound, intro
Background
| Field | What | Default |
|---|---|---|
image | the background image | none |
video | the background video: muted, looped, over the image | none |
color | the color under the image | none |
fit | cover, contain or fill | cover |
position | as in CSS: center, top, left 30% | center |
dim | dimming 0…1 | 0 |
blur | blur 0…40 px | 0 |
Event sounds
The client plays the sound for each event itself. Each theme has its own on/off switch and volume in the client settings.
| Event | When it plays |
|---|---|
connect | the channel came up |
disconnect | the channel went down |
error | connection failed or the channel dropped |
switch | a node switch on a live channel |
click | a button press in the client window |
hover | hovering over a button |
open, close | a dialog opened or closed |
notify | a notification |
"sounds": { "connect": "assets/sfx/up.mp3", "disconnect": "assets/sfx/down.mp3", "hover": "assets/sfx/tick.mp3" },
"music": { "src": "assets/music/menu.ogg", "volume": 0.35 }Intro
- The clip plays full screen when the theme is selected
- Esc skips it
- Subtitles in WebVTT format
"once": true: the intro plays only the first time the theme is selected
"intro": { "video": "assets/intro.mp4", "subtitles": "assets/intro.vtt", "volume": 0.85, "once": false }WEBVTT 00:00.500 --> 00:03.200 Link established. Welcome. 00:03.400 --> 00:06.000 Channel open.
06Palette
Your own wording
The client can speak in the theme’s words. That is how BERSERK did it: servers became LANDS, settings became GEAR. Now any theme can do this.
- Words are set per language, up to 40 characters
- If a language is not specified, the client’s usual word stays
"words": {
"ru": { "connect": "ПРИНЯТЬ КЛЕЙМО", "disconnect": "СНЯТЬ КЛЕЙМО", "servers": "ЗЕМЛИ", "settings": "СНАРЯЖЕНИЕ" },
"en": { "connect": "TAKE THE BRAND", "servers": "LANDS" }
}| Key | In Russian | In English |
|---|---|---|
connect | ПОДКЛЮЧИТЬ | CONNECT |
disconnect | ОТКЛЮЧИТЬ | DISCONNECT |
connecting | ПОДКЛЮЧАЮ… | CONNECTING… |
disconnecting | ОТКЛЮЧАЮ… | DISCONNECTING… |
switching | ПЕРЕКЛЮЧАЮ… | SWITCHING… |
connected | ЗАЩИЩЕНО | PROTECTED |
disconnected | НЕ ЗАЩИЩЕНО | NOT PROTECTED |
servers | СЕРВЕРЫ | SERVERS |
settings | НАСТРОЙКИ | SETTINGS |
about | О ПРОГРАММЕ | ABOUT |
help | ПОМОЩЬ | HELP |
themes | ТЕМЫ | THEMES |
catalog | КАТАЛОГ ТЕМ | THEME CATALOG |
split | РАЗДЕЛЬНЫЙ ТОННЕЛЬ | SPLIT TUNNEL |
update | ОБНОВЛЕНИЕ | UPDATE |
auto | АВТОВЫБОР | AUTO |
ping | ОТКЛИК | PING |
received | ПРИНЯТО | RECEIVED |
sent | ОТДАНО | SENT |
session | СЕССИЯ | SESSION |
node | УЗЕЛ | NODE |
favorites | ИЗБРАННОЕ | FAVORITES |
maintenance | ТЕХ. РАБОТЫ | MAINTENANCE |
menu | МЕНЮ | MENU |
close | ЗАКРЫТЬ | CLOSE |
back | НАЗАД | BACK |
07Palette
Window
| Field | What | Limits |
|---|---|---|
width, height | window size in pixels | 800-1920 × 560-1080, default 1000×700 |
radius | window corner rounding | 0-48 |
transparent | a transparent window: where nothing is drawn, the desktop shows through | true or false |
clip | window shape: a polygon of [x, y] points in percent | 3-96 points |
- The window cannot be resized with the mouse: the design is drawn for one size
- In full screen (F11) the client scales the whole window and paints the margins with
bg
"window": {
"width": 1280, "height": 720,
"clip": [[0,4],[6,0],[38,2],[61,0],[100,3],[98,40],[100,71],[97,100],[55,97],[30,100],[2,98],[0,60]]
}clip the window becomes transparent. Make sure the window buttons stay inside the shape, otherwise they cannot be clicked.08Style
theme.css
The theme.css file is applied on top of the client styles.
- Hook onto the
data-nflabels: they do not change between client versions - Classes inside the window (
.screen,.rowsand others) are internal and may be renamed without notice - Paths in
url()are relative totheme.css
/* connect button: a neon ring */
[data-nf='connect'] {
border-radius: 999px;
box-shadow: 0 0 32px var(--nf-glow);
}
/* a pulse while the channel comes up */
[data-nf-status='connecting'] [data-nf='connect'] { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { box-shadow: 0 0 0 8px var(--nf-glow); } }
/* nodes under maintenance are paler */
[data-nf='server'][data-nf-maint] { opacity: .5; }
/* a custom cursor */
body { cursor: url('assets/cursor.png') 4 0, default; }Window labels
| data-nf | What it is |
|---|---|
screen | the whole main window of the base theme |
map | the dotted world map behind the panels |
brand | the client logo and name |
mark | the ring logo inside brand |
menu | the menu column |
menu-item | a menu item. Which one is shown by data-nf-item: help, themes, servers, settings, about |
servers | the panel with the node list and the button |
server | a node row. Selected: aria-selected="true", under maintenance: data-nf-maint, favorite: data-nf-fav |
mode-switch | the “auto / advanced” switch |
connect | the connect button |
status | the tunnel status panel |
status-text | the PROTECTED / NOT PROTECTED label |
progress | the channel progress bar |
log | the log window |
meta | address, traffic and session time |
telegram | the Telegram button |
version | the version line |
update-chip | the old update button. Since 6.0.1 updates are shown by the client’s shared banner at the bottom center; a theme neither hides nor moves it |
window-controls | the “minimize”, “full screen”, “close” buttons |
dialog | any dialog. Which one is shown by data-nf-dialog: servers, settings, about, help, themes, catalog, split, update, sound |
dialog-title | the dialog title |
button | a regular button in a dialog |
toast | a pop-up notification |
key-screen | the key entry screen. It can only be recolored; a scene is not drawn over it |
State: attributes on <body>
| Attribute | Values |
|---|---|
data-nf-status | off, connecting, on, disconnecting, switching, error |
data-nf-theme | your theme’s id |
data-nf-lang | ru, en, zh, ja |
data-nf-full | present in full screen mode |
Ctrl+Shift+Backspace restores the base theme anyway, but a moderator will not pass such a theme.09Scene
Your own window, entirely
A scene is an HTML page inside the package. The client opens it across the whole window, in a sandbox, and passes it state via the SDK. Everything a browser can do works inside: canvas, WebGL, CSS animations, video, sound, custom fonts.
"scene": { "entry": "scene/index.html", "mode": "full" }| mode | How it behaves |
|---|---|
full | the scene fills the whole window and the client’s main screen is hidden. Client dialogs (settings, servers, help) open on top of the scene and are colored by tokens and theme.css |
backdrop | the scene becomes a live background behind the regular client window. It receives no mouse or keyboard input |
Window layers
- Introintro, above everything
- Client dialogstokens + theme.css
- Scene fullscene/index.html
- Client windowtokens + theme.css
- Scene backdroplive background
- Backgroundbackground
Scene skeleton
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="scene.css">
<script src="/__nf/sdk.js"></script>
</head>
<body>
<div id="frame">
<button id="core">-</button>
<div id="nodes"></div>
</div>
<script src="scene.js"></script>
</body>
</html>const frame = document.getElementById('frame');
nf.fit(frame, 1280, 720); // a 1280×720 frame
nf.window.dragRegions([{ x: 0, y: 0, w: 1180, h: 48 }]); // the window can be dragged by the top
const core = document.getElementById('core');
core.addEventListener('click', () => nf.toggle());
function render() {
const s = nf.state;
core.textContent = s.connected ? nf.t('disconnect') : nf.t('connect');
document.body.dataset.status = s.status;
}
nf.on('init', render);
nf.on('state', render);
nf.on('nodes', (nodes) => {
document.getElementById('nodes').replaceChildren(...nodes.map((node) => {
const row = document.createElement('button');
row.textContent = node.name + ' · ' + (node.ping || '-') + ' ms';
row.onclick = () => nf.select(node.id);
return row;
}));
});What you should know
- SDK is always at
/__nf/sdk.js. You do not need to put it in the package - Size. The window can also be full screen. Draw in a fixed-size frame and fit it with
nf.fit() - Dragging. The window has no frame. Specify regions with
nf.window.dragRegions(), otherwise the window cannot be moved - Window buttons. By default (
"hostChrome": true) the client places a block in the top-right corner over the scene: theme, minimize, full screen, close. Drawing your own buttons withnf.window.*? Set"hostChrome": false, as the template does - Client hotkeys (F1-F12, Esc) also work inside the scene. If the scene needs a key itself, call
event.preventDefault()and the client will not receive it - Leaving. When the theme changes the scene receives
leave. Return a Promise and the client waits up to 6 seconds
shadowBlur and filter in the frame loop drop such a machine to 2 frames per second. Draw glow as a pre-made sprite with drawImage. When the window is minimized, stop requestAnimationFrame; state will receive visible: false.10Scene
SDK: the object nf
Properties
| Property | Type | What |
|---|---|---|
nf.state | object | channel state, explained below. Before the init event it equals null |
nf.stats | object | { rx, tx, rxRate, txRate, ping, at }: session bytes, speed in bytes/s, latency in ms. Updated once a second while the channel is up |
nf.nodes | array | nodes available to the key: { id, name, country, city, ping, load, maintenance, favorite, selected }. The scene does not receive node addresses |
nf.settings | object | theme setting values from manifest.settings |
nf.theme | object | { id, version, name, base }. base is the package root address, ending with “/” |
nf.lang | string | client language: ru, en, zh, ja |
nf.preview | boolean | true in a preview (the website or nftheme preview), false in the client |
nf.scale | number | the frame scale after nf.fit() |
nf.version | string | SDK version |
Events
| Event | Payload | When |
|---|---|---|
init | everything at once | once, when the client has passed the data. Start drawing here |
state | nf.state | the channel went up or down, or the node, language, sound or full screen changed |
stats | nf.stats | once a second while the channel is up |
nodes | nf.nodes | a new node list or latency measurements arrived (F5, nf.ping()) |
settings | nf.settings | the user changed a theme setting in the client |
lang | string | the client language changed |
leave | the new theme’s id | the theme is being changed. Return a Promise and the client waits up to 6 seconds for the scene to finish its exit |
Methods
| Method | What it does |
|---|---|
nf.on(event, fn) | subscribe; returns an unsubscribe function |
nf.off(event, fn) | unsubscribe |
nf.ready() | the scene has finished drawing and the client removes the curtain. Without the call this happens automatically after load. To keep the curtain longer: nf.manualReady = true before loading |
nf.connect() | bring the channel up → Promise |
nf.disconnect() | bring the channel down → Promise |
nf.toggle() | bring it up or down → Promise |
nf.select(id) | select a node. On a live channel the client switches by itself |
nf.auto() | return to automatic node selection |
nf.ping() | measure latency of all nodes, like F5 |
nf.favorite(id) | add a node to favorites or remove it |
nf.open(screen) | open a client screen: servers, settings, about, help, themes, catalog, split, update |
nf.toast(text) | a notification at the bottom of the window, up to 140 characters |
nf.window.minimize() | minimize the window |
nf.window.close() | hide to the tray |
nf.window.fullscreen() | full screen and back |
nf.window.dragRegions(rects) | which parts of the scene drag the window: [{ x, y, w, h }] in scene pixels, up to 16 |
nf.t(key) | a client word in the current language, taking into account manifest.words |
nf.asset(path) | the full address of a package file |
nf.storage.get(key) | read from the theme storage → Promise |
nf.storage.set(key, value) | write any JSON, up to 64 KB in total per theme → Promise |
nf.storage.remove(key) | delete → Promise |
nf.sound.play(path, { volume, loop }) | play a package file, respecting the theme’s on/off switch and volume |
nf.sound.enabled / nf.sound.volume | whether the theme sound is on and the overall volume 0..1 |
nf.fit(el, w, h, mode) | fit a w×h frame into the window. mode: contain or cover |
nf.format.bytes(n) / rate(n) / duration(ms) | «1.3 GB», «350 KB/s», «03:46:32» |
All client calls return a Promise. If the client refuses (for example, the key is not activated), the Promise rejects with a clear error.
The state object
{
status: 'on', // off | connecting | on | disconnecting | switching | error
connected: true,
busy: false, // connecting, disconnecting or switching
node: { id: 'nl-1', name: 'Netherlands · Amsterdam', country: 'NL', city: 'Amsterdam',
ping: 41, load: 23, maintenance: false, favorite: true, selected: true },
auto: true, // the client picks the node
since: 1791232800000, // when the channel came up (ms), otherwise null
error: null, // the error text when status === 'error'
lang: 'ru',
words: { connect: 'CONNECT', servers: 'SERVERS', … }, // with manifest.words applied
sound: { enabled: true, volume: 0.8 },
visible: true, // the window is on screen: not minimized, not in the tray
fullscreen: false,
reduceMotion: false, // “reduce motion” is enabled in the system
platform: 'windows', // windows | linux
version: '6.0.0',
sub: { plan: 'NII IZNANKA', daysLeft: 27 },
update: { available: false, version: null }
}11Any level
Theme settings
- Describe the settings in the manifest and the client draws them in the settings window, in your theme’s block
- The client stores the values. The scene receives them in
nf.settingsand the eventsettings - Up to 16 settings
| type | Fields | Value |
|---|---|---|
toggle | default: true or false | boolean |
range | min, max, step, default | number |
select | options: 2-12 options { value, label }, default | string |
color | default: #rrggbb | a #rrggbb string |
"settings": [
{ "id": "glitch", "type": "toggle", "label": { "ru": "Помехи", "en": "Glitches" }, "default": true },
{ "id": "speed", "type": "range", "label": "Ring speed", "min": 0, "max": 2, "step": 0.1, "default": 1 },
{ "id": "scene", "type": "select", "label": "Scene", "default": "grid",
"options": [ { "value": "grid", "label": "Grid" }, { "value": "signal", "label": "Signal" } ] },
{ "id": "tint", "type": "color", "label": "Color", "default": "#c42b47", "hint": "The color of the rings around the button" }
]nf.on('settings', (values) => {
document.body.classList.toggle('glitch', values.glitch);
rings.speed = values.speed;
});You do not need to describe the theme sound separately. The client adds an on/off switch and volume itself if the theme has sounds, music or intro.
12Security
Sandbox
A theme is someone else’s code in an app that holds the user’s VPN key. That is why the client trusts no theme, not even a reviewed one.
nftheme://id/: only its own filesnetworkkeyfileswindowscookie
- The network is closed entirely.
fetch,XMLHttpRequest, WebSocket, images and fonts from other addresses, WebRTC: the client blocks all of it. A theme works only with files from its own package - The scene is isolated. It is an iframe with no shared origin with the client window: no client DOM, no cookies, no
localStorage. For your own data there isnf.storage - Nothing secret. No key, no node addresses, no IP; see state
- No windows, no navigation. A scene cannot open a new window, navigate the page away or open a link in the browser
- theme.css has no network.
url()only works with package files - Signature. From the catalog the client installs only packages signed by the studio. If a file is swapped, the sha256 will not match and the signature fails
- Revocation. If something harmful is found in a published theme, it is revoked. On the next catalog check, clients turn it off and remove it themselves
Emergency exit
Ctrl+Shift+Backspace: restore the base theme at any time. The client catches the shortcut before the scene, so a theme cannot intercept it- Launching with the
--safe-themeflag opens the client straight in the base theme
13Debugging
How to test
In the browser
nftheme preview my-theme starts a client stub at 127.0.0.1:4790:
- a window with
data-nflabels, your background, tokens andtheme.css - the scene in a sandbox
- a control panel on the right: channel state, language, sound, your settings, validation errors, an SDK call log
- files are reloaded on every save
In the client
- THEMES → AUTHORS → “Open theme folder”: the theme installs from the folder and reloads on every save
- “Open .nftheme file”: check a built package
- “Developer tools”: DevTools with the scene console, layout and network, where you can see what is blocked
A theme from a folder or file is marked NOT VERIFIED and runs in the same sandbox as a catalog theme.
Common errors
| Message | What to do |
|---|---|
File “…” is not in the package | the path in the manifest does not match the file. Case matters |
invalid name | rename the file using Latin letters, without spaces |
The content does not look like .mp3 | the file is in another format. Re-encode it; renaming will not help |
The scene does not include the SDK | add <script src="/__nf/sdk.js"> to <head> |
id “…” is taken | it belongs to another author or a studio theme. Pick your own |
The version is not newer than the published one | bump version |
| the window does not move | the scene has no nf.window.dragRegions() |
| an image from the internet does not load | that is by design. Put it in the package |
14Publishing
Review and the catalog
- Sign in to the studio via your account: with email or a link from the bot
- Drag the package. The website validates it with the same code as
nftheme checkand adds it to the queue - A moderator opens the theme in the sandbox and in the client and reviews the code. The decision arrives in Telegram, with a reason if rejected
- The server signs the approved version and it appears in the catalog. For those who already have the theme, the client updates it automatically
What a moderator checks
| Will pass | Will not pass |
|---|---|
| the theme works: connecting, node selection, dialogs | the connect button cannot be found or understood |
| preview: an honest screenshot of the theme itself | a preview from another theme or someone else’s art used as bait |
| readable code. Well-known libraries with version and license | obfuscated or minified code without source, eval, code from strings |
art, music and fonts are your own or used with permission, sources listed in credits | other people’s work without permission. On a rights holder’s complaint the theme is removed |
| the theme can be left: theme switch, minimize, close | a hidden theme button, traps, full-screen flashing without warning |
sound via sounds or nf.sound, respecting the settings | sound that bypasses the settings, loud surprises |
| when idle the theme barely loads the CPU | the fan takes off while the window is minimized |
| fake key-entry or payment screens, ads, links, referrals, 18+, calls to action and harassment |
Versions, hiding, revocation
- Every new version goes through review again. While it is under review, the previous one stays in the catalog
- A new submission withdraws your previous one that has not been reviewed yet
- A rejected version can be resubmitted under the same number
- Hidden: the theme was removed from the storefront; those who have it keep it
- Revoked: the theme is dangerous; clients will turn it off and remove it
- No more than 12 uploads per day
15Recipes
Ready-made techniques
An image instead of the connect button
[data-nf='connect'] {
width: 220px; height: 220px; padding: 0;
border: 0; border-radius: 50%;
background: url('assets/sigil-off.png') center / contain no-repeat;
color: transparent; /* we hide the label; a screen reader will still read it */
filter: grayscale(1) brightness(.7);
transition: filter .4s, transform .2s;
}
[data-nf-status='on'] [data-nf='connect'] {
background-image: url('assets/sigil-on.png');
filter: drop-shadow(0 0 24px var(--nf-glow));
}
[data-nf='connect']:hover { transform: scale(1.04); }Flashing on a drop
[data-nf-status='error'] [data-nf='screen'] { animation: lost .12s steps(2) 6; }
@keyframes lost { 50% { filter: invert(1) hue-rotate(160deg); } }A live background behind the regular window
"tokens": { "bg": "transparent", "panel": "rgba(0,0,0,.55)" },
"scene": { "entry": "stars/index.html", "mode": "backdrop" }const c = document.querySelector('canvas');
const ctx = c.getContext('2d');
const stars = Array.from({ length: 220 }, () => ({ x: Math.random(), y: Math.random(), z: Math.random() }));
let speed = 0.0004;
nf.on('state', (s) => { speed = s.connected ? 0.003 : s.busy ? 0.008 : 0.0004; });
(function frame() {
c.width = innerWidth; c.height = innerHeight;
for (const s of stars) {
s.z -= speed; if (s.z <= 0) s.z = 1;
const k = 0.5 / s.z;
ctx.fillStyle = 'rgba(255,255,255,' + (1 - s.z) + ')';
ctx.fillRect((s.x - 0.5) * k * c.width + c.width / 2, (s.y - 0.5) * k * c.height + c.height / 2, 2, 2);
}
if (!nf.state || nf.state.visible !== false) requestAnimationFrame(frame);
else nf.on('state', function wake(s) { if (s.visible) { nf.off('state', wake); requestAnimationFrame(frame); } });
})();Session record
nf.on('state', async (s) => {
if (s.status !== 'off' || !nf.state.since) return;
const minutes = Math.round((Date.now() - nf.state.since) / 60000);
const best = (await nf.storage.get('best')) || 0;
if (minutes > best) {
await nf.storage.set('best', minutes);
nf.toast('New record: ' + minutes + ' min');
}
});A drop-in screen on connect
let was = null;
nf.on('state', (s) => {
if (was !== 'on' && s.status === 'on') {
deploy.querySelector('b').textContent = s.node ? s.node.name : '';
deploy.hidden = false;
nf.sound.play('assets/deploy.mp3');
setTimeout(() => { deploy.hidden = true; }, 2200);
}
was = s.status;
});16Questions
The essentials
Will themes come to Android?
Yes, in the same format. Palettes, wording and sounds will work there too, and scenes will run via WebView. Make sure your theme also lives in a 1000×700 window: phone screens are vertical.
Can I make a theme based on a game or anime?
Yes, if you have rights to the art and sound or the owner does not mind. List the sources in credits. If a rights holder complains, the theme will be removed.
Can I use three.js, PixiJS, GSAP?
Yes. Put the library file in the package (it will not load from a CDN, the network is closed), do not modify it, and list the version and license in credits. A moderator will compare the file with the official one.
Where does a theme store settings and records?
The client stores the settings from manifest.settings. Put your own data in nf.storage, up to 64 KB. When the theme is deleted, the data goes with it.
How long does review take?
Usually up to a day. The decision arrives in Telegram.
Are studio themes built the same way?
Almost. BERSERK, CYBERPUNK and others sit in the catalog as separate studio-signed packages. The main NII IZNANKA theme is also built into the client. Studio themes can integrate deeper into the window: the host field is available only to studio-signed packages.
Ready?