diff --git a/HANDOFF.md b/HANDOFF.md
new file mode 100644
index 00000000..b608f998
--- /dev/null
+++ b/HANDOFF.md
@@ -0,0 +1,162 @@
+# Handoff — cropping, gradients and text docs
+
+Working notes so this can be picked up on another machine. **Delete this file
+before merging** — it is scratch, not site content.
+
+## Where things stand
+
+Two branches, both pushed to origin.
+
+### `cropping-demos` — pushed, ready for a PR
+
+Open at https://github.com/fabricjs/fabricjs.github.io/pull/new/cropping-demos
+
+| Commit | |
+| --- | --- |
+| `ae50fb4` | feat(demos): add cropping controls demo |
+| `c2df674` | docs(cropping): trim the cropping images guide |
+| `86b7068` | progress *(yours)* |
+| `f73866b` | docs(cropping): explain how enterCropMode is built |
+| `b44affd` | chore: bump fabric.js submodule to v7.4.0 |
+| `ac74579` | chore(api): regenerate reference for v7.4.0 |
+| `4fc3ecd` | feat(gradients): add gradient controls demo and using gradients guide |
+
+Contains: the cropping controls demo, the gradient controls demo, the rewritten
+`cropping-images` guide, the new `using-gradients` guide, the fabric bump to
+7.4.0 (package + submodule), and the 454-file API reference regeneration.
+
+### `using-text` — pushed, in progress
+
+| Commit | |
+| --- | --- |
+| `b3f33be` | docs(text): add the Text guide group |
+| `0047eba` / `82f6b63` | *(yours — added then removed `.claude/launch.json`)* |
+
+Nine pages under `src/content/docs/docs/Text/`, orders 310–390, rendering as a
+collapsible sidebar group between `Getting started` and `Upgrading`.
+
+## Resuming on another machine
+
+```bash
+git fetch origin
+git checkout using-text
+git submodule update --init --recursive # must land on tag v740
+npm install
+npm run dev
+```
+
+Two environment facts that will otherwise waste your time:
+
+- **`westures` is a devDependency now, and it is required.** `fabric/extensions`
+ re-exports its westures gesture integration unconditionally, but fabric
+ declares `westures` nowhere. Without it, importing `fabric/extensions` dies
+ with `ERR_MODULE_NOT_FOUND` and takes the whole dev server down.
+- **The submodule must be at `v740`.** Tags are named `v740`, not `v7.4.0`. The
+ API reference is generated from it, so a mismatch produces a large spurious
+ diff under `src/content/docs/api/`.
+
+## Text docs — the nine pages
+
+`src/content/docs/docs/Text/`
+
+| Order | File | Notes |
+| --- | --- | --- |
+| 310 | `choosing-a-class.mdx` | The three classes as a chain; only `set()` re-lays-out; the three index spaces |
+| 320 | `layout-and-appearance.mdx` | interactive canvas |
+| 330 | `fonts.mdx` | The measurement cache and the late-webfont failure. The most load-bearing page |
+| 340 | `character-styles.mdx` | interactive canvas |
+| 350 | `editing.mdx` | interactive canvas |
+| 360 | `textbox-wrapping.mdx` | interactive canvas |
+| 370 | `editing-ui.mdx` | The integration cookbook — toolbar focus, undo/redo, mobile, a11y |
+| 380 | `text-on-a-path.mdx` | Beta limits |
+| 390 | `serializing-text.mdx` | Style compression lossiness, SVG asymmetry |
+
+Interactive examples live in a sibling directory per page, e.g.
+`Text/editing/code.js`, imported as `./editing/code.js?raw`. From a page inside
+the group, `CodeEditor` is four levels up: `../../../../components/CodeEditor`.
+
+## Library findings worth taking upstream
+
+These came out of verifying doc claims against a running build. All reproduced.
+
+1. **`fabric/extensions` has an undeclared dependency on `westures`.** Not in
+ `dependencies`, `peerDependencies` or `optionalDependencies`. A downstream
+ user importing only the cropping helpers still hits it.
+
+2. **`toObject()` throws on a style declaration holding only unknown keys.**
+
+ ```js
+ new fabric.FabricText('abcd', { styles: { 0: { 0: { mine: 'x' } } } }).toObject();
+ // TypeError: Cannot read properties of undefined (reading 'end')
+ ```
+
+ `stylesToArray` tries to extend a range that was never opened, because
+ `hasStyleChanged` only compares the 14 known properties. Relevant to anyone
+ adding a custom per-character style property.
+
+3. **CRLF shifts styles by one character per line on a JSON round trip.**
+ `stylesToArray` splits on `'\n'`, `stylesFromArray` on `/\r?\n/`.
+
+ ```js
+ const t = new fabric.FabricText('ab\r\ncd', { styles: { 1: { 0: { fill: 'red' } } } });
+ (await fabric.FabricText.fromObject(t.toObject())).styles;
+ // { 1: { 1: { fill: 'red' } } } ← moved to character 1
+ ```
+
+4. **`ctrlKeysMapUp[88]` points at a `cut` method that does not exist.** Works in
+ practice only because `_copyDone` short-circuits `onKeyUp` first.
+
+5. **`renderGhostImage` and `cropPanMoveHandler` are not exported** from
+ `fabric/extensions`, so the crop ghost cannot be used with a control set you
+ assigned yourself — only via `enterCropMode`. Exporting them would make the
+ "recompose the pieces yourself" story in the docs actually true.
+
+6. **A gradient colour stop at offset 0 or 1 sits exactly under an axis endpoint
+ handle** and shadows it in hit-testing, so that endpoint becomes hard to grab.
+
+7. **`Textbox`'s class JSDoc claims Y scaling is locked.** It is not — `mt`, `mb`
+ and the corner handles all scale. The doc comment is stale.
+
+## Corrections already applied to the docs
+
+Three claims that source-reading got wrong and testing caught. Mentioning them
+because the same reasoning traps are easy to fall back into:
+
+- An assigned `Textbox` **height is not rejected** — it survives until the next
+ layout pass, then is overwritten.
+- `setSelectionStyles({ prop: undefined })` **deletes the override** and reverts
+ to the object value. It is not a no-op, and it differs from passing `false`.
+- Custom style properties are dropped when merged into a run, but **crash** when
+ they are the only key (see finding 2).
+
+## Open follow-ups
+
+- **Open the `cropping-demos` PR.** Nothing blocking it.
+- **Supersede the Old docs text pages** now that the Text group exists:
+ `Old docs/fabric-text.md`, the `### Text` section of `fabric-intro-part-2.md`,
+ and the Node fonts section of `fabric-intro-part-4.md`. The precedent is the
+ `'/docs/old-docs/fabric-object-caching'` redirect already in
+ `astro.config.mjs`.
+- **Fold the `loading-custom-fonts` demo prose into `Text/fonts.mdx`** and leave
+ the demo as the showcase, per the docs-vs-demos split in `AGENTS.md`.
+- **A rich-text toolbar demo** would be the natural showcase for `editing-ui.mdx`.
+- **Standardise demo tags** — currently `text`, `text styles`, `fonts`,
+ `typography` are used inconsistently.
+- **Collapsible / expandable `CodeEditor`** — your idea from the cropping work:
+ some canvases want the code minimised by default, with the interaction
+ foregrounded.
+- Dependabot reports 64 vulnerabilities on the default branch (28 high). Pre-existing.
+
+## Verifying interactive pages
+
+The canvases register themselves on `window.canvasesId[canvasId]`, which makes
+them scriptable from the browser console — much more reliable than eyeballing:
+
+```js
+const c = window.canvasesId['text-editing'];
+c.getObjects()[0].enterEditing();
+```
+
+One trap when simulating drags: dispatch `mouseup` on **`document`**, not
+`window`. Fabric does not see a window-level mouseup, so `_currentTransform`
+stays active and every later drag silently continues the first handle.
diff --git a/src/content/docs/docs/Text/character-styles.mdx b/src/content/docs/docs/Text/character-styles.mdx
new file mode 100644
index 00000000..70552184
--- /dev/null
+++ b/src/content/docs/docs/Text/character-styles.mdx
@@ -0,0 +1,237 @@
+---
+date: '2026-07-30'
+title: 'Styling individual characters'
+description: 'The styles object, styling a range, and how styles survive editing'
+sidebar:
+ order: 340
+---
+
+import { CodeEditor } from '../../../../components/CodeEditor';
+import code from './character-styles/code.js?raw';
+
+What makes Fabric.js text *rich* text is that any of a small set of properties can
+be overridden per character. This page is the data model behind that.
+
+
+
+
+
+## The styles object
+
+`styles` is a sparse, two-level map: **logical line index**, then **grapheme index
+within that line**.
+
+```js
+text.styles = {
+ 0: {
+ 0: { fontWeight: 'bold' },
+ 1: { fontWeight: 'bold' },
+ },
+ 2: {
+ 5: { fill: 'red' },
+ },
+};
+```
+
+A missing line means no styles on that line. A missing character means it inherits
+from the object. So `styles` records only the differences, never a complete
+picture — which is why reading a character's *effective* value needs a helper
+rather than a lookup.
+
+Two things about the indices:
+
+- Character indices count **graphemes**, not UTF-16 units. An emoji or a flag
+ occupies one style slot even though `text.length` counts several.
+- Line indices are **logical** lines — the ones separated by `\n` in your text.
+ For a `Textbox`, the lines you *see* are wrapped lines, and those are a
+ different index space. See [wrapping text with Textbox](/docs/text/textbox-wrapping).
+
+## What can be styled
+
+Exactly fourteen properties:
+
+`fontSize`, `fontWeight`, `fontFamily`, `fontStyle`, `underline`, `overline`,
+`linethrough`, `stroke`, `strokeWidth`, `fill`, `deltaY`, `textBackgroundColor`,
+`textDecorationThickness`, `textDecorationColor`.
+
+Anything not in that list is object-wide only. In particular **`textAlign`,
+`lineHeight`, `charSpacing`, `direction`, `shadow` and `opacity` cannot vary per
+character** — so there is no per-paragraph alignment within a single text object.
+If your editor needs mixed alignment, use one object per paragraph.
+
+`deltaY` shifts a character's baseline in pixels, and is how superscript works.
+
+## Reading and writing a range
+
+```js
+// read
+const styles = text.getSelectionStyles(startIndex, endIndex);
+const complete = text.getSelectionStyles(startIndex, endIndex, true);
+
+// write (merges into whatever is already there)
+text.setSelectionStyles({ fontWeight: 'bold' }, startIndex, endIndex);
+```
+
+Indices are graphemes, `start` is inclusive and `end` is **exclusive**. On an
+`IText` both arguments default to the current selection, which is what makes a
+toolbar button a one-liner:
+
+```js
+itext.setSelectionStyles({ underline: true });
+```
+
+The `complete` flag fills in the object-level value for every property that isn't
+overridden, so you get all fourteen keys back. Use it when you need to know what a
+character actually looks like; omit it when you want to know what was explicitly
+set.
+
+### Three traps in this API
+
+**Omitting `endIndex` silently does nothing.** The documentation suggests it
+defaults to `startIndex + 1`; it does not — the internal loop runs from `start` to
+`end` and with `end` missing it never executes. `getSelectionStyles(3)` returns an
+empty array and `setSelectionStyles({...}, 3)` sets nothing. Always pass an
+explicit end. For the same reason `endIndex: 0` is falsy and behaves like omitting
+it.
+
+**`undefined` and `false` do different things, and neither is a no-op.** Passing
+`undefined` *removes the override*, so the character falls back to the object-level
+value. Passing `false` writes an override of `false`. When the object's own value is
+`true`, the two give opposite results:
+
+```js
+const text = new fabric.FabricText('abc', {
+ underline: true, // object-level
+ styles: { 0: { 0: { underline: false } } }, // character 0 overrides it
+});
+
+text.setSelectionStyles({ underline: undefined }, 0, 1);
+// override deleted → character 0 is now underlined, like the rest
+
+text.setSelectionStyles({ underline: false }, 0, 1);
+// override kept → character 0 stays not underlined
+```
+
+So `undefined` means "inherit from the object" and `false` means "force off". Other
+properties in the same declaration are untouched either way. To strip a property
+from every character at once, use `text.removeStyle('underline')`.
+
+**`setSelectionStyles` does not re-measure immediately.** It marks the object for
+re-measurement at the next render instead. Between your call and that render,
+`width`, `height` and `getBoundingRect()` are stale — which bites when you
+reposition a toolbar or an overlay right after applying a style. If you need
+correct dimensions now:
+
+```js
+text.setSelectionStyles({ fontSize: 60 }, 0, 4);
+text.initDimensions();
+text.setCoords();
+```
+
+## Reading an effective value
+
+To find out what a character actually renders as — style override if present,
+object value otherwise — use `getValueOfPropertyAt`:
+
+```js
+const size = text.getValueOfPropertyAt(lineIndex, charIndex, 'fontSize');
+```
+
+Reading `text.fontSize` ignores styles, and reading `text.styles[l][c].fontSize`
+ignores the fallback. This helper is the only correct route, and it takes *line
+and character* coordinates rather than a flat index — convert with
+`text.get2DCursorLocation(index, true)`.
+
+Do not mutate what the lower-level style getters hand back. They return a live
+reference when a style exists and a throwaway empty object when it does not, and
+you cannot tell which from the outside — so treat results as read-only.
+
+## Housekeeping
+
+Styles accumulate redundancy fast, especially through copy and paste, which
+records the *complete* style of every copied character. Two helpers clean up:
+
+- **`text.cleanStyle(property)`** removes per-character values equal to the
+ object's value, prunes the empties, and — if every character agrees — hoists the
+ value onto the object and drops the per-character entries. Note that it can
+ therefore change the object-level property.
+- **`text.removeStyle(property)`** deletes that property everywhere.
+
+`text.styleHas(property)` tells you whether a property appears anywhere, and
+`isEmptyStyles(lineIndex)` whether a line has any styling at all. Both are used
+internally to pick faster rendering paths.
+
+## Superscript and subscript
+
+```js
+text.setSuperscript(startIndex, endIndex);
+text.setSubscript(startIndex, endIndex);
+```
+
+Both are conveniences over `fontSize` and `deltaY`, controlled by the
+`superscript` and `subscript` objects (`{ size: 0.6, baseline: -0.35 }` and
+`{ size: 0.6, baseline: 0.11 }`).
+
+Two consequences of being computed rather than semantic: applying it twice
+compounds — the size multiplies by 0.6 each time — and because `deltaY` is stored
+in pixels, changing `fontSize` afterwards does not rescale the shift. There is no
+`unsetSuperscript`; you reset `fontSize` and `deltaY` yourself.
+
+Also note that the default `superscript`, `subscript` and `offsets` objects are
+shared between instances, so mutating one in place affects every text object
+created afterwards. Replace the whole object instead:
+
+```js
+text.superscript = { size: 0.7, baseline: -0.4 }; // not text.superscript.size = 0.7
+```
+
+## How styles survive edits
+
+When text is inserted or deleted, style keys have to shift with it. Fabric.js
+handles this for you as long as you go through the right API:
+
+- **`text.insertChars(chars, styleArray, start, end)`** — inserts, optionally with
+ one style object per inserted grapheme.
+- **`text.removeChars(start, end)`** — deletes and reflows the remaining styles.
+
+Both re-measure and re-position afterwards. Prefer them over `set('text', …)`,
+which replaces the string without reflowing `styles` at all.
+
+Two behaviours worth knowing because they are visible to users: typing inherits
+the style of the character to the **left** of the caret, and pressing Enter at the
+end of a line deliberately does not drag the previous character's style onto the
+new line. Clearing the text entirely discards all styles.
+
+Neither `insertChars` nor `removeChars` syncs the hidden textarea or fires
+`changed`, so calling them mid-edit needs care — see
+[building a text editing UI](/docs/text/editing-ui).
+
+## Adding your own style property
+
+You can extend the styleable set on a subclass:
+
+```js
+class MyText extends fabric.IText {
+ static _styleProperties = [...fabric.IText._styleProperties, 'myProp'];
+}
+```
+
+That is enough for `getSelectionStyles(…, true)` and `getValueOfPropertyAt` to see
+it. You then override `_renderChar` to draw it, add it to `cacheProperties` if an
+object-level change should repaint, and to `textLayoutProperties` if it affects
+measurement.
+
+**There is a real limit here, so plan for it.** The internal comparison that
+decides "did the style change between these two characters" has a hard-coded list
+of the fourteen properties and no override hook. Two consequences:
+
+1. Adjacent characters differing *only* in your custom property are drawn as one
+ run, using the first character's value.
+2. Serialisation compresses runs the same way, so your property's variation is
+ **dropped by `toObject()`** — and a declaration containing *only* unknown
+ properties makes `toObject()` throw. Never let your property be the sole
+ occupant of a declaration.
+
+The workaround is to serialise `styles` yourself — the loader already accepts the
+uncompressed object form — or to ensure your property never varies independently
+of a known one. See [serializing and exporting text](/docs/text/serializing-text).
diff --git a/src/content/docs/docs/Text/character-styles/code.js b/src/content/docs/docs/Text/character-styles/code.js
new file mode 100644
index 00000000..e37658f9
--- /dev/null
+++ b/src/content/docs/docs/Text/character-styles/code.js
@@ -0,0 +1,39 @@
+const canvas = new fabric.Canvas(canvasEl);
+
+const text = new fabric.IText('Rich text on canvas', {
+ fontSize: 34,
+ fill: '#334155',
+});
+
+// styles is keyed by logical line, then by grapheme index within that line.
+text.styles = {
+ 0: {
+ 0: { fontWeight: 'bold' },
+ 1: { fontWeight: 'bold' },
+ 2: { fontWeight: 'bold' },
+ 3: { fontWeight: 'bold' },
+ 5: { fill: '#7c3aed', fontStyle: 'italic' },
+ 6: { fill: '#7c3aed', fontStyle: 'italic' },
+ 7: { fill: '#7c3aed', fontStyle: 'italic' },
+ 8: { fill: '#7c3aed', fontStyle: 'italic' },
+ 13: { underline: true, textBackgroundColor: '#fef08a' },
+ 14: { underline: true, textBackgroundColor: '#fef08a' },
+ 15: { underline: true, textBackgroundColor: '#fef08a' },
+ 16: { underline: true, textBackgroundColor: '#fef08a' },
+ 17: { underline: true, textBackgroundColor: '#fef08a' },
+ 18: { underline: true, textBackgroundColor: '#fef08a' },
+ },
+};
+
+// Assigning styles in place does not re-measure, so do it explicitly.
+text.initDimensions();
+text.setCoords();
+
+canvas.add(text);
+canvas.centerObject(text);
+
+// The supported way to style a range: indices are graphemes, end is exclusive.
+text.setSelectionStyles({ fontSize: 44 }, 0, 4);
+text.initDimensions();
+text.setCoords();
+canvas.requestRenderAll();
diff --git a/src/content/docs/docs/Text/choosing-a-class.mdx b/src/content/docs/docs/Text/choosing-a-class.mdx
new file mode 100644
index 00000000..7ba09527
--- /dev/null
+++ b/src/content/docs/docs/Text/choosing-a-class.mdx
@@ -0,0 +1,150 @@
+---
+date: '2026-07-30'
+title: 'Text objects: choosing a class'
+description: 'FabricText, IText and Textbox — what each one adds and which to use'
+sidebar:
+ order: 310
+---
+
+Fabric.js has three text classes, and they form a chain rather than a menu:
+
+```
+FabricText → IText → Textbox
+```
+
+Each one inherits everything from the previous, so the question is never "which
+features do I get" but "how far along the chain do I need to go".
+
+## FabricText
+
+The base class. It renders text and nothing else: no cursor, no keyboard, no
+mouse handling. Everything about how text *looks* lives here — fonts, spacing,
+alignment, decorations, per-character styles — and the other two classes inherit
+all of it.
+
+```js
+const label = new fabric.FabricText('Read only', {
+ fontFamily: 'Helvetica',
+ fontSize: 24,
+});
+```
+
+Reach for it when the text is content rather than input: labels, watermarks,
+captions, generated output. It is the cheapest of the three and cannot be typed
+into even if the canvas is interactive.
+
+## IText
+
+`IText` — the *I* is for interactive — adds the entire editing experience:
+
+- a caret with a blink animation, and a rendered selection
+- keyboard input through a hidden `