Turns a vague bracelet idea — "something like a sunset", "cherries on cream", or a photo — into real, followable friendship-bracelet instructions: materials, the exact cut length for every single string with the arithmetic shown, the setup order, and a numbered knot-by-knot guide.
Live: https://daniel-beachy.github.io/knotwright/
It is a static site. No backend, no accounts, no database. A design lives entirely inside its own URL, so it can be shared by link alone.
One person: a hobbyist who makes bracelets by hand, on her phone, with a bundle of embroidery floss taped to a clipboard. She is not a developer.
That single fact decides most of the design:
- If a number is wrong she loses an evening and a pile of thread. Correctness beats polish everywhere they conflict. When a value is uncertain, the app errs in the direction that wastes a little thread rather than the direction that runs out three rows from the end.
- Every estimate says how confident it is. Cut lengths are tagged
derived(follows a published, cross-checked formula) orestimate(reasoned, not verified against a real bracelet). - The arithmetic is never hidden. Every cut length expands to show the sum that produced it, so she can redo it herself if her tension differs. A number she cannot check is a number she cannot trust.
- She will close the tab. The knot-along writes her position to
localStorageon every single knot, not on unload — phone browsers kill background tabs without warning.
npm ci
npm test # 100 tests
npm run devLeave the API key field empty. That is the normal, fully-supported mode; the app makes zero network calls in it.
src/
engine/ Pure, deterministic, no DOM, no network. All geometry lives here.
knots.ts The four knot types and what each one does. 143 lines that
everything else is derived from.
normal.ts Diagonal-knot simulator (chevrons, stripes, diamonds).
alpha.ts Alpha grid simulator (words and pictures).
chain.ts Motif-chain simulator (daisies, cherries, hearts).
font.ts 5x7 bitmap font, rotated for reading along the bracelet.
thread.ts All cut-length arithmetic. The most safety-critical file.
generate.ts Brief -> 3-5 genuinely distinct candidate designs.
guide.ts Design -> materials, setup order, one step per knot.
serialize.ts Design <-> URL-safe string. The entire persistence layer.
rng.ts Seeded PRNG (xmur3 + mulberry32).
data/dmc.ts The floss palette. One file, deliberately isolated.
brief/ Turning words / photos / (optionally) Gemini into a design brief.
state/storage.ts localStorage: settings, knot-along progress, recent designs.
ui/ React screens. No geometry, no arithmetic — display only.
tests/ 100 tests, including committed golden fixtures.
The dependency arrow only ever points inwards: ui -> brief -> engine -> data. The engine imports
nothing from React, the DOM, or the network, which is why it can be tested exhaustively and why the
whole app works offline.
Vite + React + TypeScript, no framework beyond that.
- Static output was a hard requirement (GitHub Pages). Vite produces a plain
index.html+assets/with no server component. Total shipped payload is ~96 KB gzipped including React. - TypeScript in
strictmode does real work here.ColorIndex,KnotTypeand theNormalPattern | AlphaPattern | ChainPatternunion make whole categories of pattern bug unrepresentable. The compiler caught theChainModulenarrowing bug in the preview renderer before a human would have. - No state library, no router library, no CSS framework. The app has seven screens and one
state shape. A router dependency would have added more code than
router.ts(60 lines) contains. - Hash routing on purpose. GitHub Pages cannot rewrite deep paths, so
/d/<code>would 404 on refresh or on a shared link.#/d/<code>always resolves toindex.html. That is precisely what "shareable by URL alone" requires. - Vitest because it shares Vite's transform pipeline — the tests run the same TypeScript the browser runs, with no separate build config to drift.
generateCandidates(brief, seed) is a pure function. The seed is a short string in the URL; the
PRNG is xmur3-seeded mulberry32, chosen because it is ~15 lines and has no platform dependencies —
Math.random() would have made designs unshareable.
Same words + same seed = byte-identical options, on any device, forever. This is asserted in the test suite, not just intended.
A design serialises to base64url'd JSON with run-length encoding on the knot grids. A typical chevron link is ~340 characters; a full alpha word bracelet ~1300. Both survive a text message.
Version mismatches fail loudly rather than silently rendering something else. A design link that half-works is worse than one that says "this link is broken".
AI is optional and is never on the critical path. With no key the app is not degraded; that is the default path, and it is the one the tests cover.
If — and only if — the user supplies her own Google Gemini key (free tier, stored in her browser, called directly from her browser), it is used for exactly one thing: turning "something like a sunset over the sea" into a structured brief — a handful of colours, a mood, a hint of style.
That is a taste judgement. It has no correct answer, degrades gracefully when it is a bit wrong, and language models are genuinely good at it.
Even then, the model's output is snapped onto the real DMC palette before it goes anywhere
near the engine. The model proposes colours; nearestFloss() decides which actual skeins they are.
The engine owns all geometry: knot types, string order, row counts, grids, step counts, thread lengths. No language model touches any of it.
This is not squeamishness, it is the core product decision:
- A model asked for a chevron chart will produce something that looks exactly like a chevron chart. Getting one knot wrong in row 14 is invisible on screen and catastrophic at the clipboard.
- The failure is silent and unfalsifiable at the point of use. She finds out four hours in.
- Knot simulation is not hard — it is a permutation and a lookup table. It is trivially testable against known-correct published patterns. Delegating a solved deterministic problem to a stochastic system, for a user who pays for errors in wasted evenings, would be indefensible.
So: the model may say "warm oranges and a deep blue". It may not say "row 14 is a forward knot".
brief/image.ts does deterministic k-means on pixels sampled from a <canvas> in her browser,
with stride-based (not random) initialisation so the same photo always yields the same palette.
Clusters are matched to real DMC codes using redmean colour distance. No network, no key, no
upload — the photo never leaves the device.
N strings held in order. Each row ties N/2 knots between adjacent pairs; rows alternate their offset, so odd rows leave the outermost string on each side idle. Four knot types:
| Knot | Chart | Shows | Swaps? | Half-hitches |
|---|---|---|---|---|
| Forward | / |
left colour | yes | 2 (one pair) |
| Backward | \ |
right colour | yes | 2 (one pair) |
| Forward-backward | > |
left colour | no | 4 (two pairs) |
| Backward-forward | < |
right colour | no | 4 (two pairs) |
The knot type decides both the colour that shows and whether the two strings swap position, so
string order evolves row by row — and that evolution is the pattern. simulateNormal tracks
actual strand identities through every row and asserts the order is still a valid permutation after
each one. Nothing is faked with a static colour grid.
Two consequences that are easy to get wrong and are handled explicitly:
- Strings circulate. They travel inward to the centre, out to the far edge, then lead a new
diagonal. So the naive setup
A B C D D C B Adoes not produce stripes in the order A, B, C, D — it produces A, B, D, C.leadStrandOrder()computes the correct setup order at runtime, since it varies with width. - Colour period is not strand period. The strand-id permutation repeats every
2nrows but the colours repeat everyn. Reporting "16 rows" for a visually 8-row chevron is technically true and practically useless, so repeat detection compares colours.
A pixel grid, one knot per cell. Base strings stay put; a single long leader travels across each row tying over every base string, reversing direction each row.
The physical constraint that shapes everything: at each intersection only two threads exist, so
every cell must be either the leader's colour or that column's base colour. validateAlpha reports
violations; quantizeToAlpha repairs them and says how many cells it changed.
The leader does nearly all the knotting and eats far more thread than any base string — often more than a whole DMC skein. See "thread" below.
Not a grid at all: a sequence of modules on six strands — a run of chain, a motif, more chain. The strands return to their starting colour order at the end of every module, which is asserted, and which gives her a natural checkpoint: if the colours are wrong at the end of a motif, undo back to the start of that motif rather than carrying on.
Every string gets its own length from the same sum:
cut = (body length + 6 cm handling slack)
+ (half-hitch pairs that string ties × 1.1 cm)
+ (2 ends × tie length × 1.5625 twisting take-up)
rounded UP to the next 5 cm
Two independent derivations, deliberately cross-checked:
- Back-derived from a published formula. The widely used halokiwi rule
(
length_cm = 45 + P, where P is the percentage of rows in which that string knots) is calibrated for a 14 cm body with 8 cm ties. A string that knots in 100% of rows wants 145 cm; one that never knots wants 45 cm. 100 cm spread over (14 cm × 6.5 rows/cm) ≈ 91 knots gives 1.10 cm/knot. - Wrap geometry. A double half hitch wraps twice around a bundle roughly 1.5 mm across: 2 × π × 1.5 mm ≈ 0.94 cm. This is a lower bound — it ignores the working thread's own thickness, which increases the effective wrap radius.
1.1 was chosen (the upper end) because the failure modes are asymmetric: surplus thread gets trimmed, missing thread means unpicking. The implementation reproduces all three halokiwi reference points (45 / 95 / 145 cm).
FB and BF are literally two complete double-half-hitches tied by the same string. Counting them
as one knot — which is what the step count does, correctly — would understate their thread by half.
So the engine tracks two separate figures: knotsPerStrand (for step numbering) and
hitchPairsPerStrand (for thread). The thread planner uses the latter. There is a test for this.
Patterns are rounded to whole repeats when generated, so the band actually knotted can differ from the requested wrist size by a couple of centimetres. The thread plan therefore measures the pattern, not the request. Cutting for a requested 15.5 cm when the pattern knots up 18.5 cm leaves the last rows short — exactly the failure this app exists to prevent. An invariant test checks realised length against planned length for every design at every wrist preset.
An alpha leader for a word bracelet genuinely wants 12–15 metres. That number is real — every row of an alpha bracelet is a full row of knots and they compound — but nobody can work a 15 metre thread; it tangles within minutes. So the plan returns "cut 5 pieces of 295 cm" with a join allowance built into each piece, plus a note that joining the leader at the back of the work is completely normal practice.
- Printable guide — a real
@media printstylesheet. App chrome is hidden, page breaks are kept out of the middle of steps, and every collapsed<details>is force-expanded, because a printed sheet with the arithmetic folded away inside a<summary>would defeat the point. - Interactive knot-along — one knot at a time, a tap target that is most of the screen, the
current position highlighted on the chart, and her place written to
localStorageon every knot.
100 tests. npm test.
Golden-pattern fixtures (tests/fixtures/, committed): a classic 8-string chevron, a 6-string
candy stripe, and the word BFF in alpha. The simulator's output is asserted against files that
were checked by hand — I traced each colour's two diagonals to the centre of the chevron, read the
candy stripe's left edge A→F, and read the alpha grid letter by letter. Tests never regenerate
fixtures; npm run fixtures does that deliberately, and CI fails if the committed fixtures no
longer match.
Invariant tests, run against ~300 real generated designs:
- string order is still a valid permutation after every simulated row
- thread estimates are never negative or zero, at any wrist size
- shown arithmetic sums to the stated total (the displayed sum is checked, not decorative)
- serialize → deserialize is lossless, and idempotent on a second pass
- corrupt links fail loudly instead of producing a different bracelet
- the guide's step count equals the pattern's knot count, numbered 1..N with no gaps
- every design is actually long enough to go round a wrist
- no single thread exceeds a workable length
Browser smoke tests verified by hand during development, against the production build in real Edge: zero external network requests with no key, deterministic options across reload, knot-along progress surviving a tab close, a shared link reproducing identical cut lengths in a clean profile, photo import with no key, and the print stylesheet expanding the arithmetic.
Worth listing, because they are the argument for the whole approach:
- Mirrored words.
renderWordused a plain transpose to rotate glyphs a quarter turn. A transpose is a reflection, not a rotation — every alpha word would have been knotted mirrored. - Doubled knots undercharged.
FB/BFwere counted as one knot for thread purposes. Every design using them was short on floss. - A three-centimetre bracelet. The word-alpha generator rendered "BFF" exactly once: 19 rows, under 3 cm. Internally perfect, unwearable. Now the word repeats along the length with plain background margins.
"BFF IN PINK".extractQuotedWordon "the word BFF in pink" returned the whole tail, which would have produced a bracelet three times too long.- Cut lengths sized from the request, not the pattern — see above.
- A 14-metre thread presented as a single cut.
Every one of these produces a confident, plausible-looking, completely wrong result. None would have been caught by looking at the screen.
.github/workflows/deploy.yml runs on push to main: npm ci → typecheck → test → verify golden
fixtures → build → deploy to Pages via upload-pages-artifact / deploy-pages with OIDC.
Tests gate the deploy on purpose. A broken knot simulator can never reach the published site.
vite.config.ts sets base: '/knotwright/', overridable with KNOTWRIGHT_BASE for a different
repo name or a custom domain.
I have not knotted any of these bracelets. Everything below is an honest statement of what rests on reasoning rather than measurement.
Gauge (rows per cm) is the weakest link, and everything depends on it. Published figures range from about 5 to 7 rows/cm and genuinely contradict each other, because the real answer is a function of how hard you pull. The defaults (6.5 normal, 7 alpha) are the middle of the published range. Every finished length in the app is that number times a row count. The app exposes it in Settings and tells her to knot 20 rows, measure, and divide. She should do that once. Until she does, treat lengths as ±20%.
No physical bracelet was made to validate any of this. The thread formula reproduces the halokiwi reference points and agrees with wrap geometry to within about 15%, and the golden patterns match published charts. That is two independent checks, not a measurement.
Alpha leader length is the least certain number in the app, and it is flagged estimate in the
UI. My model gives ~12–15 m for a word bracelet. Some online guides say to cut the background
string 1.5–1.8 m. I believe those guides are wrong for a bracelet of this width, and I want to be
explicit about why rather than quietly picking one: an 11-string, 17 cm alpha band is ~28 cm² of
double-half-hitch fabric, and covering that at ~4 mm of thread per mm² of surface needs on the order
of 11 m. The two derivations agree with each other and disagree with the guides. But I could not
test it, so the app also tells her that joining leader thread mid-bracelet is normal and expected,
which makes an underestimate recoverable rather than fatal.
The DMC hex values are approximations. 47 curated colours in src/data/dmc.ts, with the codes
taken from published conversion charts. Screen colour is not dyed cotton and the two will not match.
A human must verify the codes against the real skein before buying thread — the file says so at
the top, and the app footer says so on every screen.
Cherry, strawberry and heart motifs are the daisy construction with different colour roles and chain lengths. That is how most community tutorials describe them and it produces a correct, knottable bracelet — but it is a simplification. Some makers use genuinely different constructions for fruit motifs, particularly strawberries. The app does not claim otherwise, and the help page says this plainly.
Chain bracelet length is the most tension-sensitive estimate. Chain sections stretch and compress far more than knotted rows. The guide tells her to measure against the wrist after two or three motifs and add or drop one, which is the only honest advice available.
Working-time estimates are a guess — a flat four seconds per knot. Beginners are much slower, and so is anyone watching television.
The diamondChooser half-period (strandCount / 2) is derived rather than visually verified
against a published diamond chart. It satisfies every invariant and looks right in the preview, but
it did not get the same line-by-line hand-check the chevron and candy stripe fixtures did.
The Gemini model name (gemini-2.0-flash) is not pinned to anything I could verify against
current API docs, which is why it is an editable field in Settings rather than a hard-coded constant.
If Google retires it, she can type a new one instead of waiting for a release.
The floss palette is deliberately small. 47 colours is enough to render a sunset and few enough to shop for. It is not a full DMC range, so an unusual colour request lands on the nearest available skein rather than an exact match.
MIT.