Desktop drawing / sketching application — Electron + React (Vite). Dark, minimalist UI, 100% custom. Runs on Windows, macOS and Linux.
- Electron — custom titlebar: frameless (
frame: false) on Windows and Linux, native traffic lights (titleBarStyle: 'hiddenInset') on macOS. - React 18 + Vite 6 — UI and bundling.
- HTML5 Canvas — drawing engine (dual main/overlay canvas).
- Handwritten CSS — no Tailwind, no Material UI.
- lucide-react — thin monochrome icons.
npm install # install dependencies
npm run dev # Vite + Electron in dev mode (HMR)
npm run build # build React (-> dist/)
npm run dist # installer for the current OS:
# Windows -> release/Strok-Setup-1.6.0.exe (NSIS)
# macOS -> release/Strok-1.6.0-arm64.dmg
# Linux -> release/strok_1.6.0_amd64.deb
# release/Strok-1.6.0.AppImage
npm run pack # unpackaged build (release/win-unpacked/, mac-arm64/
# or linux-unpacked/)
npm run icon # regenerate build/icon.ico (256px), build/icon.png (1024px)
# and build/icons/ (16 -> 512px, Linux icon set)npm run dev starts Vite (port 5173) then Electron once the server is ready.
- Fullscreen canvas, framed paper on a dark dotted background.
- Pencil (smoothed freehand stroke) and Eraser.
- Size and Opacity via custom sliders.
- Scroll-wheel size:
Ctrl + scrollincreases / decreases the brush or eraser size on the fly (non-adaptive), without having to target the slider. - Quick eraser: holding
Shifttemporarily switches to the eraser; releasing it automatically restores the previous tool. - Custom color picker: saturation/value zone, hue strip, hex field, preset palette, last 5 colors.
- Multi-document tabs (browser-style): open / close / switch, each with its own drawing and zoom level.
- Dark layer mode: paper tinted like the app menus (not black), the pencil automatically switches to a coordinated light grey.
- Zoom / unzoom:
scroll wheel(toward cursor),−/+buttons, click on%to reset; pan with middle-click drag. - Custom titlebar (minimize / maximize / close) + cursor ring showing brush size (follows zoom).
- Shortcuts cheat sheet: a ? button in the titlebar (just next to "minimize") opens a built-in popup listing all shortcuts. See Shortcuts cheat sheet.
- Undo / Redo (
Ctrl + Z/Ctrl + Y), per-tab history compatible with infinite canvas (geometry + bitmap restored). - Files: re-editable
.strokproject (save / open) + PNG export. - Autosave: the app remembers your entire workspace. On quit, every tab (drawing, view, active tab) is persisted and restored identically on next launch — even layers never saved to disk. Closing a modified tab prompts to save. See Autosave.
- Extensions (addons): plugin system — users download a
.strokaddonfile and import it via the left rail. Addons can open floating, themed, draggable windows (createWindow) to add tool panels that work alongside drawing — e.g. the bundled scientific calculator. See Extensions (addons). - Themes: change the entire app aesthetic. 5 built-in themes (default,
Light, Night, Nord, Sepia) + import
.strokthemefiles (JSON) following the same principle as addons. See Themes.
| Key | Action |
|---|---|
B / E |
Pencil / Eraser |
Shift (held) |
Temporary eraser (restores tool on release) |
Ctrl + Z |
Undo |
Ctrl + Y / Ctrl + Shift + Z |
Redo |
Ctrl + S / Ctrl + O |
Save / Open a .strok project |
Ctrl + Shift + E |
Export as PNG |
Ctrl + T |
New tab |
Ctrl + W |
Close active tab |
Ctrl + 0 |
Reset zoom |
scroll wheel |
Zoom in / out (toward cursor) |
Ctrl + scroll wheel |
Brush / eraser size |
middle-click drag |
Pan the canvas |
🍎 On macOS, use
Cmd (⌘)instead ofCtrl— the in-app cheat sheet adapts automatically.
💡 This same list is available inside the app via the ? button in the titlebar — see Shortcuts cheat sheet.
A ? button is placed in the titlebar, just to the left of the
"minimize" button (the — dash). Clicking it opens a built-in popup
summarizing all shortcuts, grouped by category (Tools, Edit, Files, Tabs, View).
How it behaves:
- This is not a real OS window: it's an overlay rendered inside the app (same mechanism as the Extensions (addons) modal).
- The app behind is blurred (
backdrop-filtereffect) while the popup is open. - It can be closed in three ways: the small × button in the top-right
corner of the popup, a click on the blurred app (outside the card), or
pressing
Esc.
Two stacked canvases ensure uniform opacity: the current stroke is drawn
at full opacity on the overlay, then flattened onto the main canvas on
pointer up with the chosen opacity (avoids dark accumulation at overlaps).
Smoothing via quadratic curves + getCoalescedEvents() for a fluid stroke
with no latency (no need to batch in rAF).
Tabs: one <Canvas> is mounted per tab (only the active one is visible) —
each document's bitmap persists naturally without manual copying.
Zoom / pan: applied via a CSS transform (translate + scale) on a
.canvas-viewport. Canvas resolution does not change (raster zoom); drawing
coordinates are recovered by dividing by the zoom (the transformed rect
already encodes the pan).
Strok distinguishes between closing the application and closing a tab.
Nothing is lost and nothing is asked. The entire workspace is persisted
internally (in …/AppData/Roaming/Strok/strok-session.json on Windows) then
restored identically on next launch: all tabs, their drawing,
their zoom/pan, light/dark mode and the tab you were on. A layer never
saved to a .strok file remains available in the app.
A quiet autosave also rewrites the session a few seconds after each change: in case of a crash/power cut, you recover almost everything.
Here, Strok warns you if you risk losing something:
| Tab… | On close |
|---|---|
| blank, or unchanged since last save | closes without asking |
| modified, never saved | prompts to save (.strok dialog) |
| modified, already linked to a file | asks to save the latest changes (overwrites the file) |
The confirmation dialog offers Save / Don't save /
Cancel (internal app overlay, not a native Windows window; Esc or a click
outside = cancel). If you cancel the save dialog, the tab is not closed.
Once a layer is saved (or opened from a
.strok),Ctrl + Sand "Save latest changes" overwrite the same file without asking for the location again. For safety, only files you explicitly designated yourself via an OS dialog during the session are silently rewritable; after an app restart, the first save of a restored tab reconfirms the location.
Strok is extensible via addons: small scripts that anyone can write, share / download elsewhere, then import into the app via a button. There is no built-in store — you import a file.
- Get a
.strokaddonfile (e.g. the ones inexamples/addons/). - In Strok: left rail → Extensions icon (puzzle piece).
- "Import an addon…" and choose the file.
- Alternative: "Addons folder" opens the storage folder; you can
drop your
.strokaddonfiles there manually (loaded on next startup).
- Alternative: "Addons folder" opens the storage folder; you can
drop your
- Addons are persistent (stored in
…/AppData/Roaming/Strok/strok-addonson Windows). Each row has an toggle switch (enable / disable) and a delete button. - Commands added by addons appear in the modal and in the "Extensions" section of the right panel.
⚠️ An addon is code that runs inside the app. Only install addons whose source you trust. They are however sandboxed (see Addon security).
An addon is a single JavaScript file (.strokaddon extension), no
build required. It populates a module object:
module.manifest = {
id: 'com.example.my-addon', // unique identifier (reverse-DNS recommended)
name: 'My addon',
version: '1.0.0',
author: 'Your name',
description: 'What your addon does, in one sentence.',
};
module.activate = function (strok) {
strok.addCommand({
id: 'hello',
label: 'Say hello',
run: () => strok.notify(`Current color: ${strok.getColor()}`),
});
// Optionally return a cleanup function, called on deactivation/deletion.
return { deactivate: () => {} };
};
module.exports = { manifest, activate }andexports.activate = …also work.activate(strok)is called once on load. Recommended starting point:examples/addons/TEMPLATE.strokaddon.
Brush / tool
| Method | Description |
|---|---|
getColor() / setColor('#rrggbb') |
Color (writing adds it to recents) |
getTool() / setTool(id) |
Tool — 'pencil' or 'eraser' |
getSize() / setSize(px) |
Brush size (1–100) |
getOpacity() / setOpacity(0..1) |
Opacity |
Active layer
| Method | Description |
|---|---|
getContext() |
CanvasRenderingContext2D (dpr transform applied → draw in CSS px). null if no layer. |
getCanvasInfo() |
{ width, height (physical px), dpr, doc:{ x, y, w, h } (CSS px) } |
commit() |
Call after drawing: validates the edit into undo/redo |
Vector drawing (
fillRect,stroke…) → usedoc.w / doc.hdimensions (CSS px). Pixel access (getImageData/putImageData) → usewidth / height(physical px), as these methods ignore the transform.
Contributions
| Method | Description |
|---|---|
addCommand({ id, label, run }) |
Adds a command button. Returns a removal function. |
createWindow(opts) |
Opens a floating, themed, draggable window. Returns a handle (see below). |
Floating windows — createWindow(opts)
A small in-app window the addon fully owns: it follows the active theme,
can be dragged, and can act on the app directly (it has the whole strok API in
scope). Usable alongside drawing — perfect for tool panels.
| Option | Default | Description |
|---|---|---|
title |
'Addon' |
Title-bar text |
width / height |
320 / auto |
Size in px |
x / y |
centered | Initial position in px |
draggable |
true |
Move it by its title bar |
resizable |
false |
Bottom-right resize handle |
backdrop |
false |
false | 'dim' | 'blur' — a backdrop behind it (clicking it closes the window; Esc also closes) |
closable |
true |
Show the close button |
className |
— | Extra CSS class for styling |
onClose |
— | Called after the window closes |
Returns a handle: { el, body, setTitle, setSize, move, focus, isOpen, close }.
Fill handle.body with your own DOM. Themed helper classes are available:
.aw-btn (--fn / --accent / --danger), .aw-display, .aw-grid.
Open windows are closed automatically when the addon is disabled/removed.
⚠️ The production CSP forbids inlineonclick=handlers — wire events withaddEventListener, not inline attributes.
Events (return an unsubscribe function)
| Event | Payload | Fired… |
|---|---|---|
on('strokeEnd', fn) |
— | after each committed stroke |
on('colorChange', fn) |
hex |
when the color changes |
on('toolChange', fn) |
id |
when the tool changes |
Misc
| Method | Description |
|---|---|
notify(msg, type?) |
Toast — 'info' (default) / 'success' / 'error' |
storage.get(key, fallback) · set(key, val) · remove(key) |
Persistent storage isolated per addon |
version |
Host app version |
manifest |
Your own addon's manifest |
| File | Demonstrates |
|---|---|
fill-background.strokaddon |
addCommand, vector drawing, commit |
invert-colors.strokaddon |
pixel access getImageData / putImageData |
rainbow-stroke.strokaddon |
events, storage, deactivate |
scientific-calculator.strokaddon |
createWindow, floating themed UI, setSize |
TEMPLATE.strokaddon |
commented skeleton + full API |
To distribute an addon, simply share its .strokaddon file.
Choose a unique manifest id: it serves as the namespace for storage.
An addon's code runs inside Electron's hardened renderer:
contextIsolation+sandbox+nodeIntegration:false→ no access to Node or the file system.- CSP
default-src 'self'→ impossible to load an external script or reach the network → no data exfiltration. - Navigation locked to the app's origin; opening new windows is refused.
To execute addon code, the production CSP allows
script-src 'self' 'unsafe-eval' (invocation via new Function). This is
necessary and intentional — without it, no addon can run — and it
does not open a remote injection path since default-src 'self'
already prevents fetching any external content. The worst a malicious addon
can do is manipulate the current canvas; it can neither touch your files
nor phone home to a server. Stay cautious:
only install addons from sources you trust.
The addons IPC (electron/main.cjs) only handles file persistence
in userData/strok-addons (list / import / delete / open folder,
with path-traversal guard and size limit) — the main process never executes
this code, it's the renderer that loads it.
Like addons, but for appearance: a theme changes the entire app aesthetic
(backgrounds, panels, borders, text, icons, accent). Strok's entire style is
driven by CSS variables on :root — a theme overrides them, and the whole
interface is repainted.
Unlike an addon, a theme is purely declarative JSON: it executes no code
(no new Function, no 'unsafe-eval'). It is therefore inherently safer.
- Left rail → Themes icon (palette).
- 5 built-in themes are available right away — click a card to apply
it instantly:
- Strok (default) · Light · Night · Nord · Sepia.
- "Import a theme…" to add a
.strokthemefile (e.g. the ones inexamples/themes/).- Alternative: "Themes folder" opens the storage folder; you can
drop your
.strokthemefiles there manually (loaded on next startup).
- Alternative: "Themes folder" opens the storage folder; you can
drop your
- The chosen theme is remembered and re-applied on next launch. Imported
themes are persistent (stored in
…/AppData/Roaming/Strok/strok-themeson Windows) and deletable (built-in ones are not). Each card shows a mini-preview of the app in its colors.
The drawing surface (the paper) is independent from the theme: its tint is controlled via the light / dark layer button on the rail, not via themes.
A theme is a single JSON file (.stroktheme extension), no build required:
{
"manifest": {
"id": "com.example.my-theme",
"name": "My theme",
"version": "1.0.0",
"author": "Your name",
"description": "Describe your theme in one sentence."
},
"variables": {
"--bg-app": "#0d0d0d",
"--bg-panel": "#1a1a1a",
"--text-bright": "#e8e8e8",
"--accent": "#6d8bff"
}
}Recommended starting point:
examples/themes/TEMPLATE.stroktheme
(contains the default theme with all customizable variables).
Only these keys (colors) are recognized; any other key is ignored. A missing variable keeps the default theme value.
| Group | Variables |
|---|---|
| Backgrounds | --bg-app, --bg-canvas-area, --bg-panel, --bg-panel-2, --bg-titlebar, --bg-rail |
| Surfaces | --surface-hover, --surface-active, --surface-input |
| Borders | --border, --border-soft, --border-strong |
| Text / icons | --text, --text-dim, --text-bright, --icon, --icon-hover, --icon-active |
| Accent | --accent, --danger |
Values are CSS colors (
#rrggbb,rgb(),hsl(), keywords). JSON does not allow comments (//) — hence the_helpfield in the template, which is simply ignored on load.
- A theme is data, not code: it is
JSON.parsed, never executed. No CSP elevation needed (themes do not require'unsafe-eval', unlike addons). - Allowlist: only the cosmetic variables listed above are applied,
via
setPropertyon:root— a theme cannot modify layout (metrics, fonts) or break the app, nor inject arbitrary CSS (values are validated: no;,{,},<,>). - The themes IPC (
electron/main.cjs) likewise only handles file persistence inuserData/strok-themes(same path-traversal guard and size limit as addons).
Stroke/
├── electron/
│ ├── main.cjs # hardened main process (IPC window + files + addons + themes + session)
│ └── preload.cjs # secure bridge (contextIsolation)
├── src/
│ ├── App.jsx # global state + assembly + addon/theme/toast integration
│ ├── components/ # TitleBar, Sidebar, Toolbar, ColorPicker, Canvas,
│ │ # AddonsModal, ThemesModal, ShortcutsModal, ConfirmModal
│ ├── hooks/ # useCanvas (drawing + undo/redo history)
│ ├── addons/ # host.js (addon engine) + useAddons.js (React layer)
│ ├── themes/ # themeHost.js + builtins.js + useThemes.js
│ └── styles/global.css
├── examples/
│ ├── addons/ # example addons (.strokaddon) + TEMPLATE
│ └── themes/ # example themes (.stroktheme) + TEMPLATE
├── build/
│ ├── generate-icon.cjs
│ ├── deb-after-install.tpl # postinst .deb (SUID chrome-sandbox)
│ ├── icons/ # 16 -> 512px PNG set (Linux)
│ └── icon.ico / icon.png
├── index.html
├── vite.config.js
├── electron-builder.yml
└── package.json
Hardening applied on the Electron side and build:
- Renderer isolation:
contextIsolation+sandbox+nodeIntegration: false— the renderer can only make IPC calls explicitly exposed bypreload.cjs, no direct access to Node or the system. (The OS-level part of that sandbox is off in the Linux AppImage only — see Linux — installing.) - No network leak: navigation locked to the app's origin
(
will-navigate), new windows refused (setWindowOpenHandler), no web permission granted, strictdefault-src 'self'CSP in production. (The prod CSP allowsscript-src 'self' 'unsafe-eval'only to execute addons — see Addon security.) - Code protection: DevTools disabled in production (+ inspector shortcuts
neutralized, app menu removed); minified build without source maps,
console.*stripped. - IPC validation: project/image size capped, file writes always go through an OS dialog (path chosen by the user).
Known limitation: an Electron
.exeexecutes code on the target machine and theapp.asaris extractable — hardening raises the bar, it does not make the source code tamper-proof.
npm run dist builds the installer for the OS you run it on: the NSIS
.exe on Windows, the .dmg (Apple Silicon) on macOS, the .deb +
.AppImage on Linux. Linux packages must be built from Linux: the .deb
needs fpm and the AppImage needs symlink creation + mksquashfs, neither of
which electron-builder can run from Windows.
Code signing is disabled in npm run dist
(CSC_IDENTITY_AUTO_DISCOVERY=false).
- Windows: avoids extracting the
winCodeSignpackage from electron-builder, which fails without the symbolic link creation privilege (Developer Mode disabled / non-admin). The generated.exeremains self-contained (Node + Chromium bundled); it will simply be unsigned. To sign later, provide a certificate viaCSC_LINK/CSC_KEY_PASSWORD. - macOS: no Apple Developer certificate is required — electron-builder
applies the ad-hoc signature mandatory on Apple Silicon. Because the
.dmgis not notarized, people who download it will see the Gatekeeper warning: right-click the app → Open (once), or runxattr -cr /Applications/Strok.appto clear the quarantine attribute. A locally-built app opens without any warning. - Linux: nothing to sign,
.deband.AppImageare unsigned by design.
The .deb is the recommended format — and the reference target on Linux
Mint (Cinnamon):
sudo apt install ./strok_1.6.0_amd64.deb # or double-click the file
strok # also in the applications menuIt installs into /opt/Strok, adds a strok command in /usr/bin, registers
the menu entry (/usr/share/applications/strok.desktop) and the icon set
(16 → 512px, in hicolor). Its post-install script marks chrome-sandbox as
SUID root, so the renderer stays sandboxed: Ubuntu 24.04 and Mint 22
restrict unprivileged user namespaces
(kernel.apparmor_restrict_unprivileged_userns=1), and without that bit
Chromium refuses to start at all.
The .AppImage is the portable fallback for other distributions:
chmod +x Strok-1.6.0.AppImage && ./Strok-1.6.0.AppImageTwo caveats compared to the .deb:
- it runs with
--no-sandbox(electron-builder default: an AppImage is mountednosuid, the sandbox helper cannot be SUID inside it) — prefer the.debif you install third-party addons, which run inside the renderer; - AppImages need FUSE 2, absent from Mint 21+ / Ubuntu 22.04+ by default:
sudo apt install libfuse2(libfuse2t64on Mint 22), or run it once with./Strok-1.6.0.AppImage --appimage-extract-and-run.
User data lives in ~/.config/Strok/ (strok-session.json, strok-addons/,
strok-themes/) — the equivalent of ~/Library/Application Support/Strok/ on
macOS and …/AppData/Roaming/Strok/ on Windows.
Shapes (line / rectangle / ellipse), fill, color picker tool, JPG export,
splash screen. (Zoom, .strok/PNG files, and undo/redo already implemented.)