Pack templates

Four packs to download and change into your own. Each one passes nkb lint with no error and no warning, and each shows a different part of the format.

How do I use a template?

Download it into your own folder, and the palette offers it at once. In the palette, press AltShiftSpace , click Pack folders, then Create and open to open that folder. Then change the template into your own pack: give it its own id, rename the file to match, and replace the values with yours. Write your own pack explains every line.

Every template stays usable as it is, so you can try one in the palette before you change anything in it.

A first pack

Three values for a name field: a space at the end, an apostrophe and an empty value. The shortest pack to start from, with a comment on the lines that matter the first time.

Download first-pack.toml

# Your first pack: three values for a name field. Replace them with your own.
#
# A pack is this one file. Put it in your own folder: in the palette, open
# the value window, click Pack folders, then Create and open. Check it with
# `nkb lint first-pack.toml`, which names every problem and how to fix it.
#
# The guide that explains every line: https://naughtykeyboard.donislawdev.com/docs/write-a-pack/
#
# A template from https://naughtykeyboard.donislawdev.com/docs/pack-templates/
# Change anything in it. When you change `id`, rename the file to match,
# first-pack.toml now, because a pack is found by its file name.

format = 1

[pack]
id          = "first-pack"
name        = "First pack"
description = "Three values for a name field, to show how a pack is written."
version     = "1.0"
updated     = 2026-11-10
license     = "CC-BY-4.0"
authors     = ["Naughty Keyboard"]
language    = "en"
tags        = ["example", "names"]
fields      = ["name", "text"]
risk        = "normal"

# Each value is one [[values]] block. The palette types them in this order,
# so put the value most likely to find a bug first. A space at the end is
# written as \u0020, because an editor can drop one written out.
[[values]]
id     = "trailing-space"
name   = "Name with a space at the end"
value  = "Kowalski\u0020"
breaks = "The sign-up form keeps the space and the login form trims it, or the other way round, so the account is created and then cannot be found."
expect = "Trimmed everywhere or kept everywhere, so the same name always finds the same account."
since  = "1.0"

[[values]]
id     = "apostrophe"
name   = "Name with an apostrophe"
value  = "O'Brien"
breaks = "Code that builds a query or a script by pasting text into it reads the apostrophe as the end of a string, so the save fails with a raw error or the name is cut short."
expect = "Saved and shown as O'Brien, with no error."
since  = "1.0"

[[values]]
id     = "empty"
name   = "Empty value"
value  = ""
breaks = "The browser treats an empty field as missing and the server as an empty string, or the other way round, so a required field is saved empty."
expect = "One clear answer on both sides: refused with a message, or saved as empty on purpose."
since  = "1.0"

A field with a length limit

Values at the edge of a 50-character limit: one under, exactly at and one over it, and the same length in Polish letters, in emoji, in spaces and in letters with a separate accent, which count differently in bytes, in code points and in the units a browser counts. Change 50 to the limit of your field, in every count and every name. It shows how type = "repeat" writes a long value in a few lines.

Download text-field-limit.toml

# A field with a limit of 50 characters. Change 50 to the limit of your field
# in every `count` and every name below, and the pack tests that limit.
#
# A value this long is written as a short piece and a count, with
# type = "repeat", so the file stays readable.
#
# A template from https://naughtykeyboard.donislawdev.com/docs/pack-templates/
# Change anything in it. When you change `id`, rename the file to match,
# text-field-limit.toml now, because a pack is found by its file name.

format = 1

[pack]
id          = "text-field-limit"
name        = "Text field limit"
description = "Values at the edge of a 50-character limit, counted in letters, in bytes and in the units a browser counts."
version     = "1.0"
updated     = 2026-11-10
license     = "CC-BY-4.0"
authors     = ["Naughty Keyboard"]
language    = "en"
tags        = ["length", "limit", "boundary"]
fields      = ["text", "name"]
risk        = "normal"

[[values]]
id     = "len-51"
name   = "51 characters, one over the limit"
type   = "repeat"
unit   = "a"
count  = 51
breaks = "A limit checked only in the browser lets it through when the request is sent another way, and a database column of 50 cuts the last letter or refuses the row with a raw error."
expect = "Refused with a message that names the limit, and never saved cut short."
since  = "1.0"

