ready for publish

This commit is contained in:
2025-10-15 23:06:01 +01:00
parent 73ce666f53
commit 2e51b11a59
18 changed files with 258 additions and 75 deletions
+4
View File
@@ -5,6 +5,10 @@ bower_components/
# Build outputs
dist/
# npm package tarballs
*.tgz
vimazing-vim-sudoku-*.tgz
# TypeScript
*.tsbuildinfo
+7 -1
View File
@@ -1,6 +1,12 @@
src/
example/
node_modules/
tsconfig.json
bun.lockb
*.log
.git
.gitignore
NPM_PUBLISHING_CHECKLIST.md
vim_sudoku_implementation.md
vim_sudoku_game_mechanics.md
vimazing-vim-sudoku-*.tgz
+42
View File
@@ -0,0 +1,42 @@
# Changelog
All notable changes to @vimazing/vim-sudoku will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2025-01-15
### Added
- Initial release of @vimazing/vim-sudoku
- VIM-style modal editing with navigation and edit modes
- Full VIM motions (h/j/k/l, counted moves, ^/$/0, repeat with .)
- Smart cell locking for pre-filled clues
- Real-time validation system (optional, disabled by default)
- Automatic win detection using sum-to-45 algorithm
- Time-based scoring with keystroke penalties
- Tokyo Night themed CSS with 3x3 box separators
- Configurable size multiplier via CSS variables
- Complete TypeScript support with type declarations
- Composable hook architecture (useGame, useScore, useBoardState, etc.)
- Tree-shakeable exports
### Features
- `useGame(platformHook?, options?)` - Main game orchestration hook
- `validateMoves` option for real-time move validation (default: false)
- Automatic win detection when board is complete and valid
- Scoring formula: `max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)`
- VIM modal editing: navigation mode (default) and edit mode (R/C/I to enter)
- Visual feedback for all game states (navigation glow, edit mode pulse, locked cells)
- Support for counted motions (3j, 5l, etc.)
- Row navigation shortcuts (^, $, 0)
- Repeat last motion with `.`
### Documentation
- Comprehensive README with usage examples
- VIM modal editing guide
- Cell state rules reference
- API documentation for all hooks
- Build and release instructions
[0.1.0]: https://github.com/vimazing/vim-sudoku/releases/tag/v0.1.0
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 André Padez
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.
+46
View File
@@ -0,0 +1,46 @@
# 📋 npm Publishing Checklist for @vimazing/vim-sudoku
## 🔴 Critical (Must Fix Before Publishing)
- [ ] **Fix TypeScript build errors** - Package won't compile
- [ ] `Sudoku.ts`: operator type issues (lines 77, 93, 95)
- [ ] `useBoardState.ts`: remove unused `useCallback`, `useMemo` imports
- [ ] `types.ts`: remove non-existent `BoardString` export
- [ ] Remove unused variables: `idx`, `max_square`
- [ ] **Add LICENSE file** - Required for MIT license in package.json
- [ ] **Update package.json author** - Currently `you@example.com`
## 🟡 Important (Should Fix)
- [ ] **Update README.md**
- [ ] Remove "coming soon" for win detection (✅ implemented!)
- [ ] Document new `validateMoves` option in `useGame`
- [ ] Update scoring formula to include move penalty
- [ ] **Fix tsconfig.json** - Conflicting `declaration` and `emitDeclarationOnly` flags
- [ ] **Verify exports** - Check `src/index.ts` exports everything needed
- [ ] **Add CHANGELOG.md** - Document version history and changes
- [ ] **Add package.json keywords** - Improve npm discoverability (sudoku, vim, react, game, puzzle)
- [ ] **Update .npmignore** - Verify `example/` is excluded
## 🟢 Nice to Have
- [ ] **Test locally** - Run `npm pack` and inspect tarball contents
- [ ] **Add npm badges** - Version and downloads badges in README
- [ ] **Add engines field** - Specify Node.js version requirements
- [ ] **GitHub topics** - Add repository tags for discoverability
---
**Total: 18 tasks** (6 critical, 6 important, 6 nice-to-have)
**Status:** Ready to start working on critical issues 🚀
+28 -5
View File
@@ -1,5 +1,11 @@
# @vimazing/vim-sudoku
[![npm version](https://img.shields.io/npm/v/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku)
[![npm downloads](https://img.shields.io/npm/dm/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku)
[![license](https://img.shields.io/npm/l/@vimazing/vim-sudoku.svg)](https://github.com/vimazing/vim-sudoku/blob/main/LICENSE)
![VIM Sudoku Demo](./vim-sudoku.gif)
Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**.
Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project.
@@ -146,9 +152,14 @@ Jump to end: Press $
## Core Hooks
### `useGame(platformHook?)`
### `useGame(platformHook?, options?)`
Main hook that orchestrates the entire sudoku game. Returns:
Main hook that orchestrates the entire sudoku game.
**Options:**
- `validateMoves?: boolean` (default: `false`) - Enable real-time move validation with conflict detection. When `false`, allows harder gameplay without immediate feedback.
**Returns:**
```ts
{
@@ -201,11 +212,17 @@ import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState";
### Scoring
Time-based scoring formula:
Scoring formula with time and move penalties:
```
score = max(0, 100000 - timeInSeconds × 10)
score = max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)
```
Where:
- `baseScore = 100000`
- `timePenalty = timeInSeconds × 10`
- `extraMoves = actualKeystrokes - minMovesRequired`
- `movePenalty = extraMoves × 50`
### Game Over Conditions
- ⏱️ **Time limit**: 60 seconds
@@ -219,7 +236,13 @@ const KEYSTROKE_LIMIT = 100;
### Win Detection
Currently manual (solving via UI or `solveBoard()`). Automatic win detection coming soon!
Automatic win detection is fully implemented using an efficient sum-to-45 algorithm:
- Validates when board is complete (no empty cells)
- Checks all rows sum to 45 (1+2+...+9)
- Checks all columns sum to 45
- Checks all 3x3 boxes sum to 45
Manual win trigger available via `solveBoard()` for testing.
### Board Format
@@ -25,21 +25,24 @@ export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[2em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
className="text-[#c8d3f5] font-mono text-center select-none"
style={{
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
fontSize: "calc(2rem * var(--sudoku-size-multiplier))"
}}
>
<div className="flex flex-col gap-10">
<p className="text-[3rem]">Press <strong className="text-[#82aaff]">New Puzzle</strong> to start</p>
<div className="text-[1.2em]">
<p className="mb-4"><strong className="text-[#ffc777]">Navigation Mode</strong> (default):</p>
<div className="flex flex-col" style={{ gap: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>
<p style={{ fontSize: "calc(3rem * var(--sudoku-size-multiplier))" }}>Press <strong className="text-[#82aaff]">New Puzzle</strong> to start</p>
<div style={{ fontSize: "calc(1.2rem * var(--sudoku-size-multiplier))" }}>
<p style={{ marginBottom: "calc(1rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#ffc777]">Navigation Mode</strong> (default):</p>
<p><strong className="text-[#82aaff]">h</strong>,<strong className="text-[#82aaff]">j</strong>,<strong className="text-[#82aaff]">k</strong>,<strong className="text-[#82aaff]">l</strong> to move around</p>
<p className="mt-2"><strong className="text-[#82aaff]">R</strong> or <strong className="text-[#82aaff]">C</strong> to enter edit mode</p>
<p style={{ marginTop: "calc(0.5rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#82aaff]">R</strong> or <strong className="text-[#82aaff]">C</strong> to enter edit mode</p>
<p className="mt-6 mb-4"><strong className="text-[#ffc777]">Edit Mode</strong>:</p>
<p style={{ marginTop: "calc(1.5rem * var(--sudoku-size-multiplier))", marginBottom: "calc(1rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#ffc777]">Edit Mode</strong>:</p>
<p>Type <strong className="text-[#82aaff]">1-9</strong> to fill cell</p>
<p className="mt-2"><strong className="text-[#82aaff]">Esc</strong> or <strong className="text-[#82aaff]">Enter</strong> to exit edit mode</p>
<p style={{ marginTop: "calc(0.5rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#82aaff]">Esc</strong> or <strong className="text-[#82aaff]">Enter</strong> to exit edit mode</p>
<p className="mt-6 text-[0.8em] text-[#828bb8]">
<p className="text-[#828bb8]" style={{ marginTop: "calc(1.5rem * var(--sudoku-size-multiplier))", fontSize: "calc(0.8rem * var(--sudoku-size-multiplier))" }}>
Try vim motions: <strong className="text-[#82aaff]">$</strong>, <strong className="text-[#82aaff]">^</strong>, <strong className="text-[#82aaff]">0</strong>, counted moves
</p>
</div>
@@ -53,13 +56,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
return (
<div
className="text-[#c8d3f5] text-[2em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
className="text-[#c8d3f5] grid font-mono uppercase text-center select-none"
style={{
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
fontSize: "calc(2rem * var(--sudoku-size-multiplier))",
gap: "calc(1rem * var(--sudoku-size-multiplier))"
}}
>
<p className="text-[#c3e88d]">🎉 Puzzle Solved!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<p className="text-[#82aaff]" style={{ fontSize: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] normal-case" style={{ fontSize: "calc(0.65rem * var(--sudoku-size-multiplier))" }}>
<span className="text-[#c099ff]">max</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">0</span>
@@ -82,8 +89,8 @@ export const GameOverContent = () => {
className="text-[#ff757f] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[5em]">Game Over</p>
<p className="text-[2em] text-[#828bb8]">Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
<p style={{ fontSize: "calc(5rem * var(--sudoku-size-multiplier))" }}>Game Over</p>
<p className="text-[#828bb8]" style={{ fontSize: "calc(2rem * var(--sudoku-size-multiplier))" }}>Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
</div>
);
};
@@ -20,11 +20,14 @@ export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverla
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[1em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
className="text-[#c8d3f5] font-mono text-center select-none"
style={{
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
fontSize: "calc(1rem * var(--sudoku-size-multiplier))"
}}
>
<div className="flex flex-col gap-3">
<p className="text-[1.5rem]">Tap New Puzzle to start</p>
<div className="flex flex-col" style={{ gap: "calc(0.75rem * var(--sudoku-size-multiplier))" }}>
<p style={{ fontSize: "calc(1.5rem * var(--sudoku-size-multiplier))" }}>Tap New Puzzle to start</p>
<p>VIM motions available <br /> via on-screen keyboard</p>
</div>
</div>
@@ -36,13 +39,17 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
return (
<div
className="text-[#c8d3f5] text-[1em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
className="text-[#c8d3f5] grid font-mono uppercase text-center select-none"
style={{
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
fontSize: "calc(1rem * var(--sudoku-size-multiplier))",
gap: "calc(1rem * var(--sudoku-size-multiplier))"
}}
>
<p className="text-[#c3e88d]">🎉 Solved!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<p className="text-[#82aaff]" style={{ fontSize: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] normal-case" style={{ fontSize: "calc(0.65rem * var(--sudoku-size-multiplier))" }}>
<span className="text-[#c099ff]">max</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">0</span>
@@ -62,8 +69,11 @@ export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType })
export const GameOverContent = () => {
return (
<div
className="text-[#ff757f] text-[5em] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
className="text-[#ff757f] font-mono uppercase text-center select-none"
style={{
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
fontSize: "calc(5rem * var(--sudoku-size-multiplier))"
}}
>
Game Over
</div>
+4
View File
@@ -1 +1,5 @@
@import "tailwindcss";
:root {
--sudoku-size-multiplier: 3;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Config } from 'tailwindcss'
export default {
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'../src/**/*.{js,ts,jsx,tsx}',
],
} satisfies Config
+15 -1
View File
@@ -2,7 +2,7 @@
"name": "@vimazing/vim-sudoku",
"version": "0.1.0",
"description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.",
"author": "André Padez <you@example.com>",
"author": "André Padez <andre.padez@gmail.com>",
"repository": {
"type": "git",
"url": "https://github.com/vimazing/vim-sudoku.git"
@@ -12,7 +12,21 @@
},
"homepage": "https://github.com/vimazing/vim-sudoku#readme",
"license": "MIT",
"keywords": [
"sudoku",
"vim",
"react",
"hooks",
"game",
"puzzle",
"modal-editing",
"typescript",
"vimazing"
],
"type": "module",
"engines": {
"node": ">=18.0.0"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
+9 -8
View File
@@ -51,11 +51,14 @@ function initialize() {
// ---------------------------------------------------------------------------
sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
let numericDifficulty: number;
if (typeof difficulty === "string" || typeof difficulty === "undefined") {
difficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
} else {
numericDifficulty = difficulty;
}
difficulty = sudoku._force_range(difficulty, NR_SQUARES + 1, MIN_GIVENS);
numericDifficulty = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
unique = unique ?? true;
const blank_board = sudoku.BLANK_BOARD;
@@ -74,7 +77,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
if (
single_candidates.length >= difficulty &&
single_candidates.length >= numericDifficulty &&
sudoku._strip_dups(single_candidates).length >= 8
) {
let board = "";
@@ -90,9 +93,9 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
if (givens_idxs.length > difficulty) {
if (givens_idxs.length > numericDifficulty) {
givens_idxs = sudoku._shuffle(givens_idxs);
for (let i = 0; i < givens_idxs.length - difficulty; ++i) {
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
const target = givens_idxs[i];
board =
board.substring(0, target) +
@@ -105,7 +108,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
return sudoku.generate(difficulty);
return sudoku.generate(numericDifficulty);
};
// ---------------------------------------------------------------------------
@@ -187,12 +190,10 @@ sudoku._search = function (candidates: Record<string, string>, reverse: boolean)
reverse = reverse || false;
let max_nr_candidates = 0;
let max_square = "";
for (const s of SQUARES) {
const n = candidates[s].length;
if (n > max_nr_candidates) {
max_nr_candidates = n;
max_square = s;
}
}
if (max_nr_candidates === 1) return candidates;
+5 -19
View File
@@ -1,28 +1,14 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
/* :root { */
/* --cell-size: 32px; */
/* --cell-font-size: 16px; */
/* --grid-gap: 2px; */
/* --box-separator: 3px; */
/* } */
:root {
--cell-size: 48px;
--cell-font-size: 32px;
--grid-gap: 4px;
--box-separator: 6px;
--sudoku-size-multiplier: 3;
--cell-size: calc(32px * var(--sudoku-size-multiplier));
--cell-font-size: calc(16px * var(--sudoku-size-multiplier));
--grid-gap: calc(2px * var(--sudoku-size-multiplier));
--box-separator: calc(3px * var(--sudoku-size-multiplier));
}
/* :root { */
/* --cell-size: 96px; */
/* --cell-font-size: 48px; */
/* --grid-gap: 6px; */
/* --box-separator: 9px; */
/* } */
/* =========================================================
Container & Grid Base
========================================================= */
-1
View File
@@ -7,7 +7,6 @@ export type UseScoreType = ReturnType<typeof useScore>;
export type {
Difficulty,
DifficultyName,
BoardString,
} from "./lib/Sudoku";
export type GamePhase = "idle" | "playing";
+17 -12
View File
@@ -1,9 +1,14 @@
import { useState, useCallback, useMemo } from "react";
import { useState } from "react";
import { sudoku } from "../lib/Sudoku";
import type { UseCursorType } from "./useCursor";
export function useBoardState(cursorManager: UseCursorType) {
type UseBoardStateOptions = {
validateMoves?: boolean;
};
export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) {
const { cursor, setCursor } = cursorManager;
const { validateMoves = false } = options || {};
const [board, setBoard] = useState<string>(sudoku.generate("medium"));
const [initialBoard, setInitialBoard] = useState<string>(board);
const [solution, setSolution] = useState<string | null>(null);
@@ -26,8 +31,6 @@ export function useBoardState(cursorManager: UseCursorType) {
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
if (digit === ".") return true;
const idx = row * 9 + col;
for (let c = 0; c < 9; c++) {
const checkIdx = row * 9 + c;
if (c !== col && testBoard[checkIdx] === digit) {
@@ -131,7 +134,7 @@ export function useBoardState(cursorManager: UseCursorType) {
};
const setDigit = (d: string) => {
const i = cursor.r * 9 + cursor.c;
const cellIndex = cursor.r * 9 + cursor.c;
if (!canEditCell(cursor.r, cursor.c)) {
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
@@ -139,10 +142,10 @@ export function useBoardState(cursorManager: UseCursorType) {
}
const arr = board.split("");
arr[i] = d;
arr[cellIndex] = d;
const newBoard = arr.join("");
if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
if (validateMoves && !isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
window.dispatchEvent(
new CustomEvent("sudoku-invalid-move", {
@@ -153,11 +156,13 @@ export function useBoardState(cursorManager: UseCursorType) {
}
setBoard(newBoard);
window.dispatchEvent(
new CustomEvent("sudoku-valid-move", {
detail: { row: cursor.r, col: cursor.c, digit: d },
})
);
if (validateMoves) {
window.dispatchEvent(
new CustomEvent("sudoku-valid-move", {
detail: { row: cursor.r, col: cursor.c, digit: d },
})
);
}
return true;
};
+7 -2
View File
@@ -6,14 +6,19 @@ import { useBoardState } from "./useBoardState";
import { useKeyBindings } from "./useKeyBindings";
import { useScore } from "../useScore";
export function useGame(platformHook?: unknown) {
export type UseGameOptions = {
validateMoves?: boolean;
};
export function useGame(platformHook?: unknown, options?: UseGameOptions) {
const { validateMoves = false } = options || {};
const rendererManager = useRenderer();
const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager;
const cursorManager = useCursor(rendererManager);
const { cursor, moveCursor } = cursorManager;
const gameManager = useGameStatus(rendererManager, cursorManager);
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
const boardManager = useBoardState(cursorManager);
const boardManager = useBoardState(cursorManager, { validateMoves });
const {
board,
initialBoard,
+2
View File
@@ -10,6 +10,8 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB