diff --git a/packages/preview/taskize/0.2.8/LICENSE b/packages/preview/taskize/0.2.8/LICENSE
new file mode 100644
index 0000000000..364e20571e
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Nathan Scheinmann
+
+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.
diff --git a/packages/preview/taskize/0.2.8/README.md b/packages/preview/taskize/0.2.8/README.md
new file mode 100644
index 0000000000..f4dd9c38da
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/README.md
@@ -0,0 +1,609 @@
+# taskize
+
+[](https://typst.app/universe/package/taskize)
+[](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.pdf)
+[](LICENSE)
+
+A Typst package for creating horizontal columned lists, similar to LaTeX's `tasks` package. Perfect for exercises, multiple-choice questions, and any content that benefits from a compact columned layout.
+
+## Gallery
+
+Click on an image to see the source code.
+
+| | | | |
+|:---:|:---:|:---:|:---:|
+| [](gallery/basic.typ) | [](gallery/math.typ) | [](gallery/labels.typ) | [](gallery/styled.typ) |
+| Basic Tasks | Math Exercises | Custom Labels | Styled Layout |
+| [](gallery/vertical.typ) | [](gallery/span.typ) | [](gallery/bold.typ) | [](gallery/resume.typ) |
+| Vertical Flow | Column Spanning | Bold Labels | Resume Numbering |
+| [](gallery/adaptive.typ) | [](gallery/wrap-zone.typ) | | |
+| Adaptive Row Heights | Wrap Zone | | |
+
+## Features
+
+- **Indentation-independent parsing** - Output is completely independent of source code indentation
+- **Horizontal flow layout** - Items flow left-to-right across columns (like LaTeX's tasks)
+- **Flexible column counts** - 2, 3, 4 or any number of columns
+- **Auto-fit columns** - Use the largest column count that does not introduce a new wrap or overflow
+- **Column spanning** - Items can span multiple columns using `(N)` syntax
+- **Multiple label formats** - Alphabetic (`a)`, `A)`), numeric (`1)`, `(1)`), roman (`i)`, `I)`), bullets, or custom
+- **Bold labels** - Make labels stand out with bold weight
+- **Resume numbering** - Continue numbering across multiple task blocks
+- **Two flow directions** - Horizontal (a b | c d) or vertical (a c | b d)
+- **Global configuration** - Set defaults for all tasks in your document
+- **Shorthand functions** - `tasks2`, `tasks3`, `tasks4` for quick column setup
+- **Spacing & alignment controls** - Fine-grained gutters, label alignment, baselines, and block spacing
+- **Wrap zone** - Reserve a top-right corner for overlay content (QR codes, stamps) and flow task rows around it
+
+## Manual
+
+A full manual is available as a [PDF](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.pdf), with a Typst source version in [docs/manual.typ](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.typ).
+
+## Quick Start
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks[
+ + First item
+ + Second item
+ + Third item
+ + Fourth item
+]
+```
+
+## Basic Usage
+
+### Simple Two-Column Layout
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks[
+ + $2 + 3 = ?$
+ + $5 - 2 = ?$
+ + $4 times 3 = ?$
+ + $8 div 2 = ?$
+]
+```
+
+### Three-Column Layout
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(columns: 3)[
+ + Option A
+ + Option B
+ + Option C
+ + Option D
+ + Option E
+ + Option F
+]
+```
+
+### Auto-Fit Columns
+
+Use `columns: "auto-fit"` to choose the largest column count that does not make any item wrap or overflow compared with a one-column layout. Limit the search with `max-columns`.
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(columns: "auto-fit", max-columns: 4)[
+ + Medium length task with detail
+ + Short
+ + Short
+ + Short
+]
+```
+
+By default, auto-fit uses `auto-fit-mode: "fill"`: an item too wide for one
+column automatically spans the minimum number of columns it needs — no
+explicit `+(N)` required — so wide items share rows with densely packed short
+items. Use `auto-fit-mode: "uniform"` when every item must fit one ordinary
+column; in that mode, the column count is the largest value where no item
+wraps at one-column width, so a wide item can force the whole block to fewer
+columns. If an item already wraps in the one-column layout, auto-fit keeps
+one column, because no no-wrap multi-column layout exists.
+
+### Shorthand Functions
+
+```typst
+#import "@preview/taskize:0.2.8": tasks2, tasks3, tasks4
+
+// Two columns
+#tasks2[
+ + Item 1
+ + Item 2
+]
+
+// Three columns
+#tasks3[
+ + Item 1
+ + Item 2
+ + Item 3
+]
+
+// Four columns
+#tasks4[
+ + A
+ + B
+ + C
+ + D
+]
+```
+
+## Label Formats
+
+### Built-in Formats
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+// Lowercase letters with parenthesis (default)
+#tasks(label: "a)")[+ One + Two + Three]
+
+// Uppercase letters
+#tasks(label: "A)")[+ One + Two + Three]
+
+// Numbers with parenthesis
+#tasks(label: "1)")[+ One + Two + Three]
+
+// Numbers in parentheses
+#tasks(label: "(1)")[+ One + Two + Three]
+
+// Lowercase roman numerals
+#tasks(label: "i)")[+ One + Two + Three]
+
+// Uppercase roman numerals
+#tasks(label: "I)")[+ One + Two + Three]
+
+// Bullet points
+#tasks(label: "*")[+ One + Two + Three]
+
+// Dashes
+#tasks(label: "-")[+ One + Two + Three]
+
+// No labels
+#tasks(label: none)[+ One + Two + Three]
+```
+
+### Custom Label Function
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+// Custom emoji labels
+#tasks(label: n => "Q" + str(n) + ":")[
+ + What is 2+2?
+ + What is the capital of France?
+ + What color is the sky?
+]
+```
+
+## Flow Direction
+
+### Horizontal Flow (Default)
+
+Items fill rows first: `a b | c d | e f`
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(columns: 2, flow: "horizontal")[
+ + a
+ + b
+ + c
+ + d
+ + e
+ + f
+]
+// Layout:
+// a) b)
+// c) d)
+// e) f)
+```
+
+### Vertical Flow
+
+Items fill columns first: `a c e | b d f`
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(columns: 2, flow: "vertical")[
+ + a
+ + b
+ + c
+ + d
+ + e
+ + f
+]
+// Layout:
+// a) d)
+// b) e)
+// c) f)
+```
+
+## Resuming Numbering
+
+```typst
+#import "@preview/taskize:0.2.8": tasks, tasks-reset
+
+#tasks[
+ + First
+ + Second
+ + Third
+]
+
+Some text between task blocks...
+
+// Continue numbering from where we left off
+#tasks(resume: true)[
+ + Fourth
+ + Fifth
+ + Sixth
+]
+
+// Reset counter to start fresh
+#tasks-reset()
+
+#tasks[
+ + Back to one
+ + Two again
+]
+```
+
+## Starting from a Specific Number
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(start: 5)[
+ + This is item 5
+ + This is item 6
+ + This is item 7
+]
+```
+
+## Global Configuration
+
+Set defaults for all tasks in your document:
+
+```typst
+#import "@preview/taskize:0.2.8": tasks, tasks-setup
+
+// Configure global defaults
+#tasks-setup(
+ columns: 3,
+ label-format: "1)",
+ column-gutter: 1.5em,
+ row-gutter: 0.8em,
+)
+
+// All subsequent tasks use these defaults
+#tasks[
+ + Item 1
+ + Item 2
+ + Item 3
+]
+
+// Override specific settings per-call
+#tasks(columns: 2, label: "a)")[
+ + Override A
+ + Override B
+]
+```
+
+## Advanced Features
+
+### Label Baseline Alignment
+
+Control the vertical alignment of labels relative to content:
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+// Center alignment (default)
+#tasks(label-baseline: "center")[
+ + Short item
+ + Multi-line item\
+ with more text
+]
+
+// Top alignment
+#tasks(label-baseline: "top")[
+ + Item aligned at top
+ + Another item
+]
+
+// Bottom alignment
+#tasks(label-baseline: "bottom")[
+ + Item aligned at bottom
+ + Another item
+]
+```
+
+### Bold Labels
+
+Make labels stand out by using bold weight:
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+// Bold labels for emphasis
+#tasks(label-weight: "bold")[
+ + Important task
+ + Critical item
+ + Key point
+]
+
+// Regular labels (default)
+#tasks(label-weight: "regular")[
+ + Normal item
+ + Standard task
+]
+```
+
+### Column Spanning
+
+Items can span multiple columns using the `+()` or `+(N)` syntax (no space after `+`). This is similar to LaTeX's `\task*` command:
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+#tasks(columns: 3)[
+ + Short
+ + Short
+ + Short
+ +(2) This item spans 2 columns
+ + Short
+ +() This item spans all columns (full width)
+ + Normal
+ + Normal
+]
+
+// Practical example: Multiple choice with explanation
+#tasks(columns: 2, label: "A)")[
+ + Paris
+ + London
+ + Berlin
+ + Madrid
+ +() The capital of France is Paris, known for the Eiffel Tower.
+]
+```
+
+**Syntax:**
+- `+ Content` - Normal item (spans 1 column)
+- `+(2) Content` - Spans 2 columns (no space after +)
+- `+(3) Content` - Spans 3 columns
+- `+() Content` - Spans all columns (full width)
+
+**Notes:**
+- **Important:** No space between `+` and `()` or `(N)`
+- If an item with span doesn't fit in the current row, it wraps to the next row
+- Column spans are clamped to the total number of columns
+- Great for adding explanations, headings, or long content within lists
+
+## Parameters Reference
+
+### `tasks` Function
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `columns` | int/string | 2 | Number of columns, or `"auto-fit"` to choose the largest no-wrap count |
+| `label` | string/function | `"a)"` | Label format (shorthand for `label-format`) |
+| `label-format` | string/function | `"a)"` | Label format |
+| `start` | int | 1 | Starting number for labels |
+| `resume` | bool | false | Continue numbering from previous block |
+| `column-gutter` | length | 1em | Space between columns |
+| `row-gutter` | length/string | 0.6em | Space between rows. `"auto"` (or `"adaptive"`) sizes rows to the true ink of their content so tall inline math (display fractions, matrices) no longer bleeds into the gutter |
+| `max-columns` | int/auto | auto | Maximum column count tested by `columns: "auto-fit"`; `auto` means up to the number of items |
+| `auto-fit-mode` | string | `"fill"` | `"fill"` measures spans at their rendered width; `"uniform"` requires every item to fit one ordinary column |
+| `auto-fit-tolerance` | length | 0.5pt | Measurement tolerance for auto-fit wrap detection |
+| `label-width` | auto/length | auto | Width reserved for labels |
+| `label-align` | alignment | right | Label alignment |
+| `label-baseline` | string/length | `"center"` | Label vertical alignment: `"center"`, `"top"`, `"bottom"`, or length |
+| `label-weight` | string | `"regular"` | Label font weight: `"regular"` or `"bold"` |
+| `indent-after-label` | length | 0.4em | Space between label and content |
+| `indent` | length | 0pt | Left indentation of entire block |
+| `above` | length | 0.5em | Space before block |
+| `below` | length | 0.5em | Space after block |
+| `flow` | string | `"horizontal"` | Flow direction: `"horizontal"` or `"vertical"` |
+
+### Label Format Options
+
+| Format | Example | Description |
+|--------|---------|-------------|
+| `"a)"` | a) b) c) | Lowercase letters with closing paren |
+| `"a."` | a. b. c. | Lowercase letters with period |
+| `"(a)"` | (a) (b) (c) | Lowercase letters in parentheses |
+| `"A)"` | A) B) C) | Uppercase letters with closing paren |
+| `"A."` | A. B. C. | Uppercase letters with period |
+| `"1)"` | 1) 2) 3) | Numbers with closing paren |
+| `"1."` | 1. 2. 3. | Numbers with period |
+| `"(1)"` | (1) (2) (3) | Numbers in parentheses |
+| `"i)"` | i) ii) iii) | Lowercase roman numerals |
+| `"I)"` | I) II) III) | Uppercase roman numerals |
+| `"*"` | bullet | Bullet points |
+| `"-"` | dash | En-dashes |
+| `none` | (none) | No labels |
+| function | custom | Custom function `n => content` |
+
+## Examples
+
+### Math Exercise Sheet
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+= Algebra Practice
+
+Simplify the following expressions:
+
+#tasks(columns: 2)[
+ + $x^2 + 2x + 1$
+ + $(a + b)^2$
+ + $3x - 5 + 2x + 8$
+ + $frac(x^2 - 4, x - 2)$
+]
+
+Solve for $x$:
+
+#tasks(columns: 2, label: "1)")[
+ + $2x + 5 = 13$
+ + $x^2 = 16$
+ + $3(x - 2) = 15$
+ + $frac(x, 4) = 7$
+]
+```
+
+### Multiple Choice Questions
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+*Question 1:* What is the capital of France?
+
+#tasks(columns: 2, label: "A)")[
+ + London
+ + Paris
+ + Berlin
+ + Madrid
+]
+
+*Question 2:* Which planet is closest to the Sun?
+
+#tasks(columns: 2, label: "A)")[
+ + Venus
+ + Mercury
+ + Mars
+ + Earth
+]
+```
+
+### Vocabulary List
+
+```typst
+#import "@preview/taskize:0.2.8": tasks
+
+= French Vocabulary
+
+#tasks(columns: 3, label: none)[
+ + *bonjour* - hello
+ + *merci* - thank you
+ + *oui* - yes
+ + *non* - no
+ + *s'il vous plait* - please
+ + *au revoir* - goodbye
+]
+```
+
+## When to Use taskize
+
+**taskize** is specifically designed for **horizontal columned layouts** where items flow left-to-right across multiple columns, perfect for exercises, multiple-choice questions, and compact lists. Choose taskize when you need:
+
+- **Horizontal flow layout** - Items arranged in rows across columns (a b | c d | e f)
+- **Compact exercise sheets** - Mathematical exercises, vocabulary lists, multiple-choice questions
+- **Flexible column control** - Easy 2, 3, 4+ column layouts with shorthand functions
+- **Resume numbering** - Continue numbering across multiple task blocks
+- **LaTeX tasks compatibility** - Familiar syntax for users migrating from LaTeX's tasks package
+
+### Comparison with itemize
+
+**[itemize](https://typst.app/universe/package/itemize)** is an excellent, feature-rich package for **vertical list styling** with advanced customization capabilities. Use itemize when you need:
+
+- **Vertical lists** with advanced styling (colors, custom markers, spacing)
+- **Nested lists** with sophisticated formatting
+- **Rich marker customization** (emojis, custom content, per-item markers)
+- **Advanced typography** control over list appearance
+- **Complex list hierarchies** with fine-grained control
+
+**In summary**: Use taskize for horizontal multi-column layouts (exercises, quizzes, compact lists), and use itemize for feature-rich vertical lists with advanced styling. They complement each other well for different layout needs.
+
+## License
+
+MIT License - see LICENSE file for details.
+
+## Changelog
+
+All notable changes to taskize are documented here.
+
+### [0.2.8] - 2026-07-17
+
+#### Added
+- **Wrap zone** - Reserve a rectangular zone at the top right of a `#tasks` call with the `wrap-zone` parameter (or the shared `taskize-wrap-zone` state) so task rows flow around overlay content placed there independently — a QR code, a stamp, a logo. Rows overlapping the zone render narrowed beside it; later rows return to full width once the zone's height is cleared. The shared state is the cross-package contract wrapper packages (e.g. exercise-bank) use to reserve space for content they draw themselves.
+- **Adaptive row heights** - `row-gutter: "auto"` (or `"adaptive"`) sizes rows to the true ink of their content, so tall inline math (display fractions, matrices) no longer bleeds into the row-gutter and the configured numeric gutter becomes the real visual gap. Rows with ordinary content keep exactly the same spacing as before. Can be set document-wide with `tasks-setup(row-gutter: "adaptive")`.
+
+### [0.2.7] - 2026-07-13
+
+#### Added
+- **Auto-fit columns** - `columns: "auto-fit"` selects the largest column count, bounded by `max-columns` (default: up to the number of items), that does not introduce a new item wrap or fixed-size content overflow. Useful for exercises mixing short choices, longer text, math, spans, and small figures.
+- **Auto-spanning in fill mode** - with `auto-fit-mode: "fill"` (default), a regular `+` item too wide for one column automatically spans the minimum number of columns it needs — no explicit `+(N)` required — so wide items share rows with densely packed short items. `auto-fit-mode: "uniform"` instead requires every item to fit one ordinary column and falls back to fewer columns when the widest item no longer fits.
+
+### [0.2.6] - 2026-07-02
+
+#### Added
+- **True baseline alignment** — with `label-baseline: "center"` (default), inline content (text, inline math, symbols) now shares the same paragraph line as the label via hanging-indent layout, so fractions and tall inline elements align their baselines with the label instead of sitting above it
+
+#### Fixed
+- `compiler` minimum lowered from `0.14.2` to `0.14.0` — patch releases add no new language features, so users on `0.14.0`/`0.14.1` were incorrectly excluded
+
+### [0.2.5] - 2026-01-27
+
+#### Added
+- Full manual (`docs/manual.typ` + `docs/manual.pdf`) with structured examples
+- Expanded gallery with styled configuration and resume numbering examples
+
+#### Fixed
+- **Indentation-independent parsing** - Tasks now render identically regardless of source code indentation
+ - All `+` items at any indentation level in the same `tasks[]` block are automatically flattened to the same level
+ - Inconsistent indentation no longer creates nested enums with different numbering
+ - Moving one character in the source no longer breaks the layout
+- **Column spanning support** - Fixed `+(2)` and `+()` syntax to work correctly with the new parser
+ - Handles both `enum.item` nodes and text nodes starting with "+"
+- Multiline tasks now capture unindented continuation lines as part of the previous item
+
+#### Changed
+- Internal parser completely rewritten to recursively extract and flatten all enum items
+- Parent items with nested enums (from indentation) have enum nodes stripped from their content
+- README gallery layout refreshed and feature list expanded
+- Documentation updated for the new manual and examples
+
+### [0.2.0] - 2026-01-15
+
+#### Fixed
+- **Indentation-independent parsing** - Tasks now render identically regardless of source code indentation
+ - All `+` items at any indentation level in the same `tasks[]` block are automatically flattened to the same level
+ - Inconsistent indentation no longer creates nested enums with different numbering
+ - Moving one character in the source no longer breaks the layout
+- **Column spanning support** - Fixed `+(2)` and `+()` syntax to work correctly with the new parser
+ - Handles both `enum.item` nodes and text nodes starting with "+"
+
+#### Changed
+- Internal parser completely rewritten to recursively extract and flatten all enum items
+- Parent items with nested enums (from indentation) have enum nodes stripped from their content
+
+#### Added
+- **Column spanning** - Items can span multiple columns using `(N)` syntax (e.g., `+ (2) Content` or `+ () Full width`)
+- `label-baseline` parameter for controlling vertical alignment of labels (`"center"`, `"top"`, `"bottom"`, or custom length)
+- `label-weight` parameter for bold labels (`"regular"` or `"bold"`)
+- Advanced Features section in documentation with examples
+- Gallery examples for column spanning and bold labels
+
+#### Changed
+- Repository URL updated to GitLab: `https://gitlab.com/nathan-ed/typst-package-taskize`
+- Description enhanced to mention "Horizontal and vertical" flow directions
+
+### [0.1.0] - 2026-01-14
+
+#### Added
+- Initial release with horizontal columned list layout
+- Flexible column counts (2, 3, 4, or custom)
+- Multiple label formats (alphabetic, numeric, roman, bullets, custom)
+- Resume numbering across multiple task blocks
+- Two flow directions: horizontal (a b | c d) and vertical (a c | b d)
+- Global configuration system with `tasks-setup()`
+- Shorthand functions `tasks2`, `tasks3`, `tasks4`
+- Comprehensive parameter customization
+- Full documentation with examples and parameter reference
diff --git a/packages/preview/taskize/0.2.8/gallery/adaptive.svg b/packages/preview/taskize/0.2.8/gallery/adaptive.svg
new file mode 100644
index 0000000000..fdc305081a
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/adaptive.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/adaptive.typ b/packages/preview/taskize/0.2.8/gallery/adaptive.typ
new file mode 100644
index 0000000000..7a0589e978
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/adaptive.typ
@@ -0,0 +1,22 @@
+// Adaptive row heights: row-gutter: "auto" keeps tall math out of the gutter.
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 11cm, height: auto, margin: 6mm)
+#set text(size: 11pt)
+#show math.frac: it => math.display(it)
+
+*Fixed `row-gutter` — fraction ink bleeds into the gutter:*
+#tasks(columns: 2)[
+ + $1/2 + 1/3$
+ + $3/4 - 1/8$
+ + $5/6 dot 2/5$
+ + $7/8 : 1/2$
+]
+
+*`row-gutter: "auto"` — rows sized to true ink, same gutter honored:*
+#tasks(columns: 2, row-gutter: "auto")[
+ + $1/2 + 1/3$
+ + $3/4 - 1/8$
+ + $5/6 dot 2/5$
+ + $7/8 : 1/2$
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/basic.svg b/packages/preview/taskize/0.2.8/gallery/basic.svg
new file mode 100644
index 0000000000..59ada8cb34
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/basic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/basic.typ b/packages/preview/taskize/0.2.8/gallery/basic.typ
new file mode 100644
index 0000000000..bc757e09eb
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/basic.typ
@@ -0,0 +1,12 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Basic Tasks
+
+#tasks[
+ + First item
+ + Second item
+ + Third item
+ + Fourth item
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/bold.svg b/packages/preview/taskize/0.2.8/gallery/bold.svg
new file mode 100644
index 0000000000..db6960829b
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/bold.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/bold.typ b/packages/preview/taskize/0.2.8/gallery/bold.typ
new file mode 100644
index 0000000000..fa23ddf0c0
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/bold.typ
@@ -0,0 +1,40 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Bold Labels
+
+Regular weight labels (default):
+
+#tasks[
+ + First item
+ + Second item
+ + Third item
+ + Fourth item
+]
+
+Bold weight labels for emphasis:
+
+#tasks(label-weight: "bold")[
+ + Important task
+ + Critical item
+ + Key point
+ + Must-do action
+]
+
+Bold labels with different formats:
+
+#tasks(label: "1)", label-weight: "bold")[
+ + Complete assignment
+ + Review notes
+ + Submit project
+]
+
+#tasks(columns: 3, label: "A)", label-weight: "bold")[
+ + Option A
+ + Option B
+ + Option C
+ + Option D
+ + Option E
+ + Option F
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/labels.svg b/packages/preview/taskize/0.2.8/gallery/labels.svg
new file mode 100644
index 0000000000..e754a20d22
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/labels.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/labels.typ b/packages/preview/taskize/0.2.8/gallery/labels.typ
new file mode 100644
index 0000000000..ebdb27b1c2
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/labels.typ
@@ -0,0 +1,23 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Label Formats
+
+Lowercase letters:
+#tasks(columns: 3, label: "a)")[+ One + Two + Three]
+
+Uppercase letters:
+#tasks(columns: 3, label: "A)")[+ One + Two + Three]
+
+Numbers:
+#tasks(columns: 3, label: "1)")[+ One + Two + Three]
+
+Roman numerals:
+#tasks(columns: 3, label: "i)")[+ One + Two + Three]
+
+Bullets:
+#tasks(columns: 3, label: "*")[+ One + Two + Three]
+
+Custom:
+#tasks(columns: 3, label: n => "Q" + str(n) + ":")[+ One + Two + Three]
diff --git a/packages/preview/taskize/0.2.8/gallery/math.svg b/packages/preview/taskize/0.2.8/gallery/math.svg
new file mode 100644
index 0000000000..9750b6a03e
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/math.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/math.typ b/packages/preview/taskize/0.2.8/gallery/math.typ
new file mode 100644
index 0000000000..0ce94b89ec
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/math.typ
@@ -0,0 +1,23 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Math Exercises
+
+Simplify:
+
+#tasks(columns: 2)[
+ + $x^2 + 2x + 1$
+ + $(a + b)^2$
+ + $3x - 5 + 2x$
+ + $frac(x^2 - 4, x - 2)$
+]
+
+Solve for $x$:
+
+#tasks(columns: 2, label: "1)")[
+ + $2x + 5 = 13$
+ + $x^2 = 16$
+ + $3(x - 2) = 15$
+ + $frac(x, 4) = 7$
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/resume.svg b/packages/preview/taskize/0.2.8/gallery/resume.svg
new file mode 100644
index 0000000000..175ae6e562
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/resume.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/resume.typ b/packages/preview/taskize/0.2.8/gallery/resume.typ
new file mode 100644
index 0000000000..15f44dd726
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/resume.typ
@@ -0,0 +1,29 @@
+#import "@preview/taskize:0.2.8": tasks, tasks-reset
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Resume Numbering
+
+First block:
+#tasks[
+ + First
+ + Second
+ + Third
+]
+
+Some text between blocks...
+
+Second block (resumed):
+#tasks(resume: true)[
+ + Fourth
+ + Fifth
+ + Sixth
+]
+
+After reset:
+#tasks-reset()
+
+#tasks[
+ + Back to one
+ + Two again
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/span.svg b/packages/preview/taskize/0.2.8/gallery/span.svg
new file mode 100644
index 0000000000..3056c2b137
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/span.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/span.typ b/packages/preview/taskize/0.2.8/gallery/span.typ
new file mode 100644
index 0000000000..a71514bffc
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/span.typ
@@ -0,0 +1,40 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 14cm, height: auto, margin: 1cm)
+
+= Column Spanning
+
+Basic spanning in 3 columns:
+
+#tasks(columns: 3)[
+ + Item A
+ + Item B
+ + Item C
+ +(2) This spans 2 columns
+ + Item F
+ +() This item spans all 3 columns
+ + Item G
+ + Item H
+]
+
+Multiple choice with explanation:
+
+#tasks(columns: 2, label: "A)")[
+ + Paris
+ + London
+ + Berlin
+ + Rome
+ +() *Explanation:* Paris is the capital and largest city of France, known for the Eiffel Tower and as a global center of art and culture.
+]
+
+Exercise with full-width notes:
+
+#tasks(columns: 3, label: "1)")[
+ + $x + 2 = 5$
+ + $3x = 12$
+ + $2x - 1 = 7$
+ +() *Hint:* Isolate the variable on one side of the equation
+ + $x^2 = 16$
+ + $5x + 3 = 18$
+ + $4x - 2 = 10$
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/styled.svg b/packages/preview/taskize/0.2.8/gallery/styled.svg
new file mode 100644
index 0000000000..e633571f8c
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/styled.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/styled.typ b/packages/preview/taskize/0.2.8/gallery/styled.typ
new file mode 100644
index 0000000000..043a327e06
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/styled.typ
@@ -0,0 +1,47 @@
+#import "@preview/taskize:0.2.8": tasks, tasks-setup
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Styled Tasks
+
+#tasks-setup(
+ columns: 3,
+ label-format: "1)",
+ column-gutter: 1.5em,
+ row-gutter: 0.8em,
+ label-weight: "bold",
+ label-baseline: "center",
+)
+
+== Global Configuration
+
+#tasks[
+ + Item 1
+ + Item 2
+ + Item 3
+ + Item 4
+ + Item 5
+ + Item 6
+]
+
+== Four Columns
+
+#tasks(columns: 4, label-weight: "regular")[
+ + A
+ + B
+ + C
+ + D
+ + E
+ + F
+ + G
+ + H
+]
+
+== With Indentation
+
+#tasks(indent: 2em, columns: 2, label-weight: "bold")[
+ + Indented item
+ + Another one
+ + Third item
+ + Fourth item
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/vertical.svg b/packages/preview/taskize/0.2.8/gallery/vertical.svg
new file mode 100644
index 0000000000..966cb1c8eb
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/vertical.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/vertical.typ b/packages/preview/taskize/0.2.8/gallery/vertical.typ
new file mode 100644
index 0000000000..b28046a63d
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/vertical.typ
@@ -0,0 +1,29 @@
+#import "@preview/taskize:0.2.8": tasks
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Flow Directions
+
+== Horizontal Flow (default)
+Items fill rows first: a b | c d | e f
+
+#tasks(columns: 2, flow: "horizontal")[
+ + a
+ + b
+ + c
+ + d
+ + e
+ + f
+]
+
+== Vertical Flow
+Items fill columns first: a c e | b d f
+
+#tasks(columns: 2, flow: "vertical")[
+ + a
+ + b
+ + c
+ + d
+ + e
+ + f
+]
diff --git a/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg b/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg
new file mode 100644
index 0000000000..590d5c0d23
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ
new file mode 100644
index 0000000000..49837e4084
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ
@@ -0,0 +1,27 @@
+// Wrap zone: reserve a top-right corner for overlay content (e.g. a QR code)
+// and flow task rows around it.
+#import "@preview/taskize:0.2.8": tasks, taskize-wrap-zone
+
+#set page(width: 12cm, height: auto, margin: 1cm)
+
+= Exercise Sheet
+
+#taskize-wrap-zone.update((width: 2.4cm, height: 2.4cm))
+#place(top + right, box(
+ width: 2.4cm,
+ height: 2.4cm,
+ stroke: 0.5pt + luma(60%),
+ radius: 2pt,
+ align(center + horizon)[#text(size: 8pt, fill: luma(60%))[QR code]],
+))
+
+#tasks(columns: 2)[
+ + $2 + 3 = ?$
+ + $5 - 1 = ?$
+ + $4 times 2 = ?$
+ + $9 div 3 = ?$
+ + $7 + 6 = ?$
+ + $12 - 4 = ?$
+ + $3 times 5 = ?$
+ + $10 div 2 = ?$
+]
diff --git a/packages/preview/taskize/0.2.8/lib.typ b/packages/preview/taskize/0.2.8/lib.typ
new file mode 100644
index 0000000000..45bf3a605a
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/lib.typ
@@ -0,0 +1,1033 @@
+// taskize - Horizontal columned lists for Typst
+// Similar to LaTeX's tasks package
+// Items flow horizontally (left-to-right) across columns
+
+// =============================================================================
+// Configuration State
+// =============================================================================
+
+#let tasks-config = state("tasks-config", (
+ columns: 2,
+ label-format: "a)", // "a)", "1)", "i)", "(a)", "(1)", custom function
+ column-gutter: 1em,
+ row-gutter: 0.6em,
+ max-columns: auto, // auto = up to number of items for auto-fit
+ auto-fit-mode: "fill", // "fill" = spans may use their span; "uniform" = every item must fit one column
+ auto-fit-tolerance: 0.5pt, // measurement tolerance for wrap detection
+ label-width: auto, // auto or fixed width
+ label-align: right,
+ label-baseline: "center", // "center", "top", "bottom", or length/auto
+ label-weight: "regular", // "regular" or "bold"
+ indent-after-label: 0.4em,
+ indent: 0pt, // left indentation of entire block
+ above: 0.5em, // space before block
+ below: 0.5em, // space after block
+ flow: "horizontal", // "horizontal" (a b | c d) or "vertical" (a c | b d)
+))
+
+// Global counter for resuming
+#let tasks-counter = counter("tasks-counter")
+
+// Rectangular zone (dict (width:, height:), absolute lengths) reserved at the
+// top right of the next #tasks call: its first rows render beside the zone at
+// reduced width, the remaining rows below at full width. Meant for wrapper
+// packages that overlay content there (e.g. exercise-bank's QR codes) — they
+// set this state before the tasks body and clear it after. The state key is
+// the cross-package contract: any package may set state("taskize-wrap-zone").
+#let taskize-wrap-zone = state("taskize-wrap-zone", none)
+
+// =============================================================================
+// Configuration Function
+// =============================================================================
+
+#let tasks-setup(
+ columns: none,
+ label-format: none,
+ column-gutter: none,
+ row-gutter: none,
+ max-columns: none,
+ auto-fit-mode: none,
+ auto-fit-tolerance: none,
+ label-width: none,
+ label-align: none,
+ label-baseline: none,
+ label-weight: none,
+ indent-after-label: none,
+ indent: none,
+ above: none,
+ below: none,
+ flow: none,
+) = {
+ tasks-config.update(cfg => {
+ let new = cfg
+ if columns != none { new.columns = columns }
+ if label-format != none { new.label-format = label-format }
+ if column-gutter != none { new.column-gutter = column-gutter }
+ if row-gutter != none { new.row-gutter = row-gutter }
+ if max-columns != none { new.max-columns = max-columns }
+ if auto-fit-mode != none { new.auto-fit-mode = auto-fit-mode }
+ if auto-fit-tolerance != none { new.auto-fit-tolerance = auto-fit-tolerance }
+ if label-width != none { new.label-width = label-width }
+ if label-align != none { new.label-align = label-align }
+ if label-baseline != none { new.label-baseline = label-baseline }
+ if label-weight != none { new.label-weight = label-weight }
+ if indent-after-label != none { new.indent-after-label = indent-after-label }
+ if indent != none { new.indent = indent }
+ if above != none { new.above = above }
+ if below != none { new.below = below }
+ if flow != none { new.flow = flow }
+ new
+ })
+}
+
+// =============================================================================
+// Column Span Parsing
+// =============================================================================
+
+// Parse column span syntax from item content
+// Returns (span, content) tuple
+// Syntax: () for all columns, (N) for N columns, otherwise 1 column
+#let parse-span(item-content, max-cols) = {
+ // Try to extract text representation from content
+ let text-str = none
+
+ // Handle different content types
+ if type(item-content) == str {
+ text-str = item-content
+ } else if type(item-content) == content {
+ // Try to get the text representation
+ if item-content.has("text") {
+ text-str = item-content.text
+ } else if item-content.has("body") and type(item-content.body) == str {
+ text-str = item-content.body
+ } else if item-content.has("children") {
+ // Extract text from first child if it's text
+ let children = item-content.children
+ if children.len() > 0 {
+ let first = children.at(0)
+ if type(first) == str {
+ text-str = first
+ } else if type(first) == content and first.has("text") {
+ text-str = first.text
+ }
+ }
+ }
+ }
+
+ // If we got text, check for span patterns
+ if text-str != none and type(text-str) == str {
+ // Remove leading + if present (from +(N) syntax)
+ let clean-text = if text-str.starts-with("+") {
+ text-str.slice(1)
+ } else {
+ text-str
+ }
+
+ // Check for () pattern - span all columns
+ if clean-text.starts-with("()") {
+ let remaining = clean-text.slice(2)
+ // Remove leading space if present
+ if remaining.len() > 0 and remaining.starts-with(" ") {
+ remaining = remaining.slice(1)
+ }
+ return (max-cols, remaining)
+ }
+
+ // Check for (N) pattern - span N columns
+ if clean-text.starts-with("(") {
+ let close-paren = clean-text.position(")")
+ if close-paren != none and close-paren > 1 {
+ let num-str = clean-text.slice(1, close-paren)
+ // Check if num-str contains only digits
+ if num-str.len() > 0 and num-str.match(regex("^\d+$")) != none {
+ let parsed = int(num-str)
+ let remaining = clean-text.slice(close-paren + 1)
+ // Remove leading space if present
+ if remaining.len() > 0 and remaining.starts-with(" ") {
+ remaining = remaining.slice(1)
+ }
+ return (calc.min(calc.max(parsed, 1), max-cols), remaining)
+ }
+ }
+ }
+ }
+
+ // Default: no span specified
+ return (1, item-content)
+}
+
+// =============================================================================
+// Label Formatting
+// =============================================================================
+
+// Convert number to label based on format
+#let format-label(n, format) = {
+ if type(format) == function {
+ format(n)
+ } else if format == "a)" {
+ numbering("a)", n)
+ } else if format == "a." {
+ numbering("a.", n)
+ } else if format == "(a)" {
+ numbering("(a)", n)
+ } else if format == "1)" {
+ numbering("1)", n)
+ } else if format == "1." {
+ numbering("1.", n)
+ } else if format == "(1)" {
+ numbering("(1)", n)
+ } else if format == "i)" {
+ numbering("i)", n)
+ } else if format == "i." {
+ numbering("i.", n)
+ } else if format == "(i)" {
+ numbering("(i)", n)
+ } else if format == "I)" {
+ numbering("I)", n)
+ } else if format == "A)" {
+ numbering("A)", n)
+ } else if format == "A." {
+ numbering("A.", n)
+ } else if format == "*" {
+ // Bullet points
+ sym.bullet
+ } else if format == "-" {
+ // Dash
+ "–"
+ } else if format == "none" or format == none {
+ // No label
+ ""
+ } else {
+ // Try as numbering pattern
+ numbering(format, n)
+ }
+}
+
+// =============================================================================
+// Adaptive row heights ("auto" row-gutter)
+// =============================================================================
+
+// Rows containing tall inline math (display fractions, matrices, ...) report
+// a height based on the font's cap-height/baseline edges, not the actual ink,
+// so the ink bleeds into the row-gutter. This rule measures each inline
+// equation with bounds text edges and switches only the tall ones over, so
+// row heights become truthful while ordinary rows keep their exact leading.
+// (Same approach as the breather package, inlined to avoid a dependency.)
+#let _breathe-rule(body) = {
+ show math.equation.where(block: false): it => context {
+ let bounded = {
+ set text(top-edge: "bounds", bottom-edge: "bounds")
+ it
+ }
+ if measure(bounded).height > 1.1em.to-absolute() { bounded } else { it }
+ }
+ body
+}
+
+#let _is-adaptive-row-gutter(value) = value == "auto" or value == "adaptive"
+
+// =============================================================================
+// Content Classification
+// =============================================================================
+
+// Returns true if the content can be laid out inline (in a paragraph line).
+// Used to decide whether the label can share the first line with the content,
+// which gives true baseline alignment (fractions, vectors, etc. no longer
+// push the content below the label's baseline).
+// Conservative: anything unknown is treated as block-level, which falls back
+// to the previous top-aligned rendering.
+#let is-inline-content(node) = {
+ if type(node) != content {
+ return type(node) in (str, int, float, symbol)
+ }
+ let f = node.func()
+ if f == math.equation {
+ return not node.block
+ }
+ let fname = repr(f)
+ if fname in (
+ "text", "space", "linebreak", "h",
+ "strong", "emph", "sub", "super", "underline", "overline", "strike",
+ "highlight", "smartquote", "link", "ref",
+ ) {
+ return true
+ }
+ if fname == "sequence" {
+ return node.children.all(is-inline-content)
+ }
+ if fname == "styled" {
+ return is-inline-content(node.child)
+ }
+ return false
+}
+
+// =============================================================================
+// Internal: Render tasks grid
+// =============================================================================
+
+#let _is-auto-fit-columns(cols) = {
+ cols == "auto-fit" or cols == "fit"
+}
+
+#let _resolve-length(value) = {
+ if type(value) in (length, relative) {
+ measure(box(width: value)).width
+ } else {
+ value
+ }
+}
+
+#let _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num) = {
+ if lbl-width == auto {
+ let max-width = 0pt
+ for i in range(task-items.len()) {
+ let label = format-label(start-num + i, fmt)
+ let label-size = measure(text(weight: lbl-weight)[#label]).width
+ if label-size > max-width { max-width = label-size }
+ }
+ max-width + indent-after
+ } else {
+ lbl-width
+ }
+}
+
+#let _task-content-width(total-width, cols, span, lbl-width, col-gut, indent-after, indent) = {
+ let cols = calc.max(1, cols)
+ let span = calc.min(calc.max(1, span), cols)
+ let label-width = _resolve-length(lbl-width)
+ let column-gutter = _resolve-length(col-gut)
+ let label-gutter = _resolve-length(indent-after)
+ let block-indent = _resolve-length(indent)
+ let inner-width = total-width - block-indent
+ let fixed-width = cols * label-width + cols * label-gutter + (cols - 1) * column-gutter
+ let content-track = (inner-width - fixed-width) / cols
+ if content-track < 0pt { content-track = 0pt }
+ content-track * span + (span - 1) * (label-width + label-gutter + column-gutter)
+}
+
+#let _task-height-at-width(content, width) = {
+ measure(block(width: width, content)).height
+}
+
+#let _select-auto-fit-columns(
+ task-items,
+ max-cols,
+ fmt,
+ col-gut,
+ lbl-width,
+ lbl-weight,
+ indent-after,
+ indent,
+ start-num,
+ mode,
+ tolerance,
+ available-width,
+) = {
+ if task-items.len() == 0 { return 1 }
+
+ let max-cols = if max-cols == auto { task-items.len() } else { max-cols }
+ max-cols = calc.max(1, calc.min(max-cols, task-items.len()))
+ let label-width = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num)
+ let tol = _resolve-length(tolerance)
+
+ let base-heights = ()
+ for item-data in task-items {
+ let content = item-data.at(0)
+ let width = _task-content-width(available-width, 1, 1, label-width, col-gut, indent-after, indent)
+ base-heights.push(_task-height-at-width(content, width))
+ }
+
+ let fits-cols(cols) = {
+ for (i, item-data) in task-items.enumerate() {
+ let content = item-data.at(0)
+ let explicit-span = item-data.at(1)
+ if mode == "uniform" or explicit-span > 1 {
+ // uniform: every item pinned to one column
+ // explicit span items: use their declared span, capped at cols
+ let span = if mode == "uniform" { 1 } else { calc.min(explicit-span, cols) }
+ let width = _task-content-width(available-width, cols, span, label-width, col-gut, indent-after, indent)
+ if measure(content).width > width + tol or _task-height-at-width(content, width) > base-heights.at(i) + tol {
+ return false
+ }
+ } else {
+ // fill mode, regular item: auto-span until height matches base.
+ // Only height is checked — text always reflows to fit any width, so
+ // natural-width would reject long sentences even at full-row span.
+ let fitted = false
+ for s in range(1, cols + 1) {
+ let width = _task-content-width(available-width, cols, s, label-width, col-gut, indent-after, indent)
+ if _task-height-at-width(content, width) <= base-heights.at(i) + tol {
+ fitted = true
+ break
+ }
+ }
+ if not fitted { return false }
+ }
+ }
+ true
+ }
+
+ let best = 1
+ for cols in range(1, max-cols + 1) {
+ if fits-cols(cols) {
+ best = cols
+ } else {
+ break
+ }
+ }
+ best
+}
+
+#let render-tasks-grid(
+ task-items,
+ cols,
+ fmt,
+ col-gut,
+ row-gut,
+ lbl-width,
+ lbl-align,
+ lbl-baseline,
+ lbl-weight,
+ indent-after,
+ indent,
+ above-spacing,
+ below-spacing,
+ flow-dir,
+ start-num,
+ adaptive-rows: false,
+) = {
+ let num-items = task-items.len()
+ let wrap-content = if adaptive-rows { _breathe-rule } else { c => c }
+
+ // Calculate label width if auto
+ let actual-label-width = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num)
+
+ // Build grid columns (label + content pairs)
+ let grid-columns = ()
+ for _ in range(cols) {
+ grid-columns.push(actual-label-width)
+ grid-columns.push(1fr)
+ }
+
+ // Helper to create label cell
+ let make-label-cell(label) = {
+ let label-height = measure(text(weight: lbl-weight)[#label]).height
+ let text-height = measure[A].height
+ let baseline-offset = if lbl-baseline == "center" {
+ (label-height - text-height) * 0.5
+ } else if lbl-baseline == "top" {
+ 0pt
+ } else if lbl-baseline == "bottom" {
+ label-height - text-height
+ } else if type(lbl-baseline) in (length, relative) {
+ lbl-baseline
+ } else {
+ 0pt
+ }
+ align(lbl-align + top)[#v(baseline-offset)#text(weight: lbl-weight)[#label]]
+ }
+
+ // Helper to create content cell
+ let make-content-cell(content) = {
+ align(left + top)[
+ #show math.vec: it => box(baseline: 30%, it)
+ #wrap-content(content)
+ ]
+ }
+
+ // Baseline-aligned rendering: label and content share the same paragraph
+ // line, so their baselines match exactly even when the content starts with
+ // a tall element (display fraction, vector, ...). Wrapped lines are pushed
+ // past the label column with a hanging indent.
+ // Only used for inline content with the default label-baseline ("center");
+ // block content and explicit baseline offsets keep the grid-cell rendering.
+ let use-inline-baseline = lbl-baseline == "center"
+
+ let make-inline-cell(label, content, span) = {
+ let lbl = text(weight: lbl-weight)[#label]
+ let halign = if type(lbl-align) == alignment and lbl-align.x != none {
+ lbl-align.x
+ } else {
+ right
+ }
+ let label-box = box(width: actual-label-width, {
+ if halign == left { lbl; h(1fr) }
+ else if halign == center { h(1fr); lbl; h(1fr) }
+ else { h(1fr); lbl }
+ })
+ // Explicit par(): bare content in a grid cell is not a paragraph element,
+ // so a `set par(hanging-indent: ..)` rule would not apply to it.
+ grid.cell(colspan: span * 2, align(left + top, par(
+ hanging-indent: actual-label-width + indent-after,
+ [#label-box#h(indent-after)#wrap-content(content)],
+ )))
+ }
+
+ // Build the cell(s) for one item: a single baseline-aligned cell when
+ // possible, otherwise the classic label cell + top-aligned content cell.
+ let make-item-cells(label, content, span) = {
+ if use-inline-baseline and is-inline-content(content) {
+ (make-inline-cell(label, content, span),)
+ } else if span == 1 {
+ (make-label-cell(label), make-content-cell(content))
+ } else {
+ (
+ make-label-cell(label),
+ grid.cell(colspan: span * 2 - 1, make-content-cell(content)),
+ )
+ }
+ }
+
+ // Build grid content with column spans
+ let grid-content = ()
+
+ if flow-dir == "vertical" {
+ // VERTICAL FLOW: Fill columns first (like v0.1.0), with column spanning support
+ // For vertical flow without column spans, we use the classic formula:
+ // For item i: col = i / num_rows, row = i % num_rows
+ // With column spans, items are placed sequentially in available positions
+
+ // First pass: check if any items have spans
+ let has-spans = false
+ for item-data in task-items {
+ if calc.min(item-data.at(1), cols) > 1 {
+ has-spans = true
+ break
+ }
+ }
+
+ if not has-spans {
+ // Simple case: no column spans, use the classic v0.1.0 algorithm
+ let num-rows = calc.ceil(num-items / cols)
+
+ for row in range(num-rows) {
+ for col in range(cols) {
+ // Calculate item index using vertical flow formula
+ let item-idx = col * num-rows + row
+
+ if item-idx < num-items {
+ let item-data = task-items.at(item-idx)
+ let item-content = item-data.at(0)
+ let num = start-num + item-idx
+ let label = format-label(num, fmt)
+
+ grid-content += make-item-cells(label, item-content, 1)
+ } else {
+ // No more items, fill with empty cells
+ grid-content.push([])
+ grid-content.push([])
+ }
+ }
+ }
+ } else {
+ // Complex case: has column spans
+ // Build a 2D grid to track occupied positions
+ // Estimate initial number of rows (may grow)
+ let num-rows = calc.ceil(num-items / cols)
+ let grid-map = () // Array of arrays: grid-map.at(row).at(col) = item-idx or none or "occupied"
+
+ // Initialize grid map
+ for r in range(num-rows + 10) { // Add extra rows in case spans push items down
+ let row-arr = ()
+ for c in range(cols) {
+ row-arr.push(none)
+ }
+ grid-map.push(row-arr)
+ }
+
+ // Place items in column-major order, respecting spans
+ // Track the current height (row) of each column
+ let col-heights = ()
+ for _ in range(cols) {
+ col-heights.push(0)
+ }
+
+ for item-idx in range(num-items) {
+ let item-data = task-items.at(item-idx)
+ let span = calc.min(item-data.at(1), cols)
+
+ // Find the column with minimum height that can accommodate this span
+ let placed = false
+ let best-col = 0
+ let best-row = 0
+
+ // Try each starting column position
+ for start-col in range(cols) {
+ if start-col + span > cols {
+ // Span doesn't fit starting from this column
+ continue
+ }
+
+ // Check the maximum height among spanned columns
+ let max-height = 0
+ for s in range(span) {
+ if col-heights.at(start-col + s) > max-height {
+ max-height = col-heights.at(start-col + s)
+ }
+ }
+
+ // This is a valid position
+ if not placed or max-height < best-row {
+ best-col = start-col
+ best-row = max-height
+ placed = true
+ }
+ }
+
+ if placed {
+ // Place item at (best-row, best-col)
+ grid-map.at(best-row).at(best-col) = item-idx
+ // Mark spanned positions as occupied
+ for s in range(1, span) {
+ grid-map.at(best-row).at(best-col + s) = "occupied"
+ }
+ // Update column heights for all spanned columns
+ for s in range(span) {
+ col-heights.at(best-col + s) = best-row + 1
+ }
+ }
+ }
+
+ // Find actual number of rows used
+ let actual-rows = 0
+ for r in range(grid-map.len()) {
+ let row-has-content = false
+ for c in range(cols) {
+ if grid-map.at(r).at(c) != none {
+ row-has-content = true
+ break
+ }
+ }
+ if row-has-content {
+ actual-rows = r + 1
+ }
+ }
+
+ // Convert grid-map to grid-content
+ for row in range(actual-rows) {
+ let col = 0
+ while col < cols {
+ let cell-value = grid-map.at(row).at(col)
+ if cell-value == none {
+ // Empty cell
+ grid-content.push([])
+ grid-content.push([])
+ col += 1
+ } else if cell-value == "occupied" {
+ // Skip - this is part of a span from a previous column
+ // Don't add any cells, the colspan handles it
+ col += 1
+ } else {
+ // Item cell
+ let item-idx = cell-value
+ let item-data = task-items.at(item-idx)
+ let item-content = item-data.at(0)
+ let span = calc.min(item-data.at(1), cols)
+ let num = start-num + item-idx
+ let label = format-label(num, fmt)
+
+ grid-content += make-item-cells(label, item-content, span)
+ col += span // Skip the spanned columns
+ }
+ }
+ }
+ }
+ } else {
+ // HORIZONTAL FLOW: Original logic (fill rows first)
+ let current-col = 0
+ let current-row = 0
+
+ for i in range(num-items) {
+ let item-data = task-items.at(i)
+ let item-content = item-data.at(0)
+ let span = calc.min(item-data.at(1), cols)
+
+ // If current position would exceed columns, move to next row
+ if current-col + span > cols {
+ // Fill remaining columns in current row with empty cells
+ while current-col < cols {
+ grid-content.push([])
+ grid-content.push([])
+ current-col += 1
+ }
+ current-col = 0
+ current-row += 1
+ }
+
+ let num = start-num + i
+ let label = format-label(num, fmt)
+
+ grid-content += make-item-cells(label, item-content, span)
+
+ current-col += span
+ }
+
+ // Fill remaining cells in last row
+ while current-col < cols {
+ grid-content.push([])
+ grid-content.push([])
+ current-col += 1
+ }
+ }
+
+ // Update counter
+ tasks-counter.update(start-num + num-items - 1)
+
+ // Render
+ block(
+ width: 100%,
+ above: above-spacing,
+ below: below-spacing,
+ inset: (left: indent),
+ grid(
+ columns: grid-columns,
+ column-gutter: (indent-after, col-gut) * cols,
+ row-gutter: row-gut,
+ ..grid-content
+ )
+ )
+}
+
+// =============================================================================
+// Main Tasks Function - Clean syntax with enum
+// =============================================================================
+
+// Usage:
+// #tasks[
+// + $2 + 3$
+// + $5 - 2$
+// + $4 times 3$
+// ]
+//
+// Or with options:
+// #tasks(columns: 3, label: "1)")[
+// + First item
+// + Second item
+// ]
+//
+// Flow options:
+// flow: "horizontal" -> a b | c d | e f (default)
+// flow: "vertical" -> a c e | b d f
+
+#let tasks(
+ columns: auto,
+ label: auto, // Shorthand for label-format
+ label-format: auto,
+ start: 1,
+ resume: false,
+ column-gutter: auto,
+ row-gutter: auto,
+ max-columns: auto,
+ auto-fit-mode: auto,
+ auto-fit-tolerance: auto,
+ label-width: auto,
+ label-align: auto,
+ label-baseline: auto,
+ label-weight: auto,
+ indent-after-label: auto,
+ indent: auto,
+ above: auto,
+ below: auto,
+ flow: auto,
+ wrap-zone: auto, // auto = honor taskize-wrap-zone state; none = ignore it;
+ // (width:, height:) = explicit top-right zone to flow around
+ body,
+) = context {
+ let cfg = tasks-config.get()
+
+ // Apply parameters (use provided value or fall back to config)
+ let requested-cols = if columns == auto { cfg.columns } else { columns }
+ let fmt = if label != auto { label } else if label-format != auto { label-format } else { cfg.label-format }
+ let col-gut = if column-gutter == auto { cfg.column-gutter } else { column-gutter }
+ let row-gut = if row-gutter == auto { cfg.row-gutter } else { row-gutter }
+ // "auto"/"adaptive" row-gutter: rows are sized to their true ink (tall
+ // inline math no longer bleeds into the gutter) and the numeric gutter
+ // falls back to the configured default.
+ let adaptive-rows = _is-adaptive-row-gutter(row-gut)
+ if adaptive-rows {
+ row-gut = if _is-adaptive-row-gutter(cfg.row-gutter) { 0.6em } else { cfg.row-gutter }
+ }
+ let max-cols = if max-columns == auto { cfg.max-columns } else { max-columns }
+ let fit-mode = if auto-fit-mode == auto { cfg.auto-fit-mode } else { auto-fit-mode }
+ let fit-tolerance = if auto-fit-tolerance == auto { cfg.auto-fit-tolerance } else { auto-fit-tolerance }
+ let lbl-width = if label-width == auto { cfg.label-width } else { label-width }
+ let lbl-align = if label-align == auto { cfg.label-align } else { label-align }
+ let lbl-baseline = if label-baseline == auto { cfg.label-baseline } else { label-baseline }
+ let lbl-weight = if label-weight == auto { cfg.label-weight } else { label-weight }
+ let indent-after = if indent-after-label == auto { cfg.indent-after-label } else { indent-after-label }
+ let blk-indent = if indent == auto { cfg.indent } else { indent }
+ let above-spacing = if above == auto { cfg.above } else { above }
+ let below-spacing = if below == auto { cfg.below } else { below }
+ let flow-dir = if flow == auto { cfg.flow } else { flow }
+ let span-parse-cols = if _is-auto-fit-columns(requested-cols) {
+ if max-cols == auto { 512 } else { max-cols }
+ } else {
+ requested-cols
+ }
+
+ // Extract items from the body content - INDENTATION-INDEPENDENT PARSING
+ // Strategy: Flatten ALL enum.item nodes at any depth into a single list
+ // Nested enums from indentation differences are just more items
+ // Each item is stored as (content, span) tuple
+ let task-items = ()
+
+ // Merge extra content into the previous item (used for multiline tasks)
+ let merge-item-content(base, extra) = {
+ if extra == none { return base }
+ let base-content = if type(base) == content { base } else { [#base] }
+ let extra-content = if type(extra) == content { extra } else { [#extra] }
+ base-content + extra-content
+ }
+
+ let append-to-last-item(items, extra) = {
+ if extra == none or items.len() == 0 { return items }
+ let last = items.at(items.len() - 1)
+ let merged = merge-item-content(last.at(0), extra)
+ let new-items = items.slice(0, items.len() - 1)
+ new-items.push((merged, last.at(1)))
+ new-items
+ }
+
+ // Helper to remove ALL enum and enum.item nodes from content (they'll be extracted separately)
+ let strip-enums(node) = {
+ if type(node) != content {
+ return node
+ }
+
+ // If this IS an enum or enum.item, return empty
+ if node.func() == enum or node.func() == enum.item {
+ return none
+ }
+
+ // If no children, return as-is
+ if not node.has("children") {
+ return node
+ }
+
+ // Process children recursively
+ let new-children = ()
+ for child in node.children {
+ if type(child) == content {
+ if child.func() == enum or child.func() == enum.item {
+ // Skip enum and enum.item children entirely
+ continue
+ }
+ // Recursively clean
+ let cleaned = strip-enums(child)
+ if cleaned != none {
+ new-children.push(cleaned)
+ }
+ } else {
+ // Non-content (text, etc) - keep it
+ new-children.push(child)
+ }
+ }
+
+ // Return result
+ if new-children.len() == 0 {
+ return none
+ } else if new-children.len() == 1 {
+ return new-children.at(0)
+ } else {
+ // Join multiple children
+ return new-children.join()
+ }
+ }
+
+ // Recursively extract ALL enum.item nodes, flattening any nesting
+ let flatten-all-enum-items(node) = {
+ let items = ()
+
+ if type(node) != content {
+ return items
+ }
+
+ if node.func() == enum {
+ // Enum - extract all items from it
+ for child in node.children {
+ items = items + flatten-all-enum-items(child)
+ }
+ } else if node.func() == enum.item {
+ // Found an item!
+ // First check if body contains nested enums (from inconsistent indentation)
+ let nested = flatten-all-enum-items(node.body)
+
+ if nested.len() > 0 {
+ // Has nested items from inconsistent indentation
+ // Strip enum.item nodes from body before parsing
+ let cleaned-body = strip-enums(node.body)
+
+ // Parse span from cleaned body
+ let (span, content-parsed) = if cleaned-body != none {
+ parse-span(cleaned-body, span-parse-cols)
+ } else {
+ (1, [])
+ }
+
+ // Add parent item if it has content after cleaning
+ if content-parsed != [] {
+ items.push((content-parsed, span))
+ }
+
+ // Add all nested items at same level
+ items = items + nested
+ } else {
+ // No nesting - parse and add as-is
+ let (span, content-parsed) = parse-span(node.body, span-parse-cols)
+ items.push((content-parsed, span))
+ }
+ } else if node.func() == text and node.text.starts-with("+") {
+ // Handle +(N) and +() syntax which gets parsed as text (not enum.item)
+ // This happens when there's no space after +
+ let text-content = node.text.slice(1) // Remove the +
+ let (span, content) = parse-span(text-content, span-parse-cols)
+ items.push((content, span))
+ } else if node.has("children") {
+ // Other content - check children for enums and text nodes, and
+ // treat non-item content between items as continuation lines.
+ for child in node.children {
+ let child-items = flatten-all-enum-items(child)
+ if child-items.len() > 0 {
+ items = items + child-items
+ } else {
+ items = append-to-last-item(items, child)
+ }
+ }
+ }
+
+ items
+ }
+
+ // Extract and flatten all items
+ task-items = flatten-all-enum-items(body)
+
+ // Determine starting number
+ let start-num = if resume {
+ tasks-counter.get().first() + 1
+ } else {
+ start
+ }
+
+ // Top-right zone to flow around (from the shared state unless overridden)
+ let zone = if wrap-zone == auto { taskize-wrap-zone.get() } else { wrap-zone }
+
+ let render-split = (items, cols, start2, above2, below2) => render-tasks-grid(
+ items, cols, fmt, col-gut, row-gut,
+ lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after,
+ blk-indent, above2, below2,
+ flow-dir, start2,
+ adaptive-rows: adaptive-rows,
+ )
+
+ if task-items.len() > 0 and zone != none and flow-dir == "horizontal" {
+ // Flow around the reserved top-right zone: render whole rows in a
+ // narrowed box until the zone height is cleared, then the remaining
+ // rows at full width. Vertical flow keeps the plain layout (splitting
+ // it would reorder the numbering).
+ let zone-w = zone.width.to-absolute()
+ let zone-h = zone.height.to-absolute()
+ layout(size => {
+ let narrow = calc.max(size.width - zone-w, size.width * 0.35)
+ let cols = if _is-auto-fit-columns(requested-cols) {
+ _select-auto-fit-columns(
+ task-items, max-cols, fmt, col-gut, lbl-width, lbl-weight,
+ indent-after, blk-indent, start-num, fit-mode, fit-tolerance, narrow,
+ )
+ } else {
+ requested-cols
+ }
+ let n = task-items.len()
+ let measure-first(k) = measure(
+ box(width: narrow, render-split(task-items.slice(0, k), cols, start-num, 0pt, 0pt)),
+ ).height
+ let k = calc.min(cols, n)
+ while k < n and measure-first(k) < zone-h {
+ k += cols
+ }
+ k = calc.min(k, n)
+ let first-height = measure-first(k)
+ block(width: 100%, above: above-spacing, below: below-spacing, {
+ box(width: narrow, render-split(task-items.slice(0, k), cols, start-num, 0pt, 0pt))
+ if k < n {
+ v(row-gut)
+ render-split(task-items.slice(k, n), cols, start-num + k, 0pt, 0pt)
+ } else if first-height < zone-h {
+ // All items fit beside the zone: pad so following content clears it
+ v(zone-h - first-height)
+ }
+ })
+ })
+ // Consumed: a later #tasks call must not flow around the same zone
+ taskize-wrap-zone.update(none)
+ } else if task-items.len() > 0 {
+ if _is-auto-fit-columns(requested-cols) {
+ assert(fit-mode in ("fill", "uniform"), message: "auto-fit-mode must be \"fill\" or \"uniform\"")
+ layout(size => {
+ let cols = _select-auto-fit-columns(
+ task-items, max-cols, fmt, col-gut, lbl-width, lbl-weight,
+ indent-after, blk-indent, start-num, fit-mode, fit-tolerance, size.width,
+ )
+ // In fill mode, compute the minimum span each regular item needs to fit
+ // at the chosen column count, so wide items auto-span rather than forcing
+ // a lower column count.
+ let render-items = if fit-mode == "fill" {
+ let lw = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num)
+ let tol = _resolve-length(fit-tolerance)
+ task-items.map(item-data => {
+ let content = item-data.at(0)
+ let explicit-span = item-data.at(1)
+ if explicit-span > 1 { return item-data }
+ let base-h = _task-height-at-width(content,
+ _task-content-width(size.width, 1, 1, lw, col-gut, indent-after, blk-indent))
+ for s in range(1, cols + 1) {
+ let w = _task-content-width(size.width, cols, s, lw, col-gut, indent-after, blk-indent)
+ if _task-height-at-width(content, w) <= base-h + tol {
+ return (content, s)
+ }
+ }
+ (content, cols)
+ })
+ } else {
+ task-items
+ }
+ render-tasks-grid(
+ render-items, cols, fmt, col-gut, row-gut,
+ lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after,
+ blk-indent, above-spacing, below-spacing,
+ flow-dir, start-num,
+ adaptive-rows: adaptive-rows,
+ )
+ })
+ } else {
+ render-tasks-grid(
+ task-items, requested-cols, fmt, col-gut, row-gut,
+ lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after,
+ blk-indent, above-spacing, below-spacing,
+ flow-dir, start-num,
+ adaptive-rows: adaptive-rows,
+ )
+ }
+ }
+}
+
+// =============================================================================
+// Convenience Functions
+// =============================================================================
+
+// Reset the tasks counter
+#let tasks-reset() = {
+ tasks-counter.update(0)
+}
+
+// Shorthand for specific column counts
+#let tasks2(body, label: auto, start: 1, resume: false) = {
+ tasks(columns: 2, label: label, start: start, resume: resume, body)
+}
+
+#let tasks3(body, label: auto, start: 1, resume: false) = {
+ tasks(columns: 3, label: label, start: start, resume: resume, body)
+}
+
+#let tasks4(body, label: auto, start: 1, resume: false) = {
+ tasks(columns: 4, label: label, start: start, resume: resume, body)
+}
diff --git a/packages/preview/taskize/0.2.8/typst.toml b/packages/preview/taskize/0.2.8/typst.toml
new file mode 100644
index 0000000000..58e640bc48
--- /dev/null
+++ b/packages/preview/taskize/0.2.8/typst.toml
@@ -0,0 +1,13 @@
+[package]
+name = "taskize"
+version = "0.2.8"
+entrypoint = "lib.typ"
+authors = ["Nathan Scheinmann"]
+license = "MIT"
+description = "Horizontal and vertical columned lists for exercises and tasks, similar to LaTeX's tasks package"
+repository = "https://github.com/nathan-ed/typst-package-taskize"
+keywords = ["tasks", "exercises", "list", "columns", "horizontal", "layout", "education"]
+categories = ["layout", "utility"]
+disciplines = ["mathematics", "education"]
+compiler = "0.14.0"
+exclude = ["docs/*", "gallery", "tests", "*.pdf"]