[[values]]
id     = "len-50"
name   = "50 characters, exactly the limit"
type   = "repeat"
unit   = "a"
count  = 50
breaks = "A check written as less than instead of at most refuses a value the field promises to take, so the longest allowed text is the one that fails."
expect = "Accepted and saved whole."
since  = "1.0"

[[values]]
id     = "polish-letters-50"
name   = "50 Polish letters, 100 bytes"
type   = "repeat"
unit   = "ą"
count  = 50
breaks = "Each of these letters takes two bytes in UTF-8, so a column or an API with a limit in bytes refuses or cuts a value the form counted as 50 characters."
expect = "Accepted whole, or refused with the same limit the form shows."
since  = "1.0"

[[values]]
id     = "emoji-50"
name   = "50 emoji"
type   = "repeat"
unit   = "😀"
count  = 50
breaks = "JavaScript counts length in UTF-16 units, two for each of these, so the form sees 100 where the server may see 50, and a cut in the middle of one leaves a broken character."
expect = "The browser and the server count the same way, and nothing is cut in half."
since  = "1.0"

[[values]]
id     = "spaces-51"
name   = "51 spaces"
type   = "repeat"
unit   = "\u0020"
count  = 51
breaks = "Over the limit before trimming and empty after it, so the answer depends on which check runs first, and a required field can be saved empty."
expect = "Refused as empty, with the same answer whichever check runs first."
since  = "1.0"

[[values]]
id     = "combining-50"
name   = "50 letters with a separate accent"
type   = "repeat"
unit   = "e\u0301"
count  = 50
breaks = "Each letter is two code points, an e and an accent, so a counter in code points sees 100 where a reader sees 50 letters, and a cut can drop the accent from the last one."
expect = "Counted as 50 letters, or normalised on save and counted the same way everywhere."
since  = "1.0"

[[values]]
id     = "len-49"
name   = "49 characters, one under the limit"
type   = "repeat"
unit   = "a"
count  = 49
breaks = "The control just under the limit. If it is refused too, the limit is smaller than the form says, and every other result here needs reading again."
expect = "Accepted and saved whole."
since  = "1.0"

A sign-up form

An e-mail field with a second field that repeats it, and a login that has to be unique. It shows two pairs: the same address with and without a space at the end, and two logins that look the same and are written in two alphabets, the second with an escaped Cyrillic letter.

Download sign-up-form.toml

# A sign-up form: an e-mail field with a second field that repeats it, and a
# login that has to be unique. Two pairs join values that are typed one after
# the other: the first half into one field, the second half into the next.
#
# A template from https://naughtykeyboard.donislawdev.com/docs/pack-templates/
# Change anything in it. When you change `id`, rename the file to match,
# sign-up-form.toml now, because a pack is found by its file name.

format = 1

[pack]
id          = "sign-up-form"
name        = "Sign-up form"
description = "Values for an e-mail field, the field that repeats it, and a login that has to be unique."
version     = "1.0"
updated     = 2026-11-10
license     = "CC-BY-4.0"
authors     = ["Naughty Keyboard"]
language    = "en"
tags        = ["sign-up", "email", "login"]
fields      = ["email", "username"]
risk        = "normal"

[[values]]
id     = "email"
name   = "E-mail address"
value  = "jan.kowalski@example.com"
breaks = "The first half of a pair, and on its own a plain address every form takes. Type it into the e-mail field, then the next value into the field that repeats it."
expect = "Accepted."
since  = "1.0"

[[values]]
id     = "email-trailing-space"
name   = "The same address with a space at the end"
value  = "jan.kowalski@example.com\u0020"
breaks = "The second half of the pair. A form that trims one field and not the other says the two addresses differ, or saves the address with the space and no mail arrives."
expect = "Trimmed in both fields, and accepted as the same address."
since  = "1.0"

[[values]]
id     = "login-latin"
name   = "Login in Latin letters"
value  = "kowalski"
breaks = "The first half of a pair, and on its own an ordinary login. Sign up with it, then try to sign up again with the next value."
expect = "Accepted."
since  = "1.0"

[[values]]
id     = "login-cyrillic-o"
name   = "The same login with a Cyrillic o"
value  = "k\u043Ewalski"
breaks = "The second half. It looks exactly like the first login, but one letter is Cyrillic, so a uniqueness check that compares bytes makes a second account nobody can tell from the first."
expect = "Refused as taken, or accepted and shown so the difference can be seen."
since  = "1.0"

[[values]]
id     = "email-plus-tag"
name   = "Address with a plus sign"
value  = "jan.kowalski+shop@example.com"
breaks = "A check that allows only letters, digits and dots refuses a valid address many people use to sort their mail, and they cannot sign up at all."
expect = "Accepted, and the confirmation mail arrives."
since  = "1.0"

[[pairs]]
id       = "repeat-email"
name     = "E-mail and the field that repeats it"
relation = "identity"
a        = "email"
b        = "email-trailing-space"
breaks   = "The two fields are compared before they are trimmed, so a person who typed the same address twice is told the addresses differ."
expect   = "Both fields trimmed the same way, and the two accepted as one address."
since    = "1.0"

[[pairs]]
id       = "look-alike-login"
name     = "Two logins that look the same"
relation = "look-alike"
a        = "login-latin"
b        = "login-cyrillic-o"
breaks   = "Two accounts with logins nobody can tell apart, so a message, a payment or an invitation goes to the wrong person."
expect   = "The second login refused as taken, or both kept and shown so they can be told apart."
since    = "1.0"

Booking dates

A check-in at an hour the clocks skip or repeat, a stay that ends the next morning, and a date far ahead. It shows type = "next-date", a date written as a rule, so that it never falls into the past, and a pair of a start and an end.

Download booking-dates.toml

# Booking dates: a check-in at an hour the clocks skip or repeat, a stay that
# ends the next morning, and a date far ahead. The two clock-change dates are
# written as a rule, type = "next-date", and worked out on the day they are
# typed, so they never fall into the past.
#
# A template from https://naughtykeyboard.donislawdev.com/docs/pack-templates/
# Change anything in it. When you change `id`, rename the file to match,
# booking-dates.toml now, because a pack is found by its file name.

format = 1

[pack]
id          = "booking-dates"
name        = "Booking dates"
description = "Dates and times for a booking form: hours the clocks skip or repeat, a stay past midnight and a date far ahead."
version     = "1.0"
updated     = 2026-11-10
license     = "CC-BY-4.0"
authors     = ["Naughty Keyboard"]
language    = "en"
tags        = ["dates", "time", "booking"]
fields      = ["datetime", "date", "time"]
risk        = "normal"

[[values]]
id      = "spring-forward"
name    = "Check-in at an hour the clocks skip"
type    = "next-date"
month   = 3
week    = "last"
weekday = "sunday"
time    = "02:30"
breaks  = "On the night clocks go forward in the European Union this hour does not exist, so a form moves it to 03:30 without saying so, or fails with a raw error."
expect  = "Refused with an explanation, or moved with the new time shown."
since   = "1.0"

[[values]]
id      = "fall-back"
name    = "Check-in at an hour the clocks repeat"
type    = "next-date"
month   = 10
week    = "last"
weekday = "sunday"
time    = "02:30"
breaks  = "On the night clocks go back in the European Union this hour happens twice, so a booking stored without a zone cannot say which of the two it means, and a stay of one hour can last two."
expect  = "The time stored with its zone or offset, or the form asks which of the two hours is meant."
since   = "1.0"

[[values]]
id     = "start-evening"
name   = "Start at six in the evening"
value  = "18:00"
breaks = "The first half of a pair, and on its own an ordinary start time. Type it into the start field, then the next value into the end field."
expect = "Accepted."
fields = ["time"]
since  = "1.0"

[[values]]
id     = "end-next-morning"
name   = "End at nine the next morning"
value  = "09:00"
breaks = "The second half: an end earlier than the start. A form that compares the two times alone refuses an overnight stay, or saves one that ends before it begins."
expect = "Read as the next morning and shown that way, or refused with a message that asks for the date."
fields = ["time"]
since  = "1.0"

[[values]]
id     = "far-future"
name   = "The last day of the year 9999"
value  = "9999-12-31"
breaks = "A date picker, a database column or a library with a narrower range refuses it with a raw error, cuts the year, or saves it and then sorts it before today."
expect = "Refused with a message that names the latest date allowed, or saved and shown as written."
fields = ["date"]
since  = "1.0"

[[pairs]]
id       = "overnight"
name     = "A stay that ends the next morning"
relation = "range"
a        = "start-evening"
b        = "end-next-morning"
breaks   = "A range check that compares times without dates reads the end as earlier than the start, so an overnight booking is refused or saved the wrong way round."
expect   = "Accepted as a stay into the next morning, with both dates shown."
since    = "1.0"