From a32ab30bf984df75d532c065068fd4fab6ecef6b Mon Sep 17 00:00:00 2001 From: ellsphillips Date: Sat, 17 Oct 2026 17:30:36 +0100 Subject: [PATCH 1/3] feat: publish lineal 0.2.0 components and theme exports --- packages/preview/lineal/0.2.0/LICENSE | 21 ++ packages/preview/lineal/0.2.0/README.md | 141 +++++++++++ .../preview/lineal/0.2.0/docs/code-block.md | 136 +++++++++++ .../preview/lineal/0.2.0/docs/components.md | 174 ++++++++++++++ .../preview/lineal/0.2.0/docs/development.md | 61 +++++ packages/preview/lineal/0.2.0/docs/layouts.md | 96 ++++++++ packages/preview/lineal/0.2.0/lib.typ | 1 + packages/preview/lineal/0.2.0/src/brand.typ | 33 +++ .../lineal/0.2.0/src/components/callout.typ | 35 +++ .../0.2.0/src/components/code-block.typ | 226 ++++++++++++++++++ .../src/components/code/css-contexts.yaml | 34 +++ .../src/components/code/lineal-dark.tmTheme | 14 ++ .../src/components/code/lineal-light.tmTheme | 14 ++ .../code/svelte-typescript.sublime-syntax | 8 + .../src/components/code/svelte.sublime-syntax | 96 ++++++++ .../components/code/typescript-contexts.yaml | 75 ++++++ .../0.2.0/src/components/comparison.typ | 30 +++ .../0.2.0/src/components/data-table.typ | 54 +++++ .../lineal/0.2.0/src/components/metric.typ | 38 +++ .../lineal/0.2.0/src/components/quote.typ | 22 ++ .../0.2.0/src/components/repository.typ | 48 ++++ .../lineal/0.2.0/src/components/steps.typ | 53 ++++ packages/preview/lineal/0.2.0/src/index.typ | 17 ++ packages/preview/lineal/0.2.0/src/layouts.typ | 158 ++++++++++++ packages/preview/lineal/0.2.0/src/outline.typ | 221 +++++++++++++++++ .../lineal/0.2.0/src/page-statistics.typ | 204 ++++++++++++++++ .../preview/lineal/0.2.0/src/progress.typ | 51 ++++ .../lineal/0.2.0/src/static/icons/README.md | 8 + .../0.2.0/src/static/icons/code/file-code.svg | 16 ++ .../src/static/icons/code/lucide-LICENSE | 43 ++++ .../src/static/icons/code/provenance.json | 16 ++ .../0.2.0/src/static/icons/code/react.svg | 1 + .../static/icons/code/simple-icons-LICENSE.md | 30 +++ .../0.2.0/src/static/icons/code/svelte.svg | 1 + .../src/static/icons/code/typescript.svg | 1 + .../lineal/0.2.0/src/static/icons/github.svg | 1 + .../0.2.0/src/static/icons/octicons-LICENSE | 21 ++ .../lineal/0.2.0/src/static/title-bg.svg | 21 ++ packages/preview/lineal/0.2.0/src/theme.typ | 206 ++++++++++++++++ packages/preview/lineal/0.2.0/src/title.typ | 69 ++++++ packages/preview/lineal/0.2.0/typst.toml | 12 + 41 files changed, 2507 insertions(+) create mode 100644 packages/preview/lineal/0.2.0/LICENSE create mode 100644 packages/preview/lineal/0.2.0/README.md create mode 100644 packages/preview/lineal/0.2.0/docs/code-block.md create mode 100644 packages/preview/lineal/0.2.0/docs/components.md create mode 100644 packages/preview/lineal/0.2.0/docs/development.md create mode 100644 packages/preview/lineal/0.2.0/docs/layouts.md create mode 100644 packages/preview/lineal/0.2.0/lib.typ create mode 100644 packages/preview/lineal/0.2.0/src/brand.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/callout.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/code-block.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/code/css-contexts.yaml create mode 100644 packages/preview/lineal/0.2.0/src/components/code/lineal-dark.tmTheme create mode 100644 packages/preview/lineal/0.2.0/src/components/code/lineal-light.tmTheme create mode 100644 packages/preview/lineal/0.2.0/src/components/code/svelte-typescript.sublime-syntax create mode 100644 packages/preview/lineal/0.2.0/src/components/code/svelte.sublime-syntax create mode 100644 packages/preview/lineal/0.2.0/src/components/code/typescript-contexts.yaml create mode 100644 packages/preview/lineal/0.2.0/src/components/comparison.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/data-table.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/metric.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/quote.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/repository.typ create mode 100644 packages/preview/lineal/0.2.0/src/components/steps.typ create mode 100644 packages/preview/lineal/0.2.0/src/index.typ create mode 100644 packages/preview/lineal/0.2.0/src/layouts.typ create mode 100644 packages/preview/lineal/0.2.0/src/outline.typ create mode 100644 packages/preview/lineal/0.2.0/src/page-statistics.typ create mode 100644 packages/preview/lineal/0.2.0/src/progress.typ create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/README.md create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/file-code.svg create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/lucide-LICENSE create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/provenance.json create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/react.svg create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/simple-icons-LICENSE.md create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/svelte.svg create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/code/typescript.svg create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/github.svg create mode 100644 packages/preview/lineal/0.2.0/src/static/icons/octicons-LICENSE create mode 100644 packages/preview/lineal/0.2.0/src/static/title-bg.svg create mode 100644 packages/preview/lineal/0.2.0/src/theme.typ create mode 100644 packages/preview/lineal/0.2.0/src/title.typ create mode 100644 packages/preview/lineal/0.2.0/typst.toml diff --git a/packages/preview/lineal/0.2.0/LICENSE b/packages/preview/lineal/0.2.0/LICENSE new file mode 100644 index 0000000000..0672edee2a --- /dev/null +++ b/packages/preview/lineal/0.2.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Elliott Phillips + +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/lineal/0.2.0/README.md b/packages/preview/lineal/0.2.0/README.md new file mode 100644 index 0000000000..755e988554 --- /dev/null +++ b/packages/preview/lineal/0.2.0/README.md @@ -0,0 +1,141 @@ +# Lineal + +Clean Typst presentations with reusable content components. + +Lineal provides a slide theme, composable layouts and components for talks, +teaching material and project reviews. The components also work in ordinary +Typst documents. Content stays editable and selectable in the exported PDF. + +![A team update composed with Lineal components](https://raw.githubusercontent.com/ellsphillips/lineal/main/assets/img/components.png) + +## Quick start + +Requires **Typst 0.13.1 or newer**. Version 0.2.0 is a release candidate until +the registry submission is published. Use the source checkout or local package +instructions below to try it now. + +Create `talk.typ` with the public package API: + +```typst +#import "@preview/lineal:0.2.0": lineal-theme, config-info, title-slide, layouts, metric, callout + +#show: lineal-theme.with( + aspect-ratio: "16-9", + config-info(title: [Project review], author: [Your name]), +) + +#title-slide() + += Delivery +== Progress this quarter + +#layouts.columns( + metric(12, label: [Teams onboarded]), + metric(4, label: [Workshops delivered]), +) +#v(1em) +#callout(title: [Next step])[Share the workshop materials with each team.] +``` + +Compile with `typst compile --root . talk.typ`. Level-one headings create +section slides with an outline. Level-two headings create content slides. +Deeper headings stay within a slide. Set `footer-progress: false` to hide the +progress bar or customise the running headers and footers. + +## Source checkout and local packages + +**Version 0.2.0 is a release candidate in this repository.** The published +`0.1.0` package lacks the `lineal-theme` export. Updating a local checkout does +not repair that older registry package. + +Clone the repository to compile the current gallery immediately: + +```sh +git clone https://github.com/ellsphillips/lineal.git +cd lineal +typst compile --root . demo/main.typ demo/main.pdf +``` + +To use the quick-start example inside this checkout, change its import path to +`"lib.typ"`. For a reusable local package, run: + +```sh +python3 scripts/package --install-local +``` + +Then use the same API from any project: + +```typst +#import "@local/lineal:0.2.0": lineal-theme, config-info, title-slide +#show: lineal-theme.with(config-info(title: [My presentation])) +#title-slide() +== First slide +Your content. +``` + +Local installation never replaces a published package or modifies the registry +cache. The [release guide](docs/development.md) explains packaging and checks. + +## Components + +Import individual functions from `lib.typ` or the package entrypoint. Components +inherit the surrounding font and accept local colours and spacing. They do not +require the slide theme, JavaScript or network requests while rendering. + +| Component | Use | +| --- | --- | +| `code-block(source, filename: ...)` | Syntax highlighting, file icons, numbered lines, ranges, captions and light/dark styles. | +| `file-icon(filename)` and `file-label(filename)` | SVG file marks and path labels, including a blue Svelte mark for `.svelte.ts`. | +| `callout(title: ..., tone: ...)[body]` | Notes with an optional heading and a semantic accent. | +| `metric(value, label: ..., detail: ..., change: ...)` | A prominent value with explicit context. | +| `steps(items, direction: ...)` | Numbered processes in a vertical or horizontal layout. | +| `data-table(headers, rows)` | Styled tables with repeating headers, alignment and highlighted rows. | +| `comparison(before, after, labels: ...)` | Two labelled content areas with configurable proportions. | +| `quote(author: ..., source: ...)[body]` | Quotations with optional rich attribution. | +| `repository(name: ..., languages: ...)` | A repository link and a language bar with normalised percentages. | + +Detailed signatures and working examples: + +- [Code blocks and file labels](docs/code-block.md) +- [Callouts, metrics, steps, tables and comparisons](docs/components.md) +- [Layouts, quotes, repositories and theme configuration](docs/layouts.md) +- [Complete component gallery](https://github.com/ellsphillips/lineal/blob/main/demo/main.typ) + +## Composition + +Use the `layouts` namespace for common arrangements: + +```typst +#layouts.columns(widths: (2fr, 1fr), gutter: 1em, + layouts.panel[Main content], + layouts.panel[Supporting content], +) +``` + +`layouts.columns` accepts any number of bodies. `layouts.panel` sizes naturally +to its content. `layouts.frame(height: 180pt)[body]` checks vertical overflow +before drawing a fixed frame. The earlier `page-center`, `two-boxes`, `contained`, +`four-columns` and `matrix` layouts remain available. + +Native Typst `grid`, `stack`, `table`, figures and links work alongside every +component. Use `config-colors` for the slide frame and named colour options on +components for local styling. See the [layout guide](docs/layouts.md). + +## Development + +```sh +./scripts/check +python3 scripts/package /tmp/lineal-package +``` + +The checks compile real examples and package imports, exercise expected errors +and build the gallery. CI runs against Typst 0.13.1 and 0.14.2. Development +scripts require Python 3.11 or newer; consuming Lineal requires only Typst. +See [development and release instructions](docs/development.md). + +## Licence + +Lineal is MIT licensed. Bundled SVG assets retain their own MIT, ISC or CC0 +notices under `src/static/icons/`. Lineal builds on +[Touying](https://github.com/touying-typ/touying) and +[Typst](https://github.com/typst/typst). diff --git a/packages/preview/lineal/0.2.0/docs/code-block.md b/packages/preview/lineal/0.2.0/docs/code-block.md new file mode 100644 index 0000000000..ec382f5af0 --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/code-block.md @@ -0,0 +1,136 @@ +# Code blocks and file labels + +`code-block` renders selectable source text with a filename header, syntax +highlighting, line numbers and optional emphasis. It works inside a Lineal slide +or an ordinary Typst document. Existing calls with a positional source string and +`filename`, `language` or `theme` options continue to work. + +```typst +#import "@preview/lineal:0.2.0": code-block + +#code-block( + read("counter.svelte"), + filename: "components/counter.svelte", + start-line: 20, + emphasize: (22, (25, 27)), + caption: [The input and derived label share the same state.], +) +``` + +## Common settings + +| Option | Default | Purpose | +| --- | --- | --- | +| `filename` | `"app.py"` | Display the path and infer the syntax language. | +| `language` | `auto` | Override inference with a Typst language tag or familiar alias such as `"typescript"`. | +| `style` | `"dark"` | Use `"dark"`, `"light"`, or a complete custom palette. | +| `theme` | `auto` | Select the style's bundled syntax theme, disable colouring with `none`, or supply a tmTheme file. | +| `size`, `font` | `12pt`, `"DejaVu Sans Mono"` | Source typography. The default code font ships with Typst. | +| `body-font` | `none` | Header and caption font; `none` inherits the document font. | +| `width` | `100%` | An absolute or container-relative width, including `100% - 12pt`. | +| `height` | `none` | Fix the panel height to align neighbouring examples. | +| `max-height` | `none` | Limit the combined height of the panel and caption. | +| `show-header` | `true` | Hide filename chrome for compact comparison rows. Syntax inference still uses `filename`. | +| `line-numbers` | `true` | Show one number per original logical source line. | +| `start-line` | `1` | Set the first displayed source number. | +| `emphasize` | `()` | Highlight displayed line numbers or inclusive `(first, last)` ranges. | +| `wrap` | `true` | Wrap at natural text breakpoints while retaining the original line number. | +| `caption` | `none` | Supporting content below the panel. | + +Additional controls are `padding: 12pt`, `line-gap: 0pt`, `radius: 7pt`, +`header-size: 10.5pt`, `caption-size: 11pt`, `caption-fill: auto` and +`tab-size: 2`. Caption colour inherits the surrounding text unless overridden. + +The component measures content before applying a fixed height. Source that does +not fit raises an actionable error; it is never silently clipped or reduced to a +smaller font. An unbreakable token wider than the source column fails even with +wrapping enabled. Captions participate in `max-height` checks. + +Source strings retain indentation, interior blank lines and spaces. CRLF becomes +LF, and only initial or final newline characters are trimmed. Highlighting uses +native Typst raw text, so source remains selectable in exported PDFs. + +## Compact comparisons + +Use the same explicit height in both columns. No manual source padding or blank +lines are needed to align panel bottoms. + +```typst +#grid(columns: (1fr, 1fr), gutter: 16pt, + code-block( + "const label = useMemo(() => `${count}`, [count]);", + filename: "counter.tsx", show-header: false, height: 90pt, + ), + code-block( + "const label = $derived(`${count}`);", + filename: "counter.svelte.ts", show-header: false, height: 90pt, + ), +) +``` + +## Light and custom themes + +`style: "light"` selects a matching light syntax theme and surface palette. +`theme: none` disables syntax colouring without removing the header or emphasis. + +For a custom syntax theme, pass bytes read in your document. This preserves the +location of your asset when importing Lineal as a package: + +```typst +#code-block( + read("example.ts"), filename: "example.ts", + theme: read("assets/my-theme.tmTheme", encoding: none), +) +``` + +Theme paths remain accepted for compatibility with native `raw` options. Byte +input avoids ambiguity over a relative path's base directory. + +A custom surface palette has seven semantic colour roles. With `theme: auto`, a +custom palette renders source in its `ink` colour. Supply `theme` explicitly when +you also want syntax colouring. + +```typst +#let green = ( + ground: rgb("#eaf2ee"), raised: rgb("#f7fbf9"), + ink: rgb("#173f34"), muted: rgb("#527365"), + wire: rgb("#b9d2c7"), accent: rgb("#006b4b"), + "accent-text": rgb("#00543b"), +) +#code-block("let ready = true;", filename: "state.ts", style: green) +``` + +## File icons and labels + +These helpers can be used in diagrams, headers, file lists and comparison tables. + +```typst +#import "@preview/lineal:0.2.0": file-icon, file-label + +#file-icon("state.svelte.ts", size: 18pt) +#file-label("src/routes/+layout.server.ts", size: 15pt) +``` + +`file-icon(filename, size: 14pt, tint: ...)` uses local SVG assets. `.svelte` +uses orange, `.svelte.ts` uses TypeScript blue, `.tsx`/`.jsx` use the React mark, +and `.ts` uses the TypeScript mark. Other files use a generic icon in the requested +tint. `file-label` distinguishes the directory from the basename and accepts +`size`, `icon-size`, `font`, `fill`, `muted` and `gap` options. Its font inherits +the document unless specified. + +Svelte syntax includes markup, directives, runes, embedded TypeScript and CSS. +Multiline strings, comments and template interpolation retain their scope across +lines. The grammars provide lexical highlighting, not type checking. Other common +languages use Typst's bundled syntax definitions. + +## Checks and assets + +Compile `tests/code-block.typ` to inspect named styles, custom colours, wrapping, +multiline syntax, icons, captions, start numbers and headerless panels. +`tests/code-block-errors.typ` exercises explicit failure conditions with +`--input case=height`, `range`, `nowrap` and the other cases listed in that file. + +All themes and syntax definitions are bundled and require no network requests. +The Lineal themes and grammars use this project's MIT licence. SVG asset origins, +exact commits and their CC0 or ISC licences are recorded under +`src/static/icons/code/`. diff --git a/packages/preview/lineal/0.2.0/docs/components.md b/packages/preview/lineal/0.2.0/docs/components.md new file mode 100644 index 0000000000..7bfda04e38 --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/components.md @@ -0,0 +1,174 @@ +# Content components + +Import the functions you need from Lineal. They work in a `lineal-theme` slide or +an ordinary Typst document. Content remains selectable text; there are no image +snapshots, extra package dependencies or required fonts. + +```typst +#import "@preview/lineal:0.2.0": callout, metric, steps, data-table, comparison +``` + +Use the local `lib.typ` import when trying an unpublished checkout. Each component +inherits the surrounding font. `size` sets its base text size; most components +default to `1em` to keep the surrounding size. Tables default to `.85em`. +Named colour options let a component fit a custom +palette without changing a global theme. Colours default to Lineal's neutral ink +and deep blue accent. + +Components use their available width and natural content height. Put them inside +`grid`, `columns` or Lineal layouts to compose a slide. They do not shrink text or +clip overflowing content. Leave enough room for their content; `data-table` can +continue across document pages and repeats its header. + +## Callout + +```typst +#callout(title: [Review the source], tone: "info")[ + Keep the measurement conditions beside the result. +] +``` + +```typst +callout( + title: none, tone: "info", accent: auto, fill: auto, + ink: colour.neutral-dark, size: 1em, inset: .8em, radius: .25em, + body, +) +``` + +`tone` accepts `"info"`, `"success"`, `"warning"`, `"danger"` or `"neutral"`. +It selects the default accent; an explicit `accent` overrides that colour. The +default background is a light tint of the accent. Set `fill: none` for no +background. `title` is optional. `body` is trailing Typst content. + +## Metric + +```typst +#grid(columns: (1fr, 1fr), gutter: 1em, + metric("82%", label: [Less JavaScript], detail: [Median browser transfer]), + metric(12, label: [Teams onboarded], change: [+3 this quarter]), +) +``` + +```typst +metric( + value, label: none, detail: none, change: none, + accent: colour.primary, ink: colour.neutral-dark, muted: rgb("#626b73"), + fill: none, size: 1em, value-size: 2.5em, inset: .8em, + radius: .25em, align: left, +) +``` + +`value` accepts text, a number or rich Typst content such as `[1.4 s]`. `label`, +`detail` and `change` are optional content. Change text is deliberately explicit: +write `[12% faster]` or `[−4 defects]` to preserve its meaning. Lineal does not +infer whether an increase is good or bad. `value-size` is relative to the base +`size` when expressed in `em`. `align` accepts a Typst alignment. + +## Steps + +```typst +#steps(( + (title: [Prepare], description: [Gather source material.]), + (title: [Review], description: [Check measurements and examples.]), + (title: [Publish], description: [Share the finished presentation.]), +), direction: "horizontal") + +#steps(([Draft], [Review], [Approve]), start: 4) +``` + +```typst +steps( + items, direction: "vertical", start: 1, + accent: colour.primary, ink: colour.neutral-dark, muted: rgb("#626b73"), + marker-ink: white, size: 1em, marker-size: 1.65em, + gap: .85em, inset: 0pt, +) +``` + +`items` is a non-empty array. Each item is content or a dictionary with `title` +and optional `description`. Unexpected dictionary keys are rejected to catch +misspelled options. `direction` accepts `"vertical"` or `"horizontal"`. +Horizontal steps share the available width equally. `start` must be a positive +integer. `gap` separates steps; `marker-size` controls the numbered circles. + +## Data table + +```typst +#data-table( + ([Route], [Before], [After]), + ( + ([Home], [1.8 MB], [320 KB]), + ([Calendar], [1.8 MB], [310 KB]), + ([Reader], [3.9 MB], [690 KB]), + ), + columns: (2fr, 1fr, 1fr), + align: (left, right, right), + highlight: (2,), +) +``` + +```typst +data-table( + headers, rows, columns: auto, align: left, highlight: (), striped: true, + accent: colour.primary, ink: colour.neutral-dark, + header-fill: auto, header-ink: white, fill: white, + stripe-fill: rgb("#f3f5f6"), highlight-fill: auto, + rule: rgb("#dce1e4"), size: .85em, inset: (x: .7em, y: .6em), +) +``` + +`headers` is a non-empty array. `rows` is an array of equally sized arrays. An +empty rows array renders only the header. Cells accept content, strings, integers, +floats, booleans or `none` for an empty cell. Pass formatted content when a value +needs units, a date or a specific number format. + +`columns` defaults to equal widths; a custom array must match the header count. +`align` accepts the native Typst table alignment, including one alignment per +column. `highlight` is an array of **zero-based data-row indices**, so `(2,)` +highlights the third data row. Header and highlighted backgrounds take precedence +over stripes. `header-fill: auto` uses `accent`; `highlight-fill: auto` uses a +light tint of `accent`. The header is semantic Typst `table.header` content and +repeats when a table crosses a page. + +## Comparison + +```typst +#comparison( + [Find settings across several configuration files.], + [Set a named option at the call site.], + labels: ([Current workflow], [Simpler workflow]), +) +``` + +```typst +comparison( + before, after, labels: ([Before], [After]), columns: (1fr, 1fr), gap: 1em, + accent: colour.primary, ink: colour.neutral-dark, + fill: colour.neutral-light, size: 1em, inset: .8em, radius: .25em, +) +``` + +Both panels accept arbitrary Typst content: prose, lists, a figure, a metric or a +`code-block`. Their labels can describe alternatives rather than a sequence. +`labels` and `columns` must each have two entries. Panels align at the top and +keep their natural heights. Set `columns: (2fr, 3fr)` for asymmetric examples or +`fill: none, inset: 0pt` for an open layout. + +## A custom palette + +```typst +#let accent = rgb("#b5d9ff") +#let surface = rgb("#263449") + +#callout( + title: [Local styling], accent: accent, fill: surface, ink: white, +)[ + Rich content keeps its usual Typst behaviour. +] +``` + +Choose an appropriate `header-ink` for a custom table header, or `marker-ink` for +light step markers. For further working examples, see +[`tests/components.typ`](https://github.com/ellsphillips/lineal/blob/main/tests/components.typ) and +[`tests/components-custom.typ`](https://github.com/ellsphillips/lineal/blob/main/tests/components-custom.typ). diff --git a/packages/preview/lineal/0.2.0/docs/development.md b/packages/preview/lineal/0.2.0/docs/development.md new file mode 100644 index 0000000000..3ce3b07c4c --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/development.md @@ -0,0 +1,61 @@ +# Development and releases + +Install Python 3.11 or later and Typst 0.13.1 or later. CI runs the same suite +against Typst 0.13.1 and 0.14.2. + +```sh +./scripts/check +``` + +The checker compiles every `tests/*.typ` fixture whose name does not contain +`errors`, verifies the invalid-input fixtures, compiles the README quick start +in a fresh project, and compiles the demo. Generated +PDFs and the staged package live in a temporary directory and are removed when +the command finishes. Use `--typst /path/to/typst` to select another compiler, or +`--skip-demo` while working on a component. + +The `tests/package-*.typ` fixtures import `@preview/lineal:0.2.0`. The checker +copies the distributable file set to a private package directory and passes it +to Typst with `--package-path`. This verifies real package imports and bundled +resources without replacing a globally cached or installed package. + +## Adding tests + +- Put a successful compilation fixture directly in `tests/`. +- Put a rejected-input fixture in `tests/invalid/`. It must fail an explicit + assertion. An optional first-line `// expect-error: message` comment requires + that message in the diagnostic as well. +- For one fixture with several `sys.inputs` cases, add a `tests/*-errors.json` + list. Each entry has `source` relative to `tests/`, `inputs` as string keys and + values, and `error` as the expected assertion-message substring. + +Compilation tests catch broken imports, missing assets and invalid inputs. +Render new layouts and inspect their PDF output before merging visual changes. + +## Packaging + +```sh +./scripts/package /tmp/lineal-0.2.0 +./scripts/package --install-local --replace +``` + +The first command stages `src/`, `lib.typ`, `typst.toml`, `README.md`, `LICENSE` +and `docs/` in a new or empty directory outside the repository. It excludes +tests, scripts, the demo, Git metadata and generated output. The second installs +the same files under `@local/lineal:0.2.0`; `--replace` only replaces an existing +local package with the same name and version. Neither command installs into the +global `@preview` namespace. + +## Publishing + +1. Update `typst.toml`, documented imports and the package fixtures together. +2. Run `./scripts/check` with both supported compiler versions and inspect the demo. +3. Merge the reviewed source and tag that exact commit. +4. Stage its files into `packages/preview/lineal//` in a fork of + [`typst/packages`](https://github.com/typst/packages). +5. Open a package-update PR. Keep earlier published version directories unchanged. + +Keep the README, license files and runtime assets in the submission. Exclude +large documentation images and PDFs from the downloaded bundle through the +manifest. See the official [submission guidelines](https://github.com/typst/packages/blob/main/docs/README.md) +and [file-selection guidance](https://github.com/typst/packages/blob/main/docs/tips.md). diff --git a/packages/preview/lineal/0.2.0/docs/layouts.md b/packages/preview/lineal/0.2.0/docs/layouts.md new file mode 100644 index 0000000000..4649e1b217 --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/layouts.md @@ -0,0 +1,96 @@ +# Layouts and existing components + +Import `layouts` from Lineal, then call its functions by name: + +```typst +#import "@preview/lineal:0.2.0": layouts, quote, repository + +#layouts.columns(widths: (2fr, 1fr), gutter: 1em, + layouts.panel[The main argument.], + layouts.panel[Supporting material.], +) +``` + +## Layout primitives + +- `layouts.columns(..bodies, widths: auto, gutter: 1em, align: top)` gives each + body an equal share, or accepts one native Typst grid track per body. +- `layouts.panel(body, fill: colour.neutral-light, ink: colour.neutral-dark, + stroke: none, inset: 1em, radius: .35em, width: 100%, height: auto, + breakable: false)` provides a surface with natural content height. +- `layouts.frame(body, width: 100%, height: 200pt, inset: 0pt, fill: none, + stroke: none, radius: 0pt)` measures the content before drawing a fixed frame. + It reports vertical overflow instead of clipping or shrinking text. Use native + wrapping or width constraints for wide images, tables and unbreakable tokens. + `width` accepts + absolute, percentage and mixed lengths such as `100% - 12pt`; `height` must + be a positive absolute length. +- `layouts.page-center(body)` centres a content block on the page. + +Existing `two-boxes`, `contained`, `four-columns` and `matrix` layouts remain +available. Use the primitives above when you need different counts, widths or +colours. Native Typst `grid` and `stack` also work with all Lineal components. + +## Quotes + +```typst +#quote(author: [A speaker], source: [Conference talk])[ + A short quotation belongs here. +] +``` + +`quote(body, author: none, source: none, fill: colour.neutral-light, +ink: colour.neutral-dark, accent: colour.primary, inset: 1em, radius: .35em, +size: 1em, gap: .6em)` accepts rich content for the body and attribution. +Omit both attribution fields for an anonymous quotation. Fonts inherit from the +document. + +## Repositories + +```typst +#repository( + name: "ellsphillips/lineal", + languages: (Typst: 2, Python: 1), +) +``` + +`repository(name: none, languages: (:), url: auto, colors: colour.tech, +ink: colour.neutral-dark, fallback: rgb("8a9599"), size: 1em, +bar-height: .5em, gap: .5em)` accepts non-negative weights with a positive total. +Weights can be byte counts or percentages. Both the bar and its labels use the +normalised shares, so `2, 1` displays `66.7%, 33.3%`. Zero-weight entries are +omitted. Unknown language names use `fallback`. + +Omit `name` to show only the language bar and legend. The default URL is +`https://github.com/` plus `name`. Override `url` for a +repository hosted elsewhere. `colors` maps lowercase language names to colours. + +## Theme configuration + +The public entrypoint exports `lineal-theme`, `title-slide`, `brand`, `colour`, +`config-info`, `config-colors`, `config-common` and `config-page`. These last four +functions come from the pinned Touying dependency and let you configure metadata, +colours, slide behaviour and page layout without a second import. + +```typst +#show: lineal-theme.with( + aspect-ratio: "16-9", align: top, + footer-progress: true, + config-info(title: [Quarterly review], author: [Your name]), + config-colors(primary: rgb("6b3fa0"), primary-light: rgb("c8b3df")), +) +``` + +Theme options include `header`, `header-right`, `footer-left`, `footer-right`, +`footer-progress` and `logo`. `logo: auto` keeps the logo in `config-info`; +explicit content overrides it for the title and footer. Set a running header or +footer field to `none` to omit it. Theme colours configure the slide frame. +Individual content components accept local colours so they can also work in +ordinary Typst documents or mixed-colour slides. + +`lineal-progress-bar(variant: "exact", height: 2pt, ratios: none, +accent: colour.primary, remaining: colour.primary-light)` works with or without +section headings. `broad` shows completed and remaining slides, `near` groups +previous/current/future sections, and `exact` splits the current section into +completed and remaining slides. Optional explicit non-negative `ratios` must +match the variant's two, three or four segments and have a positive total. diff --git a/packages/preview/lineal/0.2.0/lib.typ b/packages/preview/lineal/0.2.0/lib.typ new file mode 100644 index 0000000000..ecded15e88 --- /dev/null +++ b/packages/preview/lineal/0.2.0/lib.typ @@ -0,0 +1 @@ +#import "src/index.typ": * diff --git a/packages/preview/lineal/0.2.0/src/brand.typ b/packages/preview/lineal/0.2.0/src/brand.typ new file mode 100644 index 0000000000..c7e5721540 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/brand.typ @@ -0,0 +1,33 @@ +#let tech-colours = ( + other: rgb("#ffffff"), + typescript: rgb("#3178C6"), + javascript: rgb("#f7df1e"), + html: rgb("#e34c26"), + css: rgb("#264de4"), + python: rgb("#306998"), + typst: rgb("#239CAD"), + docker: rgb("#384d54"), + mustache: rgb("#724b3b"), + smarty: rgb("#f0c040"), +) + +#let base-colours = ( + primary: rgb("#003C57"), + secondary: rgb("#006931"), + neutral-light: rgb("#fafafa"), + neutral-dark: rgb("#23373b"), +) + +#let colour = ( + ..base-colours, + neutral-lightest: base-colours.neutral-light, + neutral-darkest: base-colours.neutral-dark, + primary-light: base-colours.primary.lighten(50%), + dev: rgb("#ec4899"), + tech: tech-colours, +) + +#let brand = ( + wordmark: [$bb("L")"ineal"$], + logo: square(size: 1em, fill: gradient.linear(colour.primary, colour.primary-light, angle: -30deg), radius: .1em), +) diff --git a/packages/preview/lineal/0.2.0/src/components/callout.typ b/packages/preview/lineal/0.2.0/src/components/callout.typ new file mode 100644 index 0000000000..27f62d9066 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/callout.typ @@ -0,0 +1,35 @@ +#import "../brand.typ": colour + +/// An inset note with an optional heading and a semantic accent. +#let callout( + title: none, + tone: "info", + accent: auto, + fill: auto, + ink: colour.neutral-dark, + size: 1em, + inset: .8em, + radius: .25em, + body, +) = { + let tones = ( + info: colour.primary, + success: rgb("#21734b"), + warning: rgb("#956114"), + danger: rgb("#b12c3b"), + neutral: rgb("#626b73"), + ) + assert(tone in tones, message: "callout tone must be info, success, warning, danger or neutral") + let accent = if accent == auto { tones.at(tone) } else { accent } + let fill = if fill == auto { accent.lighten(94%) } else { fill } + block( + width: 100%, inset: inset, radius: radius, fill: fill, + stroke: (left: 2.5pt + accent), breakable: false, + { + set text(size: size, fill: ink) + set par(spacing: .55em, leading: .4em) + let items = if title == none { () } else { (text(weight: "semibold", fill: accent, title),) } + stack(dir: ttb, spacing: .35em, ..items, body) + }, + ) +} diff --git a/packages/preview/lineal/0.2.0/src/components/code-block.typ b/packages/preview/lineal/0.2.0/src/components/code-block.typ new file mode 100644 index 0000000000..533e82fa0e --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code-block.typ @@ -0,0 +1,226 @@ +// Typst loads each syntax into its own set. Compose local contexts into each +// complete definition rather than referring to unavailable bundled scopes. +#let typescript-contexts = read("code/typescript-contexts.yaml") +#let syntax-files = ( + bytes(read("code/svelte.sublime-syntax") + typescript-contexts + read("code/css-contexts.yaml")), + bytes(read("code/svelte-typescript.sublime-syntax") + typescript-contexts), +) + +// Bytes keep bundled themes independent of the consuming project's root. +#let DEFAULT_CODE_THEME = read("code/lineal-dark.tmTheme", encoding: none) +#let light-code-theme = read("code/lineal-light.tmTheme", encoding: none) +#let code-styles = ( + dark: (ground: rgb("#172126"), raised: rgb("#1d2b31"), ink: rgb("#f1f5f9"), muted: rgb("#a3b3bf"), wire: rgb("#40515d"), accent: rgb("#67d2ff"), accent-text: rgb("#9de2ff")), + light: (ground: rgb("#f1f5f9"), raised: white, ink: rgb("#172126"), muted: rgb("#526475"), wire: rgb("#cbd5e1"), accent: rgb("#006b94"), accent-text: rgb("#005778")), +) + +#let resolve-style(style) = { + if type(style) == str { + assert(style in code-styles, message: "code-block style must be dark, light, or a complete palette") + return code-styles.at(style) + } + assert(type(style) == dictionary, message: "code-block style must be dark, light, or a complete palette") + for key in ("ground", "raised", "ink", "muted", "wire", "accent", "accent-text") { + assert(key in style and type(style.at(key)) == color, message: "code-block palette requires colour " + key) + } + style +} + +#let resolve-theme(theme, style) = { + if theme != auto { return theme } + if style == "dark" { return DEFAULT_CODE_THEME } + if style == "light" { return light-code-theme } + none +} + +#let resolved-code-width(width, body) = { + assert(type(width) in (length, ratio, relative), message: "code-block width must be a length, ratio, or relative length") + if type(width) == length { + assert(width > 0pt, message: "code-block width must be positive") + return body(width) + } + layout(size => { + let actual = if type(width) == ratio { width * size.width } else { width.length + width.ratio * size.width } + assert(actual > 0pt, message: "code-block width must be positive") + body(actual) + }) +} + +#let file-kind(filename) = { + let filename = lower(filename) + if filename.ends-with(".svelte.ts") { return "svelte-ts" } + if filename.ends-with(".svelte") { return "svelte" } + let extension = lower(filename.split(".").last()) + let extensions = (typescript: "ts", javascript: "js", python: "py", ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", py: "py", json: "json", css: "css", html: "html", htm: "html", yaml: "yaml", yml: "yaml", md: "md", typ: "typ") + extensions.at(extension, default: "text") +} + +#let language-info(filename, language) = { + let kind = if language == auto { file-kind(filename) } else { language } + let aliases = (typescript: "ts", javascript: "js", python: "py", markdown: "md", typst: "typ", yml: "yaml", plaintext: "text", "svelte.ts": "svelte-ts") + kind = aliases.at(kind, default: kind) + let tags = (svelte: "lineal-svelte", "svelte-ts": "lineal-svelte-ts") + let names = (ts: "TypeScript", tsx: "React TSX", svelte: "Svelte", "svelte-ts": "Svelte TS", js: "JavaScript", jsx: "React JSX", py: "Python", json: "JSON", css: "CSS", html: "HTML", yaml: "YAML", md: "Markdown", typ: "Typst", text: "Plain text") + (tag: tags.at(kind, default: kind), label: names.at(kind, default: kind)) +} + +/// Extension-aware SVG mark. Generic files use a tintable Lucide file icon. +#let file-icon(filename, size: 14pt, tint: rgb("#526475")) = { + let names = (ts: "typescript", tsx: "react", jsx: "react", svelte: "svelte", "svelte-ts": "svelte") + let kind = file-kind(filename) + let name = names.at(kind, default: "file-code") + let svg = read("../static/icons/code/" + name + ".svg") + if kind == "svelte-ts" { svg = svg.replace("#FF3E00", "#3178C6") } + image(bytes(svg.replace("currentColor", tint.to-hex())), width: size, height: size, alt: language-info(filename, auto).label + " file") +} + +/// A compact filename lockup for headers, diagrams and ownership columns. +#let file-label(filename, size: 11pt, icon-size: auto, font: none, fill: rgb("#172126"), muted: rgb("#526475"), gap: 7pt) = { + let parts = filename.split("/") + let basename = parts.pop() + let directory = if parts.len() == 0 { "" } else { parts.join("/") + "/" } + grid(columns: (auto, 1fr), column-gutter: gap, align: horizon, + file-icon(filename, size: if icon-size == auto { size * 1.25 } else { icon-size }, tint: fill), + text(..if font == none { (:) } else { (font: font) }, size: size)[#text(fill: muted, directory)#text(weight: "semibold", fill: fill, basename)], + ) +} + +/// Compatibility helpers retained for existing Lineal documents. +#let code-block-icon(language: "python") = file-icon("snippet." + language, size: .6em) +#let code-block-title(title: "Code block", language: "python") = file-label(title, size: .5em, fill: code-styles.dark.ink, muted: code-styles.dark.muted) + +#let validate-lines(emphasize, first, count) = { + let last = first + count - 1 + for entry in emphasize { + if type(entry) == int { + assert(first <= entry and entry <= last, message: "emphasized line is outside the source range") + } else { + assert(type(entry) == array and entry.len() == 2, message: "emphasize expects line numbers or inclusive (start, end) pairs") + assert(type(entry.first()) == int and type(entry.last()) == int, message: "emphasis range endpoints must be integers") + assert(first <= entry.first() and entry.first() <= entry.last() and entry.last() <= last, message: "emphasis range is outside the source range") + } + } +} + +#let selected-line(number, emphasize) = emphasize.any(entry => { + if type(entry) == int { return number == entry } + entry.first() <= number and number <= entry.last() +}) + +#let code-header(options) = block( + width: 100%, fill: options.style.ground, radius: (top-left: options.radius, top-right: options.radius), + inset: (x: options.padding, y: 8pt), + grid(columns: (1fr, auto), column-gutter: 10pt, align: horizon, + file-label(options.filename, size: options.header-size, font: options.body-font, fill: options.style.ink, muted: options.style.muted), + text(..if options.body-font == none { (:) } else { (font: options.body-font) }, size: options.header-size * .86, fill: options.style.muted, options.language.label), + ), +) + +#let source-line(line, options, width) = context { + let number = options.start-line + line.number - 1 + let chosen = selected-line(number, options.emphasize) + let number-width = if options.line-numbers { options.number-width } else { 0pt } + let gap = if options.line-numbers { 10pt } else { 0pt } + let available = width - 2 * options.padding - number-width - gap + assert(available > 0pt, message: "code panel has no remaining width for source") + if not options.wrap { + assert(measure(line.body).width <= available, message: options.filename + ":" + str(number) + " exceeds code width; enable wrapping, widen the panel or reduce size") + } else { + for token in line.text.split(regex("\\s+")) { + assert(measure(text(token)).width <= available, message: options.filename + ":" + str(number) + " contains an unbreakable token wider than the panel") + } + } + let source = block(width: available, { + set text(top-edge: "ascender", bottom-edge: "descender") + set par(leading: options.size * .12, spacing: 0pt) + line.body + }) + let row-height = calc.max(options.size * 1.1, measure(source).height) + options.line-gap + box(width: width, height: row-height, + block(width: width, height: row-height, inset: (x: options.padding), + fill: if chosen { options.style.accent.transparentize(87%) } else { none }, + { + if chosen { place(top + left, dx: -options.padding, rect(width: 2pt, height: row-height, fill: options.style.accent)) } + align(horizon, grid(columns: (number-width, 1fr), column-gutter: gap, align: top, + if options.line-numbers { + pad(top: options.size * .18, align(right, text(size: options.size * .72, fill: if chosen { options.style.accent-text } else { options.style.muted }, str(number)))) + } else { [] }, + source, + )) + }, + ), + ) +} + +#let code-source(source, options, width) = { + set text(font: options.font, size: options.size, fill: options.style.ink, hyphenate: false, ligatures: false) + set par(leading: 0pt, spacing: 0pt) + set block(spacing: 0pt) + show raw: set text(font: options.font, size: options.size) + show raw.line: line => source-line(line, options, width) + block(width: width, inset: (y: options.padding * .7), + raw(source, block: true, lang: options.language.tag, syntaxes: syntax-files, theme: options.theme, tab-size: options.tab-size), + ) +} + +#let panel(source, options, width, height: auto) = block( + width: width, height: height, fill: options.style.raised, clip: true, + radius: options.radius, stroke: .6pt + options.style.wire, + { + set block(spacing: 0pt) + if options.show-header { code-header(options) } + code-source(source, options, width) + }, +) + +#let render-code(source, options, width) = context { + let intrinsic = measure(panel(source, options, width)).height + let height = if options.height == none { intrinsic } else { options.height } + assert(intrinsic <= height, message: options.filename + " needs " + repr(intrinsic) + "; requested height is " + repr(height)) + let content = { + panel(source, options, width, height: height) + if options.caption != none { + v(7pt) + text(..if options.body-font == none { (:) } else { (font: options.body-font) }, size: options.caption-size, fill: options.caption-fill, options.caption) + } + } + if options.max-height != none { + assert(measure(block(width: width, content)).height <= options.max-height, message: options.filename + " and caption exceed max-height") + } + content +} + +/// Selectable, highlighted source with optional filename chrome and measured overflow. +/// A named style supplies its own syntax theme; a custom palette uses plain ink +/// unless theme is supplied. Explicit height aligns neighbouring panel bottoms. +#let code-block( + content, filename: "app.py", language: auto, style: "dark", + body-font: none, font: "DejaVu Sans Mono", size: 12pt, + width: 100%, height: none, max-height: none, + line-numbers: true, start-line: 1, emphasize: (), + wrap: true, padding: 12pt, line-gap: 0pt, radius: 7pt, + show-header: true, header-size: 10.5pt, caption: none, caption-size: 11pt, + caption-fill: auto, tab-size: 2, theme: auto, +) = context { + assert(type(content) == str, message: "code-block source must be a string") + let theme = resolve-theme(theme, style) + let style = resolve-style(style) + assert(size > 0pt and padding >= 0pt and line-gap >= 0pt, message: "code dimensions must be positive") + assert(start-line >= 1 and type(start-line) == int, message: "start-line must be a positive integer") + assert(type(tab-size) == int and tab-size > 0, message: "code-block tab-size must be a positive integer") + assert(height == none or type(height) == length and height > 0pt, message: "code-block height must be a positive length or none") + assert(max-height == none or type(max-height) == length and max-height > 0pt, message: "code-block max-height must be a positive length or none") + let source = content.replace("\r\n", "\n").trim("\n") + let count = source.split("\n").len() + validate-lines(emphasize, start-line, count) + let number-width = measure(text(font: font, size: size * .72, str(start-line + count - 1))).width + let options = ( + filename: filename, language: language-info(filename, language), style: style, + body-font: body-font, font: font, size: size, height: height, max-height: max-height, + line-numbers: line-numbers, start-line: start-line, emphasize: emphasize, + wrap: wrap, padding: padding, line-gap: line-gap, radius: radius, + show-header: show-header, header-size: header-size, caption: caption, caption-size: caption-size, + caption-fill: if caption-fill == auto { text.fill } else { caption-fill }, tab-size: tab-size, theme: theme, number-width: number-width, + ) + resolved-code-width(width, actual => render-code(source, options, actual)) +} diff --git a/packages/preview/lineal/0.2.0/src/components/code/css-contexts.yaml b/packages/preview/lineal/0.2.0/src/components/code/css-contexts.yaml new file mode 100644 index 0000000000..ed43de8a7a --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/css-contexts.yaml @@ -0,0 +1,34 @@ + css-main: + - match: '/\*' + scope: punctuation.definition.comment.begin.css + push: + - meta_scope: comment.block.css + - match: '\*/' + scope: punctuation.definition.comment.end.css + pop: true + - match: '"' + push: + - meta_scope: string.quoted.double.css + - match: '\\.' + scope: constant.character.escape.css + - match: '"' + pop: true + - match: "'" + push: + - meta_scope: string.quoted.single.css + - match: '\\.' + scope: constant.character.escape.css + - match: "'" + pop: true + - match: '[.#][A-Za-z_][A-Za-z0-9_-]*' + scope: entity.name.type.css + - match: '[-A-Za-z]+(?=\s*:)' + scope: entity.other.attribute-name.css + - match: '#[0-9A-Fa-f]{3,8}\b|\b\d+(?:\.\d+)?(?:px|pt|em|rem|vh|vw|s|ms|%)?\b' + scope: constant.numeric.css + - match: '\b[-A-Za-z]+(?=\s*\()' + scope: support.function.css + - match: '\b(?:inherit|initial|unset|auto|none|block|inline|flex|grid|solid|absolute|relative|fixed|transparent|rebeccapurple|red|blue|green|black|white)\b' + scope: constant.language.css + - match: '[{}:;,()]' + scope: punctuation.css diff --git a/packages/preview/lineal/0.2.0/src/components/code/lineal-dark.tmTheme b/packages/preview/lineal/0.2.0/src/components/code/lineal-dark.tmTheme new file mode 100644 index 0000000000..d979476007 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/lineal-dark.tmTheme @@ -0,0 +1,14 @@ + + +nameLineal darksettings +settingsforeground#f1f5f9background#1d2b31 +scopecommentsettingsforeground#a3b3bffontStyleitalic +scopekeyword, storage, punctuation.definition.block.sveltesettingsforeground#c4b5fd +scopesupport.function.rune.svelte, keyword.control.block.sveltesettingsforeground#67d2ff +scopestringsettingsforeground#b5d99c +scopeconstant.numeric, constant.languagesettingsforeground#f3cb8b +scopeentity.name.tag, punctuation.definition.tagsettingsforeground#83d9f2 +scopeentity.other.attribute-name, entity.name.function, support.functionsettingsforeground#93c5fd +scopeentity.name.type, support.type, support.classsettingsforeground#8cdbd0 +scopekeyword.operator, punctuationsettingsforeground#cbd5e1 + diff --git a/packages/preview/lineal/0.2.0/src/components/code/lineal-light.tmTheme b/packages/preview/lineal/0.2.0/src/components/code/lineal-light.tmTheme new file mode 100644 index 0000000000..11ee46b431 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/lineal-light.tmTheme @@ -0,0 +1,14 @@ + + +nameLineal lightsettings +settingsforeground#172126background#ffffff +scopecommentsettingsforeground#64748bfontStyleitalic +scopekeyword, storage, punctuation.definition.block.sveltesettingsforeground#7444a4 +scopesupport.function.rune.svelte, keyword.control.block.sveltesettingsforeground#006b94 +scopestringsettingsforeground#42752a +scopeconstant.numeric, constant.languagesettingsforeground#8b5900 +scopeentity.name.tag, punctuation.definition.tagsettingsforeground#006b94 +scopeentity.other.attribute-name, entity.name.function, support.functionsettingsforeground#215a9c +scopeentity.name.type, support.type, support.classsettingsforeground#006e65 +scopekeyword.operator, punctuationsettingsforeground#475569 + diff --git a/packages/preview/lineal/0.2.0/src/components/code/svelte-typescript.sublime-syntax b/packages/preview/lineal/0.2.0/src/components/code/svelte-typescript.sublime-syntax new file mode 100644 index 0000000000..969740629f --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/svelte-typescript.sublime-syntax @@ -0,0 +1,8 @@ +%YAML 1.2 +--- +name: Lineal Svelte TypeScript +file_extensions: [svelte-ts, lineal-svelte-ts] +scope: source.ts.svelte +contexts: + main: + - include: ts-main diff --git a/packages/preview/lineal/0.2.0/src/components/code/svelte.sublime-syntax b/packages/preview/lineal/0.2.0/src/components/code/svelte.sublime-syntax new file mode 100644 index 0000000000..1a858c2021 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/svelte.sublime-syntax @@ -0,0 +1,96 @@ +%YAML 1.2 +--- +name: Lineal Svelte +file_extensions: [svelte, lineal-svelte] +scope: text.html.svelte +contexts: + main: + - match: '' + scope: punctuation.definition.comment.end.html + pop: true + tag: + - match: '/?>' + scope: punctuation.definition.tag.end.html + pop: true + - match: '\{' + scope: punctuation.section.embedded.begin.svelte + push: expression + - include: attributes + attributes: + - match: '[A-Za-z_:][A-Za-z0-9_:.-]*(?=\s*=)' + scope: entity.other.attribute-name.html + - match: '=' + scope: keyword.operator.assignment.html + - match: '"' + scope: punctuation.definition.string.begin.html + push: + - meta_scope: string.quoted.double.html + - match: '"' + scope: punctuation.definition.string.end.html + pop: true + - match: "'" + scope: punctuation.definition.string.begin.html + push: + - meta_scope: string.quoted.single.html + - match: "'" + scope: punctuation.definition.string.end.html + pop: true + script-tag: + - match: '>' + scope: punctuation.definition.tag.end.html + set: script + - include: attributes + script: + - meta_scope: source.ts.embedded.svelte + - match: '(?i)' + scope: entity.name.tag.script.html + pop: true + - include: ts-main + style-tag: + - match: '>' + scope: punctuation.definition.tag.end.html + set: style + - include: attributes + style: + - meta_scope: source.css.embedded.svelte + - match: '(?i)' + scope: entity.name.tag.style.html + pop: true + - include: css-main + expression: + - meta_scope: source.ts.embedded.svelte + - match: '\}' + scope: punctuation.section.embedded.end.svelte + pop: true + - match: '\{' + scope: punctuation.section.embedded.begin.svelte + push: expression + - include: ts-main diff --git a/packages/preview/lineal/0.2.0/src/components/code/typescript-contexts.yaml b/packages/preview/lineal/0.2.0/src/components/code/typescript-contexts.yaml new file mode 100644 index 0000000000..cdf9400436 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/code/typescript-contexts.yaml @@ -0,0 +1,75 @@ + ts-main: + - match: '//' + scope: punctuation.definition.comment.ts + push: + - meta_scope: comment.line.double-slash.ts + - match: '$' + pop: true + - match: '/\*' + scope: punctuation.definition.comment.begin.ts + push: + - meta_scope: comment.block.ts + - match: '\*/' + scope: punctuation.definition.comment.end.ts + pop: true + - match: '"' + scope: punctuation.definition.string.begin.ts + push: ts-double-string + - match: "'" + scope: punctuation.definition.string.begin.ts + push: ts-single-string + - match: '`' + scope: punctuation.definition.string.begin.ts + push: ts-template-string + - match: '\$(?:state|derived|effect|props|bindable|inspect|host)(?:\.(?:raw|snapshot|by|pre|tracking|root|pending|eager|trace))?\b' + scope: support.function.rune.svelte + - match: '\b(?:abstract|as|asserts|async|await|break|case|catch|class|const|constructor|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|infer|instanceof|interface|is|keyof|let|namespace|new|of|override|private|protected|public|readonly|return|satisfies|set|static|super|switch|throw|try|type|typeof|var|void|while|with|yield)\b' + scope: keyword.control.ts + - match: '\b(?:any|bigint|boolean|never|number|object|string|symbol|unknown)\b' + scope: storage.type.ts + - match: '\b(?:true|false|null|undefined|NaN|Infinity)\b' + scope: constant.language.ts + - match: '\b(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)n?\b' + scope: constant.numeric.ts + - match: '\b[A-Z][A-Za-z0-9_$]*\b' + scope: entity.name.type.ts + - match: '[$A-Za-z_][A-Za-z0-9_$]*(?=\s*\()' + scope: entity.name.function.ts + - match: '[+*/%=!<>?:&|~^-]+' + scope: keyword.operator.ts + - match: '[{}\[\](),.;]' + scope: punctuation.ts + ts-double-string: + - meta_scope: string.quoted.double.ts + - match: '\\.' + scope: constant.character.escape.ts + - match: '"' + scope: punctuation.definition.string.end.ts + pop: true + ts-single-string: + - meta_scope: string.quoted.single.ts + - match: '\\.' + scope: constant.character.escape.ts + - match: "'" + scope: punctuation.definition.string.end.ts + pop: true + ts-template-string: + - meta_scope: string.template.ts + - match: '\\.' + scope: constant.character.escape.ts + - match: '\$\{' + scope: punctuation.section.interpolation.begin.ts + push: ts-interpolation + - match: '`' + scope: punctuation.definition.string.end.ts + pop: true + ts-interpolation: + - meta_scope: meta.interpolation.ts + - clear_scopes: 1 + - match: '\}' + scope: punctuation.section.interpolation.end.ts + pop: true + - match: '\{' + scope: punctuation.section.interpolation.begin.ts + push: ts-interpolation + - include: ts-main diff --git a/packages/preview/lineal/0.2.0/src/components/comparison.typ b/packages/preview/lineal/0.2.0/src/components/comparison.typ new file mode 100644 index 0000000000..8c78e531f9 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/comparison.typ @@ -0,0 +1,30 @@ +#import "../brand.typ": colour + +/// Two labelled panels that accept arbitrary Typst content, including code. +#let comparison( + before, + after, + labels: ([Before], [After]), + columns: (1fr, 1fr), + gap: 1em, + accent: colour.primary, + ink: colour.neutral-dark, + fill: colour.neutral-light, + size: 1em, + inset: .8em, + radius: .25em, +) = { + assert(type(labels) == array and labels.len() == 2, message: "comparison needs exactly two labels") + assert(type(columns) == array and columns.len() == 2, message: "comparison needs exactly two column widths") + let panel(label, body) = block( + width: 100%, fill: fill, inset: inset, radius: radius, breakable: false, + { + set text(size: size, fill: ink) + set par(spacing: .55em, leading: .4em) + stack(dir: ttb, spacing: .65em, text(weight: "semibold", fill: accent, label), body) + }, + ) + grid(columns: columns, column-gutter: gap, align: top, + panel(labels.first(), before), panel(labels.last(), after), + ) +} diff --git a/packages/preview/lineal/0.2.0/src/components/data-table.typ b/packages/preview/lineal/0.2.0/src/components/data-table.typ new file mode 100644 index 0000000000..cbaef23ded --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/data-table.typ @@ -0,0 +1,54 @@ +#import "../brand.typ": colour + +/// A table with a repeating header, subtle row rules and zero-based row accents. +#let data-table( + headers, + rows, + columns: auto, + align: left, + highlight: (), + striped: true, + accent: colour.primary, + ink: colour.neutral-dark, + header-fill: auto, + header-ink: white, + fill: white, + stripe-fill: rgb("#f3f5f6"), + highlight-fill: auto, + rule: rgb("#dce1e4"), + size: .85em, + inset: (x: .7em, y: .6em), +) = { + assert(type(headers) == array and headers.len() > 0, message: "data-table needs a non-empty headers array") + assert(type(rows) == array, message: "data-table rows must be an array") + assert(rows.all(row => type(row) == array and row.len() == headers.len()), message: "every data-table row must match the header count") + let valid-cell(value) = value == none or type(value) in (content, str, int, float, bool) + assert(headers.all(valid-cell) and rows.all(row => row.all(valid-cell)), message: "data-table cells must be content, strings, numbers, booleans or none") + assert(type(highlight) == array and highlight.all(index => type(index) == int and index >= 0 and index < rows.len()), message: "data-table highlight indices must refer to existing zero-based rows") + if columns != auto { + assert(type(columns) == array and columns.len() == headers.len(), message: "data-table columns must match the header count") + } + let display(value) = { + if value == none { return [] } + if type(value) == bool { return repr(value) } + if type(value) in (int, float) { return str(value) } + value + } + let header-fill = if header-fill == auto { accent } else { header-fill } + let highlight-fill = if highlight-fill == auto { accent.lighten(90%) } else { highlight-fill } + set text(size: size, fill: ink) + set par(spacing: .55em, leading: .4em) + table( + columns: if columns == auto { (1fr,) * headers.len() } else { columns }, + align: align, inset: inset, + stroke: (x: none, y: .5pt + rule), + fill: (_, y) => { + if y == 0 { return header-fill } + if (y - 1) in highlight { return highlight-fill } + if striped and calc.rem(y, 2) == 0 { return stripe-fill } + fill + }, + table.header(..headers.map(value => text(weight: "semibold", fill: header-ink, display(value)))), + ..rows.flatten().map(display), + ) +} diff --git a/packages/preview/lineal/0.2.0/src/components/metric.typ b/packages/preview/lineal/0.2.0/src/components/metric.typ new file mode 100644 index 0000000000..a1e0fedf7e --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/metric.typ @@ -0,0 +1,38 @@ +#import "../brand.typ": colour + +#let text-align = align + +/// A prominent value with a label, optional context and optional change label. +#let metric( + value, + label: none, + detail: none, + change: none, + accent: colour.primary, + ink: colour.neutral-dark, + muted: rgb("#626b73"), + fill: none, + size: 1em, + value-size: 2.5em, + inset: .8em, + radius: .25em, + align: left, +) = block( + width: 100%, inset: inset, fill: fill, radius: radius, breakable: false, + { + set text(size: size, fill: ink) + set par(spacing: .55em, leading: .4em) + set text-align(align) + let items = if label == none { () } else { (text(weight: "semibold", label),) } + items.push(text(size: value-size, weight: "bold", fill: accent, + if type(value) in (int, float) { str(value) } else { value }, + )) + if change != none { + items.push(text(weight: "semibold", fill: accent, change)) + } + if detail != none { + items.push(text(size: .85em, fill: muted, detail)) + } + stack(dir: ttb, spacing: .45em, ..items) + }, +) diff --git a/packages/preview/lineal/0.2.0/src/components/quote.typ b/packages/preview/lineal/0.2.0/src/components/quote.typ new file mode 100644 index 0000000000..385a6f7274 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/quote.typ @@ -0,0 +1,22 @@ +#import "../brand.typ": colour + +/// A quotation with optional attribution and source, usable without the theme. +#let quote(body, author: none, source: none, fill: colour.neutral-light, + ink: colour.neutral-dark, accent: colour.primary, inset: 1em, + radius: .35em, size: 1em, gap: .6em, +) = block(width: 100%, fill: fill, inset: inset, radius: radius, breakable: false, { + set text(size: size, fill: ink) + grid(columns: (auto, 1fr), column-gutter: .4em, align: top, + text(size: 2em, fill: accent)[“], + [#body + #if author != none or source != none { + v(gap) + align(right, [ + #if author != none { text(weight: "semibold", author) } + #if author != none and source != none { linebreak() } + #if source != none { text(size: .85em, source) } + ]) + } + ], + ) +}) diff --git a/packages/preview/lineal/0.2.0/src/components/repository.typ b/packages/preview/lineal/0.2.0/src/components/repository.typ new file mode 100644 index 0000000000..ab0cd03567 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/repository.typ @@ -0,0 +1,48 @@ +#import "../brand.typ": colour, tech-colours + +#let repository-shares(languages) = { + assert(type(languages) == dictionary and languages.len() > 0, + message: "repository: provide at least one language weight") + assert(languages.values().all(value => type(value) in (int, float) and value >= 0), + message: "repository: language weights must be non-negative numbers") + let total = languages.values().sum() + assert(total > 0, message: "repository: language weights must have a positive total") + languages.pairs().filter(pair => pair.last() > 0) + .map(pair => (name: pair.first(), share: pair.last() / total)) +} + +/// Language weights can be percentages, bytes, or any comparable quantities. +#let repository(name: none, languages: (:), url: auto, + colors: tech-colours, ink: colour.neutral-dark, fallback: rgb("8a9599"), + size: 1em, bar-height: .5em, gap: .5em, +) = { + assert(name == none or (type(name) == str and name.trim() != ""), + message: "repository: name must be none or a non-empty string") + let shares = repository-shares(languages) + let fill(item) = colors.at(lower(item.name), default: fallback) + set text(size: size, fill: ink) + block(width: 100%, breakable: false, [ + #if name != none { + link(if url == auto { "https://github.com/" + name } else { url }, + grid(columns: (auto, 1fr), column-gutter: .4em, align: horizon, + image(bytes(read("../static/icons/github.svg").replace(" item.share * 100%), rows: bar-height, + ..shares.map(item => rect(width: 100%, height: bar-height, fill: fill(item), stroke: none)), + ), + ) + #v(gap) + #grid(columns: (auto, 1fr, auto), column-gutter: .5em, row-gutter: .25em, align: horizon, + ..shares.map(item => ( + circle(radius: .2em, fill: fill(item)), + text(size: .85em, item.name), + text(size: .85em, str(calc.round(item.share * 100, digits: 1)) + "%"), + )).flatten(), + ) + ]) +} diff --git a/packages/preview/lineal/0.2.0/src/components/steps.typ b/packages/preview/lineal/0.2.0/src/components/steps.typ new file mode 100644 index 0000000000..83521929b8 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/components/steps.typ @@ -0,0 +1,53 @@ +#import "../brand.typ": colour + +#let step-item(item) = { + if type(item) != dictionary { return (title: item, description: none) } + assert("title" in item, message: "each steps dictionary needs a title") + assert(item.keys().all(key => key in ("title", "description")), message: "steps items accept only title and description") + (title: item.title, description: item.at("description", default: none)) +} + +/// A numbered process. Items are content or (title:, description:) dictionaries. +#let steps( + items, + direction: "vertical", + start: 1, + accent: colour.primary, + ink: colour.neutral-dark, + muted: rgb("#626b73"), + marker-ink: white, + size: 1em, + marker-size: 1.65em, + gap: .85em, + inset: 0pt, +) = { + assert(type(items) == array and items.len() > 0, message: "steps needs a non-empty array of items") + assert(direction in ("vertical", "horizontal"), message: "steps direction must be vertical or horizontal") + assert(type(start) == int and start >= 1, message: "steps start must be a positive integer") + let items = items.map(step-item) + let marker(number) = circle(width: marker-size, height: marker-size, fill: accent, + align(center + horizon, text(fill: marker-ink, size: .75em, weight: "semibold", str(number))), + ) + let description(item) = { + let detail = if item.description == none { () } else { (text(size: .85em, fill: muted, item.description),) } + stack(dir: ttb, spacing: .3em, text(weight: "semibold", item.title), ..detail) + } + block(width: 100%, inset: inset, { + set text(size: size, fill: ink) + set par(spacing: .55em, leading: .4em) + if direction == "horizontal" { + grid(columns: (1fr,) * items.len(), column-gutter: gap, align: top, + ..items.enumerate().map(((index, item)) => block(breakable: false, { + stack(dir: ttb, spacing: .55em, marker(start + index), description(item)) + })), + ) + } else { + grid(columns: (auto, 1fr), column-gutter: .6em, row-gutter: gap, align: top, + ..items.enumerate().map(((index, item)) => ( + marker(start + index), + pad(top: (marker-size - .65em) / 2, description(item)), + )).flatten(), + ) + } + }) +} diff --git a/packages/preview/lineal/0.2.0/src/index.typ b/packages/preview/lineal/0.2.0/src/index.typ new file mode 100644 index 0000000000..d13b4b7b5f --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/index.typ @@ -0,0 +1,17 @@ +#import "brand.typ": brand, colour +#import "@preview/touying:0.5.3": config-info, config-colors, config-common, config-page +#import "layouts.typ" + +#import "outline.typ": lineal-outline +#import "progress.typ": lineal-progress-bar +#import "title.typ": title-slide +#import "theme.typ": lineal-theme + +#import "components/code-block.typ": code-block, file-icon, file-label +#import "components/repository.typ": repository +#import "components/quote.typ": quote +#import "components/callout.typ": callout +#import "components/metric.typ": metric +#import "components/steps.typ": steps +#import "components/data-table.typ": data-table +#import "components/comparison.typ": comparison diff --git a/packages/preview/lineal/0.2.0/src/layouts.typ b/packages/preview/lineal/0.2.0/src/layouts.typ new file mode 100644 index 0000000000..2b1f31e8ef --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/layouts.typ @@ -0,0 +1,158 @@ +#import "@preview/touying:0.5.3": components + +#import "../src/brand.typ": colour, brand + +/// Compose arbitrary content in equally sized or explicitly weighted columns. +#let columns(..bodies, widths: auto, gutter: 1em, align: top) = { + assert(bodies.named().len() == 0, message: "columns: unexpected named option") + let bodies = bodies.pos() + assert(bodies.len() > 0, message: "columns: provide at least one body") + let widths = if widths == auto { (1fr,) * bodies.len() } else { widths } + assert(type(widths) == array and widths.len() == bodies.len(), + message: "columns: widths must contain one track per body") + grid(columns: widths, gutter: gutter, align: align, ..bodies) +} + +/// A surface for content, with an automatic height by default. +#let panel(body, fill: colour.neutral-light, ink: colour.neutral-dark, + stroke: none, inset: 1em, radius: .35em, width: 100%, height: auto, + breakable: false, +) = block(width: width, height: height, fill: fill, stroke: stroke, + inset: inset, radius: radius, breakable: breakable, text(fill: ink, body)) + +/// A fixed frame that reports vertical overflow instead of clipping content. +#let frame(body, width: 100%, height: 200pt, inset: 0pt, + fill: none, stroke: none, radius: 0pt, +) = layout(size => context { + assert(type(width) in (length, ratio, relative), + message: "frame: width must be a length or relative width") + let width = if type(width) == length { width } else if type(width) == ratio { + width * size.width + } else { width.length + width.ratio * size.width } + assert(width > 0pt, message: "frame: width must be positive") + assert(type(height) == length and height > 0pt, + message: "frame: height must be a positive length") + let used = measure(block(width: width, inset: inset, breakable: false, body)).height + assert(used <= height, message: "frame: content exceeds the available height") + block(width: width, height: height, inset: inset, fill: fill, + stroke: stroke, radius: radius, breakable: false, body) +}) + +#let page-center(content) = { + block(width: 100%)[ + #set align(horizon + center) + #components.cell(width: auto)[ + #set align(left) + #content + ] + ] +} + +#let two-boxes(content1, content2) = { + grid( + columns: 2, + column-gutter: 1em, + align: horizon + center, + + block( + height: 100%, + width: 100%, + fill: gradient.linear(colour.primary, colour.primary-light, angle: -30deg), + inset: .5em, + radius: .5em, + )[ + #set text(fill: white) + #content1 + ], + + block( + height: 100%, + width: 100%, + fill: colour.neutral-light, + stroke: colour.primary, + inset: .5em, + radius: .5em, + )[ + #set text(fill: colour.primary) + #content2 + ] + ) +} + +#let contained(content, sidebar) = { + block( + inset: .5em, + radius: .5em, + width: 100%, + height: 100%, + fill: colour.neutral-light, + + grid( + columns: 2, + column-gutter: 1em, + + content, + + block( + height: 100%, + width: 100%, + fill: colour.primary, + inset: .5em, + radius: .25em, + + align(horizon + center)[ + #set text(fill: white) + #sidebar + ] + ) + ) + ) +} + +#let four-columns(..contents) = { + let grid-item(fill, body) = { + block( + width: 100%, + height: 100%, + inset: .5em, + radius: 4pt, + fill: fill, + body + ) + } + + grid( + columns: (1fr,) * 4, + column-gutter: 1em, + align: top + left, + + ..contents.pos().enumerate().map(((index, content)) => { + let lightness = 20% * index + grid-item(colour.primary.lighten(lightness))[ + #set text(fill: if lightness > 30% { black } else { white }) + #content + ] + }) + ) +} + +#let matrix(items) = { + let matrix-item(body, fill: colour.primary) = { + block( + width: 100%, + height: 50% - .5em, + inset: .5em, + radius: .5em, + fill: fill, + body + ) + } + + grid( + columns: (1fr,) * 3, + rows: 2, + gutter: 1em, + align: horizon + center, + ..items.map(item => matrix-item[#text(fill: white, item)]) + ) +} diff --git a/packages/preview/lineal/0.2.0/src/outline.typ b/packages/preview/lineal/0.2.0/src/outline.typ new file mode 100644 index 0000000000..0a2f84a470 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/outline.typ @@ -0,0 +1,221 @@ +#import "@preview/touying:0.5.3": * + +#import "brand.typ": colour +#import "progress.typ": lineal-progress-bar + +#let current-heading(level: auto, outlined: true) = { + let here = here() + query(heading.where(outlined: outlined).before(inclusive: false, here)) + .at(-1, default: none) +} + +#let headings-context(target: heading.where(outlined: true)) = { + let current-heading = current-heading() + + let headings = query(target) + let path = () + let level = 0 + let current-heading-idx = none + + let result = () + + for hd in headings { + let diff = hd.level - level + if diff > 0 { + for _ in range(diff) { + path.push(1) + } + } else { + for _ in range(-diff) { + let _ = path.pop() + } + path.at(-1) = path.at(-1) + 1 + } + + result.push(( + path: path, + heading: hd + )) + + if current-heading != none and hd.location() == current-heading.location() { // matching on location, otherwise this will be `true` for an identical heading at another location + current-heading-idx = result.len() - 1 + } + + level = hd.level + } + + return ( + current-heading: current-heading, + current-heading-idx: current-heading-idx, + headings: result + ) +} + + + +#let lineal-outline( + filter: hd => true, + transform: (_, it) => it, + target: heading.where(outlined: true), + ..args +) = context { + let cx = headings-context(target: target) + let idx = cx.at("current-heading-idx", default: none) + let scope-path = if idx != none { + cx.headings.at(idx).path + } + + let headings = cx.headings.map(hd => { + let level = hd.path.len() + + let relation = if scope-path != none { + let path-len = calc.min(level, scope-path.len()) + let common-path = hd.path.slice(0, path-len) == scope-path.slice(0, path-len) + + let same = hd.path == scope-path + let parent = not same and common-path and level < scope-path.len() + let child = not same and common-path and level > scope-path.len() + let sibling = not same and level == scope-path.len() and hd.path.slice(0, path-len - 1) == scope-path.slice(0, path-len - 1) + + ( + same: same, + parent: parent, + child: child, + sibling: sibling, + unrelated: not same and not parent and not child and not sibling, + ) + } + + ( + here-path: scope-path, + here-level: if scope-path != none { scope-path.len() }, + level: level, + path: hd.path, + relation: relation, + loc: hd.heading.location(), + heading: hd.heading + ) + }).filter(filter) + + if headings == () { + let nonsense-target = selector(

).and() + outline(target: nonsense-target, ..args) + return + } + + let find-heading(location) = { + for x in headings { // forgive me :( + if x.loc == location { + return x + } + } + } + + place( + top, + move( + dy: -15%, + block(width: 100%)[ + #grid( + align: top, + columns: (1fr, 2fr), + column-gutter: 2em, + { + // Left column: all section headings + show outline.entry: it => { + let hd = find-heading(it.element.location()) + if hd.relation != none and hd.relation.same { + // Active section - mark its position + box(width: 100%)[ + #set align(right) + #h(1em) + #link( + it.element.location(), + transform(hd, it.body()), + ) + ] + } else { + // Regular section + box(width: 100%)[ + #set align(right) + #h(1em) + #link( + it.element.location(), + transform(hd, it.body()), + ) + ] + } + } + + let all_sections = headings.filter(hd => hd.level == 1) + let section_selector = if all_sections != () { + let sel = selector(all_sections.at(0).loc) + for idx in range(1, all_sections.len()) { + sel = sel.or(all_sections.at(idx).loc) + } + sel + } else { + selector(

).and() + } + + outline(target: section_selector, indent: 0em, ..args) + }, + { + // Right column: current section and its subsections + show outline.entry: it => { + let hd = find-heading(it.element.location()) + if hd.level == 1 { + // Section heading + box(width: 100%)[ + #link( + it.element.location(), + transform(hd, it.body()), + ) + #v(-0.25em) + #box(width: 100%)[ + #let pages-in-section = headings.filter(h => h.level == 1).len() - 1 + #let section-ratio = (hd.path.at(0) - 1) / (if pages-in-section > 0 { pages-in-section } else { 1 }) + #lineal-progress-bar(variant: "broad") + ] + #v(0.5em) + ] + } else { + // Subsection heading with hyphen + box(width: 100%)[ + #set list(marker: text(fill: colour.neutral-dark.transparentize(60%))[---]) + - #link( + it.element.location(), + transform(hd, it.body()), + ) + ] + } + } + + let current_section = if scope-path != none { + // Find the root section for current heading + let root_path = scope-path.slice(0, 1) + headings.filter(hd => + hd.path.slice(0, 1) == root_path and + (hd.level == 1 or hd.relation.child or hd.relation.same) + ) + } else { + () + } + + let section_selector = if current_section != () { + let sel = selector(current_section.at(0).loc) + for idx in range(1, current_section.len()) { + sel = sel.or(current_section.at(idx).loc) + } + sel + } else { + selector(

).and() + } + + outline(target: section_selector, ..args) + } + ) + ] + ) + ) +} diff --git a/packages/preview/lineal/0.2.0/src/page-statistics.typ b/packages/preview/lineal/0.2.0/src/page-statistics.typ new file mode 100644 index 0000000000..6de56a2f1e --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/page-statistics.typ @@ -0,0 +1,204 @@ +#import "@preview/touying:0.5.3": * + +#let get-current-section-number() = ( + context { + let heading-selector = selector(heading.where(level: 1)).before(here()) + let level = counter(heading-selector) + let headings = query(heading-selector) + if headings.len() == 0 { return 0 } + let heading = headings.last() + level.get().first() + } +) + +#let is-last-section() = { + context { + let total-sections = query(selector(heading.where(level: 1))).len() + + let selector = selector(heading.where(level: 1)).before(here()) + let level = counter(selector) + let headings = query(selector) + if headings.len() == 0 { return 0 } + let heading = headings.last() + let current-section-number = level.get().first() + + return total-sections == current-section-number + } +} + + +#let page-stats = [ + #set text(size: 12pt) + #context { + let total-sections = query(selector(heading.where(level: 1))).len() + [Total sections: #{total-sections}] + } + + #context { + let slide-count = utils.last-slide-number + [Total number of slides: #{slide-count}] + } + + #context { + [Current section number: #{get-current-section-number()}] + } + + #context { + let page-number-of-current-section-start = utils.current-heading(level: 1).location().page() + [Page number of start of current section: #{page-number-of-current-section-start}] + } + + #v(1em) + + #context { + let number-of-slides-in-previous-sections = ( + query(selector(heading.where(level: 2)).before(here(), inclusive: false)).len() + // Exclude the current slide's title element + - 2 + // Number of new section slides + + query(selector(heading.where(level: 1)).before(here())).len() + ) + [\# slides in previous sections: #{number-of-slides-in-previous-sections}] + } + + #v(1em) + + #context { + let number-of-slides-in-current-section = ( + query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() + + 2 + ) + [\# slides in current section: #{number-of-slides-in-current-section}] + } + + #v(1em) + + #context { + // High level stats + let total-sections = query(selector(heading.where(level: 1))).len() + let current-heading = utils.current-heading(level: 1) + + // Determine if this is the last section + let heading-selector = selector(heading.where(level: 1)).before(here()) + let level = counter(heading-selector) + let headings = query(heading-selector) + if headings.len() == 0 { return 0 } + let current-section-number = level.get().first() + + let is-last-section = total-sections == current-section-number + + // Calculate remaining slides + let number-of-slides-in-current-section = ( + query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() + ) + + let remaining-slide-count = if is-last-section { + 0 + } else { + utils.last-slide-counter.final().first() - (utils.slide-counter.get().first() + number-of-slides-in-current-section) + } + + [\# slides in next sections: #{remaining-slide-count}] + } + + #v(1em) + + #context { + let prev = ( + query(selector(heading.where(level: 2)).before(here(), inclusive: false)).len() + // Exclude the current slide's title element + - 2 + // Number of new section slides + + query(selector(heading.where(level: 1)).before(here())).len() + ) + + let current = ( + query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() + + 2 + ) + + // High level stats + let total-sections = query(selector(heading.where(level: 1))).len() + let current-heading = utils.current-heading(level: 1) + + // Determine if this is the last section + let heading-selector = selector(heading.where(level: 1)).before(here()) + let level = counter(heading-selector) + let headings = query(heading-selector) + if headings.len() == 0 { return 0 } + let current-section-number = level.get().first() + + let is-last-section = total-sections == current-section-number + + // Calculate remaining slides + let number-of-slides-in-current-section = ( + query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() + ) + + let next = if is-last-section { + 0 + } else { + utils.last-slide-counter.final().first() - (utils.slide-counter.get().first() + number-of-slides-in-current-section) + } + + let total = prev + current + next + + // Array of proportions of slides in previous sections, current section, and next sections + let pages = ( + calc.round(prev / total, digits: 2), + calc.round(current / total, digits: 2), + calc.round(next / total, digits: 2), + ) + pages + } + + #context { + let current-section-start = utils.current-heading(level: 1).location() + + let historic-section-slides = ( + query(selector(heading.where(level: 2)).before(current-section-start, inclusive: false)).len() + + query(selector(heading.where(level: 1)).before(here(), inclusive: false)).len() + - 1 + ) + + let slides-covered-in-current-section = ( + query(selector(heading.where(level: 2)).after(current-section-start).before(here(), inclusive: false)).len() + ) + + let slides-remaining-in-current-section = ( + query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() + ) + + // Determine if this is the last section + let total-sections = query(selector(heading.where(level: 1))).len() + let heading-selector = selector(heading.where(level: 1)).before(here()) + let level = counter(heading-selector) + let headings = query(heading-selector) + if headings.len() == 0 { return 0 } + let current-section-number = level.get().first() + + let is-last-section = total-sections == current-section-number + + let future-section-slides = if is-last-section { + 0 + } else { + ( + query(selector(heading.where(level: 2)).after(here()).after(selector(heading.where(level: 1)).after(here()))).len() + + query(selector(heading.where(level: 1)).after(here())).len() + + 1 + ) + } + + ( + historic-section-slides, + slides-covered-in-current-section, + slides-remaining-in-current-section, + future-section-slides, + + historic-section-slides + + slides-covered-in-current-section + + slides-remaining-in-current-section + + future-section-slides + ) + } +] diff --git a/packages/preview/lineal/0.2.0/src/progress.typ b/packages/preview/lineal/0.2.0/src/progress.typ new file mode 100644 index 0000000000..78166541b8 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/progress.typ @@ -0,0 +1,51 @@ +#import "@preview/touying:0.5.3": utils +#import "brand.typ": colour + +// Use slide counters so title slides and overlays do not skew progress. +#let progress-ratios(current, total, start: 1, end: auto, variant: "exact") = { + assert(variant in ("broad", "near", "exact"), message: "progress: unknown variant") + if total <= 0 { return (broad: (1, 0), near: (0, 1, 0), exact: (0, 1, 0, 0)).at(variant) } + let current = calc.clamp(current, 0, total) + let before = calc.clamp(start - 1, 0, current) + let end = if end == auto { total } else { calc.clamp(end, current, total) } + let parts = ( + broad: (current, total - current), + near: (before, end - before, total - end), + exact: (before, current - before, end - current, total - end), + ) + parts.at(variant).map(value => value / total) +} + +#let lineal-progress-bar( + variant: "exact", height: 2pt, ratios: none, + accent: colour.primary, remaining: colour.primary-light, +) = context { + assert(variant in ("broad", "near", "exact"), message: "progress: unknown variant") + let total = utils.last-slide-counter.final().first() + let current = utils.slide-counter.get().first() + let sections = query(heading.where(level: 1)) + let previous = sections.filter(section => section.location().position().page <= here().position().page) + let future = sections.filter(section => section.location().position().page > here().position().page) + let start = if previous.len() > 0 { + utils.slide-counter.at(previous.last().location()).first() + } else { 1 } + let end = if future.len() > 0 { + utils.slide-counter.at(future.first().location()).first() - 1 + } else { total } + let values = if ratios == none { + progress-ratios(current, total, start: start, end: end, variant: variant) + } else { ratios } + let fills = ( + broad: (accent, remaining), + near: (black.transparentize(60%), accent, remaining), + exact: (black.transparentize(60%), accent, remaining, black.transparentize(80%)), + ).at(variant) + assert(type(values) == array and values.len() == fills.len(), + message: "progress: ratios must match the variant's segment count") + assert(values.all(value => type(value) in (int, float) and value >= 0) and values.sum() > 0, + message: "progress: ratios must be non-negative numbers with a positive total") + let segments = values.enumerate().filter(pair => pair.last() > 0) + grid(columns: segments.map(pair => pair.last() / values.sum() * 100%), rows: height, + ..segments.map(pair => rect(width: 100%, height: height, fill: fills.at(pair.first()), stroke: none)), + ) +} diff --git a/packages/preview/lineal/0.2.0/src/static/icons/README.md b/packages/preview/lineal/0.2.0/src/static/icons/README.md new file mode 100644 index 0000000000..30663b8dcd --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/README.md @@ -0,0 +1,8 @@ +# GitHub icon + +`github.svg` is `mark-github-16.svg` from GitHub's Primer Octicons at revision +`6dbe38e4e9b4987b2c9332d719c57fbbdd083e2d`: +https://github.com/primer/octicons/blob/6dbe38e4e9b4987b2c9332d719c57fbbdd083e2d/icons/mark-github-16.svg + +Its MIT license is included in `octicons-LICENSE`. GitHub's trademark rights +remain with GitHub. Code file icons have their own notices in `code/`. diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/file-code.svg b/packages/preview/lineal/0.2.0/src/static/icons/code/file-code.svg new file mode 100644 index 0000000000..84c01e1ab7 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/file-code.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/lucide-LICENSE b/packages/preview/lineal/0.2.0/src/static/icons/code/lucide-LICENSE new file mode 100644 index 0000000000..718bb3f0e4 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/lucide-LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +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/lineal/0.2.0/src/static/icons/code/provenance.json b/packages/preview/lineal/0.2.0/src/static/icons/code/provenance.json new file mode 100644 index 0000000000..400595ea2f --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/provenance.json @@ -0,0 +1,16 @@ +{ + "simple_icons": { + "repository": "https://github.com/simple-icons/simple-icons", + "commit": "4d29a35c954699b389a834aacc97dd6e7e74143c", + "license": "CC0-1.0", + "assets": ["typescript.svg", "react.svg", "svelte.svg"], + "adaptation": "Added brand fill colours without changing path geometry. The renderer recolours the Svelte mark to TypeScript blue for .svelte.ts files." + }, + "lucide": { + "repository": "https://github.com/lucide-icons/lucide", + "commit": "1ef60af944de207e0d5176905a0759ba12e6ee8d", + "license": "ISC", + "assets": ["file-code.svg"], + "adaptation": "None. The renderer replaces currentColor with the requested tint." + } +} diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/react.svg b/packages/preview/lineal/0.2.0/src/static/icons/code/react.svg new file mode 100644 index 0000000000..88b9c7710e --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/react.svg @@ -0,0 +1 @@ +React \ No newline at end of file diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/simple-icons-LICENSE.md b/packages/preview/lineal/0.2.0/src/static/icons/code/simple-icons-LICENSE.md new file mode 100644 index 0000000000..70d4a7b674 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/simple-icons-LICENSE.md @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/svelte.svg b/packages/preview/lineal/0.2.0/src/static/icons/code/svelte.svg new file mode 100644 index 0000000000..a3f86d5b89 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/svelte.svg @@ -0,0 +1 @@ +Svelte \ No newline at end of file diff --git a/packages/preview/lineal/0.2.0/src/static/icons/code/typescript.svg b/packages/preview/lineal/0.2.0/src/static/icons/code/typescript.svg new file mode 100644 index 0000000000..688ddcc2f7 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/code/typescript.svg @@ -0,0 +1 @@ +TypeScript \ No newline at end of file diff --git a/packages/preview/lineal/0.2.0/src/static/icons/github.svg b/packages/preview/lineal/0.2.0/src/static/icons/github.svg new file mode 100644 index 0000000000..0e55a8ef2d --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/lineal/0.2.0/src/static/icons/octicons-LICENSE b/packages/preview/lineal/0.2.0/src/static/icons/octicons-LICENSE new file mode 100644 index 0000000000..aa1fa80ed8 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/icons/octicons-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GitHub Inc. + +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/lineal/0.2.0/src/static/title-bg.svg b/packages/preview/lineal/0.2.0/src/static/title-bg.svg new file mode 100644 index 0000000000..5f10114f8d --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/static/title-bg.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/preview/lineal/0.2.0/src/theme.typ b/packages/preview/lineal/0.2.0/src/theme.typ new file mode 100644 index 0000000000..ed138e3fd5 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/theme.typ @@ -0,0 +1,206 @@ +#import "@preview/touying:0.5.3": * + +#import "brand.typ": colour +#import "outline.typ": lineal-outline +#import "progress.typ": lineal-progress-bar + +#let multicolumns(columns: auto, alignment: top, gutter: 1em, ..bodies) = { + let bodies = bodies.pos() + if bodies.len() == 1 { + return bodies.first() + } + let columns = if columns == auto { + (1fr,) * bodies.len() + } else { + columns + } + grid(columns: columns, gutter: gutter, align: alignment, ..bodies) +} + +#let _typst-builtin-align = align + + + +#let slide( + title: auto, + align: auto, + config: (:), + repeat: auto, + setting: body => body, + composer: auto, + ..bodies, +) = touying-slide-wrapper(self => { + if align != auto { + self.store.align = align + } + // restore typst builtin align function + let align = _typst-builtin-align + let header(self) = { + set align(top) + show: components.cell.with(inset: (x: 2em)) + set align(bottom) + components.left-and-right( + text( + fill: self.colors.primary, + weight: "bold", + size: 1em, + if title != auto { + utils.fit-to-width.with(grow: false, 100%, title) + } else { + utils.call-or-display(self, self.store.header) + } + ), + text( + fill: self.colors.primary, + weight: "bold", + size: 0.6em, + utils.call-or-display(self, self.store.header-right) + ), + ) + + } + let footer(self) = { + set align(bottom) + set text(size: 0.8em) + show: components.cell.with(inset: (x: 2em, y: -1em)) + if self.store.footer-progress { + box(width: 100%)[ + #lineal-progress-bar(variant: "exact", accent: self.colors.primary, remaining: self.colors.primary-light) + ] + } + show: components.cell.with(inset: (y: 2em)) + components.left-and-right( + text(fill: self.colors.neutral-darkest.lighten(40%), utils.call-or-display(self, self.store.footer-left)), + text(fill: self.colors.neutral-darkest, utils.call-or-display(self, self.store.footer-right)), + ) + } + let self = utils.merge-dicts( + self, + config-page( + fill: self.colors.neutral-lightest, + header: header, + footer: footer, + ), + ) + let new-setting = body => { + show: align.with(self.store.align) + set text(fill: self.colors.neutral-darkest) + show heading.where(level: self.slide-level + 1): it => { + stack( + dir: ltr, spacing: .4em, + square(size: .35em, fill: self.colors.primary), + text(fill: self.colors.primary, it.body) + ) + } + set enum(numbering: (nums) => { + text(fill: self.colors.primary, weight: "bold", str(nums) + ".") + }) + set list(marker: (level) => { + place(dy: 2pt, square(fill: self.colors.primary, radius: 1.5pt, size: 8pt)) + h(.5em) + }) + set table(stroke: self.colors.primary) + show: setting + block(inset: (bottom: 1em), body) + } + let composer = if composer == auto { multicolumns } else { composer } + touying-slide(self: self, config: config, repeat: repeat, setting: new-setting, composer: composer, ..bodies) +}) + + + +#let new-section-slide(level: 1, numbered: true, body) = touying-slide-wrapper(self => { + let slide-body = { + set align(horizon) + show: pad.with(left: 5%, right: 10%) + + lineal-outline( + title: none, + filter: hd => hd.relation != none and not hd.relation.unrelated, + depth: 2, + transform: (hd, it) => { + set text(size: 1.25em, fill: self.colors.primary, weight: "bold") if hd.relation != none and hd.relation.same + set text(fill: self.colors.primary) if hd.relation != none and hd.relation.child + set text(fill: text.fill.transparentize(60%)) if hd.relation != none and hd.relation.sibling + + // Extract just the heading text without numbering + let heading-text = hd.heading.body + heading-text + } + ) + + body + } + self = utils.merge-dicts( + self, + config-page(fill: self.colors.neutral-lightest), + ) + touying-slide(self: self, slide-body) +}) + + + +#let focus-slide(align: horizon + center, body) = touying-slide-wrapper(self => { + let _align = align + let align = _typst-builtin-align + self = utils.merge-dicts( + self, + config-common(freeze-slide-counter: true), + config-page(fill: self.colors.neutral-lightest, margin: 2em), + ) + set text(fill: self.colors.primary, size: 1.5em) + touying-slide(self: self, align(_align, body)) +}) + + + +#let lineal-theme( + aspect-ratio: "16-9", + align: horizon, + header: self => utils.display-current-heading(setting: utils.fit-to-width.with(grow: false, 100%), depth: self.slide-level), + logo: auto, + header-right: context utils.slide-counter.display() + " / " + utils.last-slide-number, + footer-left: self => pad(self.info.logo, y: -.2em), + footer-right: self => self.info.title, + footer-progress: true, + ..args, + body, +) = { + let logo-config = if logo == auto { () } else { (config-info(logo: logo),) } + show: touying-slides.with( + config-page( + paper: "presentation-" + aspect-ratio, + header-ascent: 30%, + footer-descent: 30%, + margin: (top: 3em, bottom: 1.5em, x: 2em), + ), + config-common( + slide-fn: slide, + new-section-slide-fn: new-section-slide, + ), + config-methods( + init: (self: none, body) => { + set text(size: 20pt) + show highlight: body => text(fill: self.colors.primary, strong(body.body)) + body + }, + alert: utils.alert-with-primary-color + ), + config-colors(..colour), + // save the variables for later use + config-store( + align: align, + header: header, + header-right: header-right, + footer-left: footer-left, + footer-right: footer-right, + footer-progress: footer-progress, + ), + config-info( + datetime-format: "[day] [month repr:short] [year]" + ), + ..args, + ..logo-config, + ) + body +} diff --git a/packages/preview/lineal/0.2.0/src/title.typ b/packages/preview/lineal/0.2.0/src/title.typ new file mode 100644 index 0000000000..48d4b90024 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/title.typ @@ -0,0 +1,69 @@ +#import "@preview/touying:0.5.3": * + +#import "brand.typ": colour + +#let title-slide( + extra: none, + ..args, +) = touying-slide-wrapper(self => { + let info = self.info + args.named() + let body = { + set text(fill: self.colors.neutral-darkest) + + place( + horizon + center, + image("static/title-bg.svg") + ) + + block( + width: 60%, + inset: 2cm, + breakable: false, + align( + horizon + left, + { + v(40%) + block( + inset: 0em, + breakable: false, + { + stack( + dir: ltr, + spacing: 1fr, + text(size: 40pt, info.title), + info.logo, + ) + line( + length: 100%, + stroke: 2pt + self.colors.primary, + ) + if info.subtitle != none { + parbreak() + text(size: 1.2em, info.subtitle) + } + }, + ) + set text(size: .8em) + v(1em) + if info.institution != none { + parbreak() + text(size: .9em, info.institution) + } + if info.date != none { + v(20%) + text(size: .8em, utils.display-info-date(self)) + } + }, + ) + ) + } + self = utils.merge-dicts( + self, + config-common(freeze-slide-counter: true), + config-page( + fill: self.colors.neutral-light, + margin: 0em + ), + ) + touying-slide(self: self, body) +}) diff --git a/packages/preview/lineal/0.2.0/typst.toml b/packages/preview/lineal/0.2.0/typst.toml new file mode 100644 index 0000000000..ba3029373a --- /dev/null +++ b/packages/preview/lineal/0.2.0/typst.toml @@ -0,0 +1,12 @@ +[package] +name = "lineal" +version = "0.2.0" +entrypoint = "lib.typ" +authors = ["ellsphillips <@ellsphillips>"] +license = "MIT" +description = "Clean slide decks and reusable presentation components" +compiler = "0.13.1" +repository = "https://github.com/ellsphillips/lineal" +keywords = ["typst", "touying", "slide", "theme", "template", "lecture", "presentation"] +categories = ["presentation"] +exclude = [".github/", "tests/", "scripts/", "demo/", "assets/", "Justfile"] From fe5690e1c53988bdcb248def0ac53a68f4872008 Mon Sep 17 00:00:00 2001 From: ellsphillips Date: Sat, 17 Oct 2026 17:52:18 +0100 Subject: [PATCH 2/3] fix(lineal): synchronize verified navigation and progress models --- packages/preview/lineal/0.2.0/README.md | 9 +- .../preview/lineal/0.2.0/docs/components.md | 4 +- .../preview/lineal/0.2.0/docs/development.md | 7 + packages/preview/lineal/0.2.0/docs/layouts.md | 6 +- .../lineal/0.2.0/docs/navigation-model.md | 68 +++++ .../preview/lineal/0.2.0/docs/navigation.md | 165 ++++++++++ .../preview/lineal/0.2.0/docs/progress.md | 96 ++++++ packages/preview/lineal/0.2.0/src/index.typ | 4 +- packages/preview/lineal/0.2.0/src/layouts.typ | 2 +- .../0.2.0/src/navigation/heading-model.typ | 197 ++++++++++++ .../0.2.0/src/navigation/progress-model.typ | 54 ++++ packages/preview/lineal/0.2.0/src/outline.typ | 288 +++++------------- .../lineal/0.2.0/src/page-statistics.typ | 204 ------------- .../preview/lineal/0.2.0/src/progress.typ | 89 +++--- packages/preview/lineal/0.2.0/src/theme.typ | 51 ++-- packages/preview/lineal/0.2.0/src/title.typ | 2 +- 16 files changed, 774 insertions(+), 472 deletions(-) create mode 100644 packages/preview/lineal/0.2.0/docs/navigation-model.md create mode 100644 packages/preview/lineal/0.2.0/docs/navigation.md create mode 100644 packages/preview/lineal/0.2.0/docs/progress.md create mode 100644 packages/preview/lineal/0.2.0/src/navigation/heading-model.typ create mode 100644 packages/preview/lineal/0.2.0/src/navigation/progress-model.typ delete mode 100644 packages/preview/lineal/0.2.0/src/page-statistics.typ diff --git a/packages/preview/lineal/0.2.0/README.md b/packages/preview/lineal/0.2.0/README.md index 755e988554..5110b5abd8 100644 --- a/packages/preview/lineal/0.2.0/README.md +++ b/packages/preview/lineal/0.2.0/README.md @@ -6,7 +6,7 @@ Lineal provides a slide theme, composable layouts and components for talks, teaching material and project reviews. The components also work in ordinary Typst documents. Content stays editable and selectable in the exported PDF. -![A team update composed with Lineal components](https://raw.githubusercontent.com/ellsphillips/lineal/main/assets/img/components.png) +![A team update composed with Lineal components](https://raw.githubusercontent.com/ellsphillips/lineal/941f266c40b5b24ba881fded1e6a52d6eafec8de/assets/img/components.png) ## Quick start @@ -93,13 +93,18 @@ require the slide theme, JavaScript or network requests while rendering. | `comparison(before, after, labels: ...)` | Two labelled content areas with configurable proportions. | | `quote(author: ..., source: ...)[body]` | Quotations with optional rich attribution. | | `repository(name: ..., languages: ...)` | A repository link and a language bar with normalised percentages. | +| `lineal-outline()` and `lineal-toc()` | Contextual section navigation and a complete, flowing table of contents. | +| `lineal-progress-bar(variant: ...)` | Logical slide progress with correct section, overlay and handout counts. | Detailed signatures and working examples: - [Code blocks and file labels](docs/code-block.md) - [Callouts, metrics, steps, tables and comparisons](docs/components.md) - [Layouts, quotes, repositories and theme configuration](docs/layouts.md) -- [Complete component gallery](https://github.com/ellsphillips/lineal/blob/main/demo/main.typ) +- [Outlines and tables of contents](docs/navigation.md) +- [Progress calculations and counter semantics](docs/progress.md) +- [Heading hierarchy and overlay identity](docs/navigation-model.md) +- [Complete component gallery](https://github.com/ellsphillips/lineal/blob/941f266c40b5b24ba881fded1e6a52d6eafec8de/demo/main.typ) ## Composition diff --git a/packages/preview/lineal/0.2.0/docs/components.md b/packages/preview/lineal/0.2.0/docs/components.md index 7bfda04e38..db9e88aaf7 100644 --- a/packages/preview/lineal/0.2.0/docs/components.md +++ b/packages/preview/lineal/0.2.0/docs/components.md @@ -170,5 +170,5 @@ keep their natural heights. Set `columns: (2fr, 3fr)` for asymmetric examples or Choose an appropriate `header-ink` for a custom table header, or `marker-ink` for light step markers. For further working examples, see -[`tests/components.typ`](https://github.com/ellsphillips/lineal/blob/main/tests/components.typ) and -[`tests/components-custom.typ`](https://github.com/ellsphillips/lineal/blob/main/tests/components-custom.typ). +[`tests/components.typ`](https://github.com/ellsphillips/lineal/blob/941f266c40b5b24ba881fded1e6a52d6eafec8de/tests/components.typ) and +[`tests/components-custom.typ`](https://github.com/ellsphillips/lineal/blob/941f266c40b5b24ba881fded1e6a52d6eafec8de/tests/components-custom.typ). diff --git a/packages/preview/lineal/0.2.0/docs/development.md b/packages/preview/lineal/0.2.0/docs/development.md index 3ce3b07c4c..622db2d4fb 100644 --- a/packages/preview/lineal/0.2.0/docs/development.md +++ b/packages/preview/lineal/0.2.0/docs/development.md @@ -30,6 +30,8 @@ resources without replacing a globally cached or installed package. values, and `error` as the expected assertion-message substring. Compilation tests catch broken imports, missing assets and invalid inputs. +Successful fixtures must also compile without warnings; non-converging layouts +and missing fonts fail the check even when Typst produces a PDF. Render new layouts and inspect their PDF output before merging visual changes. ## Packaging @@ -48,6 +50,11 @@ global `@preview` namespace. ## Publishing +Lineal currently pins Touying 0.7.4 to retain Typst 0.13.1 compatibility. Touying +0.8.0 requires Typst 0.15.0. Upgrading the presentation engine therefore needs +an explicit compiler-support change and the navigation/overlay regression suite. +The registry's older-import notices reflect this compatibility choice. + 1. Update `typst.toml`, documented imports and the package fixtures together. 2. Run `./scripts/check` with both supported compiler versions and inspect the demo. 3. Merge the reviewed source and tag that exact commit. diff --git a/packages/preview/lineal/0.2.0/docs/layouts.md b/packages/preview/lineal/0.2.0/docs/layouts.md index 4649e1b217..8fd88dae02 100644 --- a/packages/preview/lineal/0.2.0/docs/layouts.md +++ b/packages/preview/lineal/0.2.0/docs/layouts.md @@ -82,7 +82,11 @@ colours, slide behaviour and page layout without a second import. ``` Theme options include `header`, `header-right`, `footer-left`, `footer-right`, -`footer-progress` and `logo`. `logo: auto` keeps the logo in `config-info`; +`footer-progress`, `logo`, `section-depth` and `section-numbered`. +`section-depth: 2` limits automatic section outlines to heading levels one and +two. Use `section-depth: 1` for a shorter section list. Oversized automatic +outlines raise an error instead of clipping. `section-numbered: false` excludes +section cards from the logical slide count. `logo: auto` keeps the logo in `config-info`; explicit content overrides it for the title and footer. Set a running header or footer field to `none` to omit it. Theme colours configure the slide frame. Individual content components accept local colours so they can also work in diff --git a/packages/preview/lineal/0.2.0/docs/navigation-model.md b/packages/preview/lineal/0.2.0/docs/navigation-model.md new file mode 100644 index 0000000000..9726785636 --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/navigation-model.md @@ -0,0 +1,68 @@ +# Heading model + +`src/navigation/heading-model.typ` separates document structure from outline +presentation. It has no Touying dependency. The outline UI uses its query +adapter; structural tests call the pure model directly. + +```typst +#import "../src/navigation/heading-model.typ": build-heading-model + +#let model = build-heading-model( + ((id: "section", level: 1), (id: "slide", level: 3)), + current: "slide", +) +#assert.eq(model.entries.last().path, (1, 1)) +``` + +Every input entry needs a unique non-`none` `id` and a positive integer `level`. +Additional fields are retained. The query adapter supplies `heading`, `loc` and +`outlined` fields using actual Typst heading locations as stable IDs. + +The tree includes every authored heading, including `outlined: false` headings. +A node belongs to its nearest preceding lower-level ancestor. Missing heading +levels do not create synthetic parents. Printed numbering, repeated titles, +target selectors and display filters do not change its ownership. + +Each entry carries: + +- `level`: the original Typst heading level. +- `depth`, `path`: normalized structural depth and sibling ordinals. +- `index`, `subtree-end`: an exclusive interval containing the node and descendants. +- `parent-index`, `parent-id`, `root-index`, `root-id`: structural ownership. +- `relation`: `same`, `parent` (any ancestor), `child` (any descendant), `sibling` + or `unrelated`, relative to the actual current heading. Exactly one is true. +- `is-current`, `is-active`: actual position and nearest visible ancestor. +- `here-path`, `here-level`, `here-depth`: current-heading context for transforms. + +The model returns `entries` for the whole tree, `visible` for the presentation +projection, `current` for the actual current heading, `section` for its structural +root and `active` for its closest visible ancestor, including itself. Before the +first heading, `current`, `section`, `active` and each `relation` are `none`. + +`selected` accepts `auto` or an array of IDs. `depth` retains native outline +semantics: it limits the original absolute heading level. `filter` accepts an +annotated entry and returns a boolean. These options only change `visible`. +Filter predicates can use `is-current` and `relation`. `is-active` is computed +after selection for transforms and model output. + +Call `get-heading-model(target: heading.where(outlined: true), depth: none, +filter: entry => true)` inside a Typst `context`. Targets must select headings. +The `current-heading(level: auto, outlined: true)` compatibility helper honors +both arguments. `headings-context(target: ...)` returns the visible entries and +the active heading with its index in that visible array. + +## Overlays + +Touying 0.7.4 emits logical-slide and physical-subslide metadata. Its authored +section and slide headings appear once, while body headings can be replicated +across overlay pages. The query adapter maps those replicas back to the first +location at the same ordinal in a logical slide's heading sequence. Two authored +headings with identical text retain distinct ordinals and IDs. + +Body heading sequences must stay identical or extend as a cumulative prefix +across overlays. Removing, reordering or changing earlier headings raises an +assertion. Use ordinary styled text for conditional overlay labels, or move +headings outside overlay functions. Swapping indistinguishable conditional +headings at the same ordinal is outside this contract: Typst's query API does +not expose their original source identities. Ordinary documents have no overlay +deduplication and preserve every authored heading location. diff --git a/packages/preview/lineal/0.2.0/docs/navigation.md b/packages/preview/lineal/0.2.0/docs/navigation.md new file mode 100644 index 0000000000..f95bb15be4 --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/navigation.md @@ -0,0 +1,165 @@ +# Navigation and contents + +Lineal provides a contextual outline for section navigation and a complete table +of contents for agendas, handouts and longer documents. Both render ordinary, +selectable Typst content with links to the original headings. + +```typst +#import "@preview/lineal:0.2.0": lineal-outline, lineal-toc + +#lineal-toc(title: [Workshop programme], depth: 2) +``` + +Titles are plain content, not generated headings. They appear once and do not +change the slide header, heading hierarchy, section ownership or progress bars. + +## Contextual outline + +```typst +#lineal-outline(title: [This section], depth: 3) +``` + +With a current section, the left column lists visible level-one headings. The +right column includes the entire current section: its heading, topics, siblings +and descendants. It does not narrow the list to descendants of the current topic. +The current heading and its visible ancestors receive emphasis. + +Before the first heading, or in a document without level-one sections, the outline +falls back to a complete hierarchy. If no section headings are selected, the +current section's visible content uses the full width. + +```typst +lineal-outline( + title: none, target: heading.where(outlined: true), depth: none, + filter: entry => true, transform: (_, body) => body, + columns: (1fr, 2fr), gutter: 2em, + indent: 1.1em, gap: .45em, size: 1em, + ink: auto, accent: auto, + page-numbers: false, page-numbering: "1", fill: none, +) +``` + +`columns` contains two track widths. `gutter` separates the columns. Section names +align to the right in the left column; the current hierarchy aligns to the left. + +## Complete table of contents + +```typst +#lineal-toc( + title: [Contents], depth: 3, + page-numbers: true, fill: repeat[.], +) +``` + +`lineal-toc` always includes the complete selected hierarchy, regardless of where +it appears in the document. Deeper entries are indented. Root groups have a small +vertical separation, so unrelated sections remain distinct. + +```typst +lineal-toc( + title: [Contents], target: heading.where(outlined: true), depth: none, + filter: entry => true, transform: (_, body) => body, + indent: 1.1em, gap: .45em, size: 1em, + ink: auto, accent: auto, + page-numbers: false, page-numbering: "1", fill: none, +) +``` + +Set `title: none` to omit the title, or `title: auto` for plain `[Contents]`. An empty selection renders no entries; an +explicit title still appears. + +## Selection preserves structure + +`target`, `depth` and `filter` change which headings are visible. They do not +change the underlying parent relationships or move a topic into another section. + +```typst +#lineal-outline( + target: heading.where(level: 2), + filter: entry => entry.heading.outlined, +) +``` + +`depth` limits the **raw heading level**, matching Typst's outline convention: +`depth: 2` includes `=` and `==` headings. It does not count how many visible +ancestors remain after filtering. Skipped heading levels keep their actual raw +level while indentation follows the structural hierarchy. + +A heading with `outlined: false` can still own a subtree. Its selected children +stay in that section, and its hidden text is never rendered. In a complete table +of contents, such a group starts at its first visible depth and is separated from +the preceding root group. It does not appear to belong to the preceding visible +section. + +`filter(entry)` must return a boolean. `transform(entry, body)` returns the +content used for the label. For example: + +```typst +#lineal-toc( + transform: (entry, body) => { + if entry.is-current { strong(body) } else { body } + }, +) +``` + +Useful entry fields include: + +| Field | Meaning | +| --- | --- | +| `heading`, `loc` | Original heading content and link destination. | +| `level` | Raw Typst heading level, preserved for existing filters. | +| `depth`, `path` | Structural depth and ancestry path, independent of visibility. | +| `root-id`, `parent-id` | Structural ownership, including hidden ancestors. | +| `is-current` | This is the heading immediately before the navigation. | +| `is-active` | This is the nearest selected ancestor of the current heading, including itself. | +| `relation` | `same`, `parent`, `child`, `sibling` and `unrelated` booleans, or `none` before the first heading. | +| `here-path`, `here-level` | Current structural path and raw level, or `none` before the first heading. | + +`is-active` is computed after filtering for transforms and model output. Filters +can use `is-current` and `relation`. + +Keep transforms to display content rather than returning new `heading` elements. + +## Page numbers and styling + +Page numbers use `counter(page)` **at the target heading**, including deliberate +counter resets. They do not use the PDF's physical page index. Choose the display +format explicitly with `page-numbering`, which accepts a Typst numbering string +or function: + +```typst +#set page(numbering: "i") +#lineal-toc(page-numbers: true, page-numbering: "i", fill: repeat[.]) +``` + +The default format is `"1"`. Navigation does not infer different numbering formats +for separate parts of a document. `fill` supplies optional leader content between +a label and its page number; it has no effect when `page-numbers` is false. + +The surrounding font and colour are inherited. `size` controls the base text +size; `ink` sets ordinary labels and `accent` sets the active heading and its +ancestors. Both colours default to `auto`. `indent` and `gap` control hierarchy +and row spacing. All links preserve the heading's original location. + +## Flow and slide limits + +Navigation has natural height. In ordinary documents, long lists continue across +pages without dropping entries. Long labels wrap within the available width. + +Automatic section slides have a fixed available height. If their outline does +not fit, compilation reports: + +> section outline exceeds the available height; reduce its depth or use separate contents pages + +Reduce the displayed depth, select fewer entries, or put a complete table of +contents on dedicated pages. Lineal does not silently crop the outline or shrink +its text. The slide footer remains responsible for deck progress; the contextual +outline does not add a second progress bar with a different meaning. + +For automatic section slides, set the limit on the theme: + +```typst +#show: lineal-theme.with(section-depth: 1) +``` + +The default is `section-depth: 2`, showing section headings and their slide titles. diff --git a/packages/preview/lineal/0.2.0/docs/progress.md b/packages/preview/lineal/0.2.0/docs/progress.md new file mode 100644 index 0000000000..f61e4f257c --- /dev/null +++ b/packages/preview/lineal/0.2.0/docs/progress.md @@ -0,0 +1,96 @@ +# Progress bars + +`lineal-progress-bar` tracks logical slides. Title slides and frozen focus slides +do not advance it. Revealing an overlay leaves the current slide number unchanged. +Handout mode counts each logical slide once after collapsing its overlays. +Counted section cards contribute one slide; `section-numbered: false` cards contribute +none. Appendix slides do not increase the main-deck total. + +```typst +#import "@preview/lineal:0.2.0": lineal-progress-bar + +#lineal-progress-bar(variant: "exact", height: 3pt) +``` + +The variants describe the same deck with different segment detail: + +| Variant | Segments, from left to right | +| --- | --- | +| `"broad"` | Completed slides, remaining slides | +| `"near"` | Previous sections, whole current section, future sections | +| `"exact"` | Previous sections, completed current slides, remaining current slides, future sections | + +Completion includes the slide being displayed. On the first counted slide, +one slide is complete; on the final counted slide, every slide is complete. +A frozen focus page retains the preceding slide's progress. + +## Section boundaries + +For a non-empty main deck, let: + +- `N` be the number of counted main-deck slides. +- `C` be the current counted slide, capped at `N` in appendices. +- `B` be the logical count before the current section starts. +- `E` be the logical count before the next section starts, or `N` in the last section. + +The proportions are: + +```text +broad = (C, N − C) / N +near = (B, E − B, N − E) / N +exact = (B, C − B, E − C, N − E) / N +``` + +The model requires `0 ≤ B ≤ C ≤ E ≤ N`. Invalid finite bounds fail explicitly; +they are not silently clamped. Appendix counters are saturated at the main-deck +boundary before entering the model. An empty section can have zero width when +its card is unnumbered. Before the first section, opening slides form an +unsectioned range with `B = 0` and `E` at the first section's boundary. + +When `N = 0`, the bar shows **0% complete** as a neutral remaining track. Its +data proportions are `(0, 1)` for broad, `(0, 1, 0)` for near and `(0, 0, 1, 0)` +for exact. No division by zero is performed. + +Section membership comes from authored level-one headings in Touying's slide +preamble. It includes authored unoutlined sections but excludes headings created +inside slide content, native outlines or decorative titles. A section boundary +uses Touying's counter immediately before that page starts, so counted and frozen +section cards both produce the correct section proportions. Changing `slide-level` +does not turn deeper content headings into root sections. + +## Explicit weights and styling + +Supply `ratios` to render known proportions independently of slide counts: + +```typst +#lineal-progress-bar( + variant: "broad", ratios: (2, 3), height: 4pt, + accent: rgb("375b9a"), remaining: rgb("dce4f0"), +) +``` + +Weights must be finite, non-negative numbers with a positive total. Their count +must match the variant: two, three or four. They do not need to add up to one. +Lineal divides by the largest weight before summing, so values such as +`(1e308, 1e308)` still produce equal halves. NaN, infinity, negative values and +all-zero weights fail explicit assertions. Zero-weight segments are omitted from +the drawing while the other segments retain their colours. + +`height` must be a finite positive length; font-relative lengths such as `1em` +resolve against the surrounding text size. `accent` and `remaining` control the +active and remaining colours. The theme passes its configured colours to the +footer bar. Explicit weights are rendered even when the document has no slides. + +## Implementation and checks + +`src/navigation/progress-model.typ` owns the pure arithmetic and weight validation. +`src/progress.typ` adapts counters and authored headings, then draws the segments. +This adapter intentionally uses the pinned Touying 0.7.4 preamble markers; its +counter fixtures must pass before upgrading that dependency. + +The tests assert data for counted and frozen sections, title pages, opening manual +slides, focus pages, regular and section overlays, unoutlined sections, native +outline headings, empty sections, appendices and custom slide levels. Pure tests +cover 31,878 valid boundary/variant combinations and large or subnormal weights. +Invalid-input fixtures verify ordered bounds, finite weights and bar dimensions. +Compile `tests/progress-render.typ` to inspect segment colours and geometry. diff --git a/packages/preview/lineal/0.2.0/src/index.typ b/packages/preview/lineal/0.2.0/src/index.typ index d13b4b7b5f..e97d7ad6c9 100644 --- a/packages/preview/lineal/0.2.0/src/index.typ +++ b/packages/preview/lineal/0.2.0/src/index.typ @@ -1,8 +1,8 @@ #import "brand.typ": brand, colour -#import "@preview/touying:0.5.3": config-info, config-colors, config-common, config-page +#import "@preview/touying:0.7.4": config-info, config-colors, config-common, config-page #import "layouts.typ" -#import "outline.typ": lineal-outline +#import "outline.typ": lineal-outline, lineal-toc #import "progress.typ": lineal-progress-bar #import "title.typ": title-slide #import "theme.typ": lineal-theme diff --git a/packages/preview/lineal/0.2.0/src/layouts.typ b/packages/preview/lineal/0.2.0/src/layouts.typ index 2b1f31e8ef..1caaea522a 100644 --- a/packages/preview/lineal/0.2.0/src/layouts.typ +++ b/packages/preview/lineal/0.2.0/src/layouts.typ @@ -1,4 +1,4 @@ -#import "@preview/touying:0.5.3": components +#import "@preview/touying:0.7.4": components #import "../src/brand.typ": colour, brand diff --git a/packages/preview/lineal/0.2.0/src/navigation/heading-model.typ b/packages/preview/lineal/0.2.0/src/navigation/heading-model.typ new file mode 100644 index 0000000000..c5695e0171 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/navigation/heading-model.typ @@ -0,0 +1,197 @@ +// Heading structure is independent of outline visibility and printed numbering. +#let validate-headings(entries) = { + assert(type(entries) == array, message: "heading-model: entries must be an array") + assert(entries.all(entry => type(entry) == dictionary and "id" in entry and "level" in entry), + message: "heading-model: entries require id and level") + assert(entries.all(entry => entry.id != none), message: "heading-model: ids must not be none") + assert(entries.all(entry => type(entry.level) == int and entry.level > 0), + message: "heading-model: levels must be positive integers") + let ids = entries.map(entry => entry.id) + assert(ids.dedup().len() == ids.len(), message: "heading-model: ids must be unique") +} + +#let heading-tree(entries) = { + validate-headings(entries) + let nodes = () + let ancestors = () + let sibling-counts = (:) + for (index, entry) in entries.enumerate() { + while ancestors.len() > 0 and nodes.at(ancestors.last()).level >= entry.level { + let closed = ancestors.pop() + nodes.at(closed).subtree-end = index + } + let parent = ancestors.at(-1, default: none) + let key = if parent == none { "root" } else { str(parent) } + let ordinal = sibling-counts.at(key, default: 0) + 1 + sibling-counts.insert(key, ordinal) + let parent-path = if parent == none { () } else { nodes.at(parent).path } + let root = if parent == none { index } else { nodes.at(parent).root-index } + nodes.push(( + ..entry, index: index, loc: entry.at("loc", default: entry.id), + depth: ancestors.len() + 1, path: parent-path + (ordinal,), + parent-index: parent, + parent-id: if parent != none { nodes.at(parent).id }, + root-index: root, root-id: entries.at(root).id, + subtree-end: entries.len(), + )) + ancestors.push(index) + } + nodes +} + +#let heading-relation(entry, current) = { + if current == none { return none } + let same = entry.index == current.index + let parent = entry.index < current.index and current.index < entry.subtree-end + let child = current.index < entry.index and entry.index < current.subtree-end + let sibling = not same and entry.parent-index == current.parent-index + ( + same: same, parent: parent, child: child, sibling: sibling, + unrelated: not same and not parent and not child and not sibling, + ) +} + +#let heading-annotations(entries, current) = entries.map(entry => ( + ..entry, relation: heading-relation(entry, current), + here-path: if current != none { current.path }, + here-level: if current != none { current.level }, + here-depth: if current != none { current.depth }, + is-current: current != none and entry.id == current.id, + is-active: false, +)) + +#let visible-headings(entries, selected, depth, filter) = { + assert(selected == auto or type(selected) == array, + message: "heading-model: selected must be auto or an array of ids") + assert(depth == none or (type(depth) == int and depth > 0), + message: "heading-model: depth must be none or a positive integer") + let ids = entries.map(entry => entry.id) + assert(selected == auto or selected.all(id => id in ids), + message: "heading-model: selected ids must exist in the structural model") + entries.filter(entry => { + if selected != auto and entry.id not in selected { return false } + if depth != none and entry.level > depth { return false } + let visible = filter(entry) + assert(type(visible) == bool, message: "heading-model: filter must return a boolean") + visible + }) +} + +#let build-heading-model(entries, current: none, selected: auto, depth: none, filter: entry => true) = { + let tree = heading-tree(entries) + let current-index = tree.position(entry => entry.id == current) + assert(current == none or current-index != none, + message: "heading-model: current id must exist in the structural model") + let current = if current-index != none { tree.at(current-index) } + let annotated = heading-annotations(tree, current) + let visible = visible-headings(annotated, selected, depth, filter) + let ancestors = visible.filter(entry => entry.is-current or (entry.relation != none and entry.relation.parent)) + let active-id = if ancestors.len() > 0 { ancestors.last().id } + let entries = annotated.map(entry => (..entry, is-active: entry.id == active-id)) + ( + entries: entries, + visible: visible.map(entry => entries.at(entry.index)), + current: if current-index != none { entries.at(current-index) }, + section: if current != none { entries.at(current.root-index) }, + active: if active-id != none { entries.find(entry => entry.id == active-id) }, + ) +} + +#let overlay-prefix-error = "heading-model: overlay headings must form a cumulative prefix; move conditional headings outside overlay functions or use ordinary styled text" + +#let flush-heading-overlay(state) = { + if not state.body-open { return state } + assert(state.body.len() >= state.baseline.len(), + message: overlay-prefix-error) + for (index, entry) in state.body.enumerate() { + if index < state.baseline.len() { + assert(entry.signature == state.baseline.at(index).signature, + message: overlay-prefix-error) + } else { + state.baseline.push(entry) + state.entries.push(entry) + } + state.aliases.push((id: entry.id, canonical: state.baseline.at(index).id)) + } + state.body = () + state.body-open = false + state +} + +// Touying emits one logical-slide marker and a marker for each physical subslide. +// Body heading ordinals identify replicas; content equality alone never does. +#let collapse-heading-overlays(events) = { + let state = (entries: (), aliases: (), baseline: (), body: (), body-open: false) + for event in events { + if event.kind == "slide" { + state = flush-heading-overlay(state) + state.baseline = () + } else if event.kind == "subslide" { + state = flush-heading-overlay(state) + state.body-open = true + } else if event.kind == "heading" { + if state.body-open { + state.body.push(event.entry) + } else { + state.entries.push(event.entry) + state.aliases.push((id: event.entry.id, canonical: event.entry.id)) + } + } + } + state = flush-heading-overlay(state) + (entries: state.entries, aliases: state.aliases) +} + +#let query-authored-headings() = { + let events = query(selector(heading).or()).filter(entry => { + if entry.func() == heading { return true } + if entry.func() != metadata or type(entry.value) != dictionary { return false } + entry.value.at("kind", default: none) in ("touying-new-slide", "touying-new-subslide") + }).map(entry => { + if entry.func() != heading { + return (kind: if entry.value.kind == "touying-new-slide" { "slide" } else { "subslide" }) + } + (kind: "heading", entry: ( + id: entry.location(), loc: entry.location(), level: entry.level, + heading: entry, outlined: entry.outlined, signature: entry.fields(), + )) + }) + collapse-heading-overlays(events) +} + +#let canonical-heading-id(authored, id) = { + if id == none { return none } + let alias = authored.aliases.find(alias => alias.id == id) + if alias != none { alias.canonical } +} + +// Call query adapters inside a context; pure model functions need no context. +#let get-heading-model(target: heading.where(outlined: true), depth: none, filter: entry => true) = { + let authored = query-authored-headings() + let selected = query(target) + assert(selected.all(entry => entry.func() == heading), + message: "heading-model: target must select headings only") + let previous = query(selector(heading).before(here(), inclusive: false)) + let current = if previous.len() > 0 { canonical-heading-id(authored, previous.last().location()) } + let selected = selected.map(entry => canonical-heading-id(authored, entry.location())).dedup() + build-heading-model(authored.entries, current: current, selected: selected, depth: depth, filter: filter) +} + +#let current-heading(level: auto, outlined: true) = { + assert(level == auto or (type(level) == int and level > 0), + message: "heading-model: level must be auto or a positive integer") + let target = heading.where(outlined: outlined) + if level != auto { target = target.and(heading.where(level: level)) } + query(target.before(here(), inclusive: false)).at(-1, default: none) +} + +#let headings-context(target: heading.where(outlined: true)) = { + let model = get-heading-model(target: target) + ( + headings: model.visible, + current-heading: if model.active != none { model.active.heading }, + current-heading-idx: if model.active != none { + model.visible.position(entry => entry.id == model.active.id) + }, + ) +} diff --git a/packages/preview/lineal/0.2.0/src/navigation/progress-model.typ b/packages/preview/lineal/0.2.0/src/navigation/progress-model.typ new file mode 100644 index 0000000000..2a89c67d91 --- /dev/null +++ b/packages/preview/lineal/0.2.0/src/navigation/progress-model.typ @@ -0,0 +1,54 @@ +// Pure arithmetic: no document queries, counters, colours or rendering. +#let segment-count(variant) = { + assert(variant in ("broad", "near", "exact"), message: "progress: unknown variant") + (broad: 2, near: 3, exact: 4).at(variant) +} + +#let finite-number(value) = type(value) in (int, float) and value == value and -calc.inf < value and value < calc.inf + +/// Divide by the largest weight before summing, so large finite inputs cannot +/// overflow their total. Zero weights retain their position in the result. +#let normalize-progress(weights, variant: "exact") = { + let count = segment-count(variant) + assert(type(weights) == array and weights.len() == count, + message: "progress: ratios must match the variant's segment count") + assert(weights.all(value => finite-number(value) and value >= 0), + message: "progress: ratios must be finite non-negative numbers") + let maximum = calc.max(..weights) + assert(maximum > 0, message: "progress: ratios must have a positive total") + let scaled = weights.map(value => value / maximum) + let total = scaled.sum() + scaled.map(value => value / total) +} + +/// Logical slides, including the current slide, determine completion. The data +/// adapter saturates appendix counters before calling this strict pure model. +#let progress-ratios(current, total, start: 1, end: auto, variant: "exact") = { + let _ = segment-count(variant) + assert((current, total, start).all(finite-number) and (end == auto or finite-number(end)), + message: "progress: slide bounds must be finite numbers") + let before = start - 1 + let end = if end == auto { total } else { end } + assert(0 <= before and before <= current and current <= end and end <= total, + message: "progress: bounds must satisfy 0 <= before <= current <= end <= total") + if total == 0 { + return (broad: (0, 1), near: (0, 1, 0), exact: (0, 0, 1, 0)).at(variant) + } + let parts = ( + broad: (current, total - current), + near: (before, end - before, total - end), + exact: (before, current - before, end - current, total - end), + ) + normalize-progress(parts.at(variant), variant: variant) +} + +/// Each section records the logical count immediately before its physical page. +/// This works whether the section card itself increments the counter or not. +#let section-bounds(sections, page, total) = { + let previous = sections.filter(section => section.page <= page) + let future = sections.filter(section => section.page > page) + ( + start: if previous.len() > 0 { previous.last().before + 1 } else { 1 }, + end: if future.len() > 0 { future.first().before } else { total }, + ) +} diff --git a/packages/preview/lineal/0.2.0/src/outline.typ b/packages/preview/lineal/0.2.0/src/outline.typ index 0a2f84a470..d36ebf1392 100644 --- a/packages/preview/lineal/0.2.0/src/outline.typ +++ b/packages/preview/lineal/0.2.0/src/outline.typ @@ -1,221 +1,99 @@ -#import "@preview/touying:0.5.3": * - -#import "brand.typ": colour -#import "progress.typ": lineal-progress-bar - -#let current-heading(level: auto, outlined: true) = { - let here = here() - query(heading.where(outlined: outlined).before(inclusive: false, here)) - .at(-1, default: none) +#import "navigation/heading-model.typ": get-heading-model, current-heading, headings-context + +#let navigation-options(depth, indent, gap, size, ink, accent, fill, page-numbering) = { + assert(depth == none or (type(depth) == int and depth > 0), message: "navigation: depth must be a positive integer or none") + assert(indent >= 0pt and gap >= 0pt and size > 0pt, message: "navigation: size must be positive and spacing must be non-negative") + let ink = if ink == auto { text.fill } else { ink } + let accent = if accent == auto { ink } else { accent } + assert(type(ink) == color and type(accent) == color, message: "navigation: ink and accent must be colours or auto") + assert(type(page-numbering) in (str, function), message: "navigation: page-numbering must be a string or function") + (indent: indent, gap: gap, size: size, ink: ink, accent: accent, fill: fill, page-numbering: page-numbering) } -#let headings-context(target: heading.where(outlined: true)) = { - let current-heading = current-heading() - - let headings = query(target) - let path = () - let level = 0 - let current-heading-idx = none - - let result = () - - for hd in headings { - let diff = hd.level - level - if diff > 0 { - for _ in range(diff) { - path.push(1) - } - } else { - for _ in range(-diff) { - let _ = path.pop() - } - path.at(-1) = path.at(-1) + 1 - } - - result.push(( - path: path, - heading: hd - )) +#let navigation-row(entry, options, transform, base, right-aligned: false, page-numbers: false) = { + let active = entry.is-active or (entry.relation != none and entry.relation.parent) + let label = text( + weight: if active or entry.level == 1 { "semibold" } else { "regular" }, + fill: if active { options.accent } else { options.ink }, + transform(entry, entry.heading.body), + ) + let body = if page-numbers { + let number = context numbering(options.page-numbering, ..counter(page).at(entry.loc)) + label + h(.75em) + box(width: 1fr, if options.fill == none { [] } else { options.fill }) + h(.75em) + number + } else { label } + block(width: 100%, breakable: false, above: 0pt, below: options.gap, { + set align(if right-aligned { right } else { left }) + pad(left: if right-aligned { 0pt } else { (entry.depth - base) * options.indent }, link(entry.loc, body)) + }) +} - if current-heading != none and hd.location() == current-heading.location() { // matching on location, otherwise this will be `true` for an identical heading at another location - current-heading-idx = result.len() - 1 +#let navigation-rows(entries, options, transform, right-aligned: false, page-numbers: false, group-roots: true) = { + if entries.len() == 0 { return [] } + let roots = if group-roots { entries.map(entry => entry.root-id).dedup() } else { (none,) } + for (index, root) in roots.enumerate() { + let group = if group-roots { entries.filter(entry => entry.root-id == root) } else { entries } + let base = calc.min(..group.map(entry => entry.depth)) + if index > 0 { v(options.gap * .75) } + for entry in group { + navigation-row(entry, options, transform, base, right-aligned: right-aligned, page-numbers: page-numbers) } - - level = hd.level } - - return ( - current-heading: current-heading, - current-heading-idx: current-heading-idx, - headings: result - ) } +#let navigation-content(title, options, body) = { + set text(size: options.size, fill: options.ink) + set par(spacing: .5em, leading: .35em) + if title != none { + let title = if title == auto { [Contents] } else { title } + block(above: 0pt, below: .8em, text(size: 1.2em, weight: "bold", title)) + } + body +} - +/// Contextual section navigation. Selection changes visibility, never ancestry. #let lineal-outline( - filter: hd => true, - transform: (_, it) => it, - target: heading.where(outlined: true), - ..args + title: none, target: heading.where(outlined: true), depth: none, + filter: entry => true, transform: (_, body) => body, + columns: (1fr, 2fr), gutter: 2em, + indent: 1.1em, gap: .45em, size: 1em, + ink: auto, accent: auto, page-numbers: false, page-numbering: "1", fill: none, ) = context { - let cx = headings-context(target: target) - let idx = cx.at("current-heading-idx", default: none) - let scope-path = if idx != none { - cx.headings.at(idx).path - } - - let headings = cx.headings.map(hd => { - let level = hd.path.len() - - let relation = if scope-path != none { - let path-len = calc.min(level, scope-path.len()) - let common-path = hd.path.slice(0, path-len) == scope-path.slice(0, path-len) - - let same = hd.path == scope-path - let parent = not same and common-path and level < scope-path.len() - let child = not same and common-path and level > scope-path.len() - let sibling = not same and level == scope-path.len() and hd.path.slice(0, path-len - 1) == scope-path.slice(0, path-len - 1) - - ( - same: same, - parent: parent, - child: child, - sibling: sibling, - unrelated: not same and not parent and not child and not sibling, + assert(type(columns) == array and columns.len() == 2, message: "lineal-outline: columns must contain two widths") + assert(gutter >= 0pt, message: "lineal-outline: gutter must be non-negative") + assert(type(filter) == function and type(transform) == function, message: "navigation: filter and transform must be functions") + assert(type(page-numbers) == bool, message: "navigation: page-numbers must be a boolean") + let options = navigation-options(depth, indent, gap, size, ink, accent, fill, page-numbering) + let model = get-heading-model(target: target, depth: depth, filter: filter) + let sections = model.visible.filter(entry => entry.level == 1) + let has-sections = model.entries.any(entry => entry.level == 1) + let body = if model.current == none or not has-sections or model.section == none { + navigation-rows(model.visible, options, transform, page-numbers: page-numbers) + } else { + let current = model.visible.filter(entry => entry.root-id == model.section.id) + if sections.len() == 0 { + navigation-rows(current, options, transform, page-numbers: page-numbers) + } else { + grid(columns: columns, column-gutter: gutter, align: top, + navigation-rows(sections, options, transform, right-aligned: true, page-numbers: page-numbers, group-roots: false), + navigation-rows(current, options, transform, page-numbers: page-numbers), ) } - - ( - here-path: scope-path, - here-level: if scope-path != none { scope-path.len() }, - level: level, - path: hd.path, - relation: relation, - loc: hd.heading.location(), - heading: hd.heading - ) - }).filter(filter) - - if headings == () { - let nonsense-target = selector(

).and() - outline(target: nonsense-target, ..args) - return - } - - let find-heading(location) = { - for x in headings { // forgive me :( - if x.loc == location { - return x - } - } } + navigation-content(title, options, body) +} - place( - top, - move( - dy: -15%, - block(width: 100%)[ - #grid( - align: top, - columns: (1fr, 2fr), - column-gutter: 2em, - { - // Left column: all section headings - show outline.entry: it => { - let hd = find-heading(it.element.location()) - if hd.relation != none and hd.relation.same { - // Active section - mark its position - box(width: 100%)[ - #set align(right) - #h(1em) - #link( - it.element.location(), - transform(hd, it.body()), - ) - ] - } else { - // Regular section - box(width: 100%)[ - #set align(right) - #h(1em) - #link( - it.element.location(), - transform(hd, it.body()), - ) - ] - } - } - - let all_sections = headings.filter(hd => hd.level == 1) - let section_selector = if all_sections != () { - let sel = selector(all_sections.at(0).loc) - for idx in range(1, all_sections.len()) { - sel = sel.or(all_sections.at(idx).loc) - } - sel - } else { - selector(

).and() - } - - outline(target: section_selector, indent: 0em, ..args) - }, - { - // Right column: current section and its subsections - show outline.entry: it => { - let hd = find-heading(it.element.location()) - if hd.level == 1 { - // Section heading - box(width: 100%)[ - #link( - it.element.location(), - transform(hd, it.body()), - ) - #v(-0.25em) - #box(width: 100%)[ - #let pages-in-section = headings.filter(h => h.level == 1).len() - 1 - #let section-ratio = (hd.path.at(0) - 1) / (if pages-in-section > 0 { pages-in-section } else { 1 }) - #lineal-progress-bar(variant: "broad") - ] - #v(0.5em) - ] - } else { - // Subsection heading with hyphen - box(width: 100%)[ - #set list(marker: text(fill: colour.neutral-dark.transparentize(60%))[---]) - - #link( - it.element.location(), - transform(hd, it.body()), - ) - ] - } - } - - let current_section = if scope-path != none { - // Find the root section for current heading - let root_path = scope-path.slice(0, 1) - headings.filter(hd => - hd.path.slice(0, 1) == root_path and - (hd.level == 1 or hd.relation.child or hd.relation.same) - ) - } else { - () - } - - let section_selector = if current_section != () { - let sel = selector(current_section.at(0).loc) - for idx in range(1, current_section.len()) { - sel = sel.or(current_section.at(idx).loc) - } - sel - } else { - selector(

).and() - } - - outline(target: section_selector, ..args) - } - ) - ] - ) +/// A complete, linked hierarchy that flows across normal document pages. +#let lineal-toc( + title: [Contents], target: heading.where(outlined: true), depth: none, + filter: entry => true, transform: (_, body) => body, + indent: 1.1em, gap: .45em, size: 1em, + ink: auto, accent: auto, page-numbers: false, page-numbering: "1", fill: none, +) = context { + assert(type(filter) == function and type(transform) == function, message: "navigation: filter and transform must be functions") + assert(type(page-numbers) == bool, message: "navigation: page-numbers must be a boolean") + let options = navigation-options(depth, indent, gap, size, ink, accent, fill, page-numbering) + let model = get-heading-model(target: target, depth: depth, filter: filter) + navigation-content(title, options, + navigation-rows(model.visible, options, transform, page-numbers: page-numbers), ) } diff --git a/packages/preview/lineal/0.2.0/src/page-statistics.typ b/packages/preview/lineal/0.2.0/src/page-statistics.typ deleted file mode 100644 index 6de56a2f1e..0000000000 --- a/packages/preview/lineal/0.2.0/src/page-statistics.typ +++ /dev/null @@ -1,204 +0,0 @@ -#import "@preview/touying:0.5.3": * - -#let get-current-section-number() = ( - context { - let heading-selector = selector(heading.where(level: 1)).before(here()) - let level = counter(heading-selector) - let headings = query(heading-selector) - if headings.len() == 0 { return 0 } - let heading = headings.last() - level.get().first() - } -) - -#let is-last-section() = { - context { - let total-sections = query(selector(heading.where(level: 1))).len() - - let selector = selector(heading.where(level: 1)).before(here()) - let level = counter(selector) - let headings = query(selector) - if headings.len() == 0 { return 0 } - let heading = headings.last() - let current-section-number = level.get().first() - - return total-sections == current-section-number - } -} - - -#let page-stats = [ - #set text(size: 12pt) - #context { - let total-sections = query(selector(heading.where(level: 1))).len() - [Total sections: #{total-sections}] - } - - #context { - let slide-count = utils.last-slide-number - [Total number of slides: #{slide-count}] - } - - #context { - [Current section number: #{get-current-section-number()}] - } - - #context { - let page-number-of-current-section-start = utils.current-heading(level: 1).location().page() - [Page number of start of current section: #{page-number-of-current-section-start}] - } - - #v(1em) - - #context { - let number-of-slides-in-previous-sections = ( - query(selector(heading.where(level: 2)).before(here(), inclusive: false)).len() - // Exclude the current slide's title element - - 2 - // Number of new section slides - + query(selector(heading.where(level: 1)).before(here())).len() - ) - [\# slides in previous sections: #{number-of-slides-in-previous-sections}] - } - - #v(1em) - - #context { - let number-of-slides-in-current-section = ( - query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() - + 2 - ) - [\# slides in current section: #{number-of-slides-in-current-section}] - } - - #v(1em) - - #context { - // High level stats - let total-sections = query(selector(heading.where(level: 1))).len() - let current-heading = utils.current-heading(level: 1) - - // Determine if this is the last section - let heading-selector = selector(heading.where(level: 1)).before(here()) - let level = counter(heading-selector) - let headings = query(heading-selector) - if headings.len() == 0 { return 0 } - let current-section-number = level.get().first() - - let is-last-section = total-sections == current-section-number - - // Calculate remaining slides - let number-of-slides-in-current-section = ( - query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() - ) - - let remaining-slide-count = if is-last-section { - 0 - } else { - utils.last-slide-counter.final().first() - (utils.slide-counter.get().first() + number-of-slides-in-current-section) - } - - [\# slides in next sections: #{remaining-slide-count}] - } - - #v(1em) - - #context { - let prev = ( - query(selector(heading.where(level: 2)).before(here(), inclusive: false)).len() - // Exclude the current slide's title element - - 2 - // Number of new section slides - + query(selector(heading.where(level: 1)).before(here())).len() - ) - - let current = ( - query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() - + 2 - ) - - // High level stats - let total-sections = query(selector(heading.where(level: 1))).len() - let current-heading = utils.current-heading(level: 1) - - // Determine if this is the last section - let heading-selector = selector(heading.where(level: 1)).before(here()) - let level = counter(heading-selector) - let headings = query(heading-selector) - if headings.len() == 0 { return 0 } - let current-section-number = level.get().first() - - let is-last-section = total-sections == current-section-number - - // Calculate remaining slides - let number-of-slides-in-current-section = ( - query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() - ) - - let next = if is-last-section { - 0 - } else { - utils.last-slide-counter.final().first() - (utils.slide-counter.get().first() + number-of-slides-in-current-section) - } - - let total = prev + current + next - - // Array of proportions of slides in previous sections, current section, and next sections - let pages = ( - calc.round(prev / total, digits: 2), - calc.round(current / total, digits: 2), - calc.round(next / total, digits: 2), - ) - pages - } - - #context { - let current-section-start = utils.current-heading(level: 1).location() - - let historic-section-slides = ( - query(selector(heading.where(level: 2)).before(current-section-start, inclusive: false)).len() - + query(selector(heading.where(level: 1)).before(here(), inclusive: false)).len() - - 1 - ) - - let slides-covered-in-current-section = ( - query(selector(heading.where(level: 2)).after(current-section-start).before(here(), inclusive: false)).len() - ) - - let slides-remaining-in-current-section = ( - query(selector(heading.where(level: 2)).after(here()).before(selector(heading.where(level: 1)).after(here()))).len() - ) - - // Determine if this is the last section - let total-sections = query(selector(heading.where(level: 1))).len() - let heading-selector = selector(heading.where(level: 1)).before(here()) - let level = counter(heading-selector) - let headings = query(heading-selector) - if headings.len() == 0 { return 0 } - let current-section-number = level.get().first() - - let is-last-section = total-sections == current-section-number - - let future-section-slides = if is-last-section { - 0 - } else { - ( - query(selector(heading.where(level: 2)).after(here()).after(selector(heading.where(level: 1)).after(here()))).len() - + query(selector(heading.where(level: 1)).after(here())).len() - + 1 - ) - } - - ( - historic-section-slides, - slides-covered-in-current-section, - slides-remaining-in-current-section, - future-section-slides, - - historic-section-slides - + slides-covered-in-current-section - + slides-remaining-in-current-section - + future-section-slides - ) - } -] diff --git a/packages/preview/lineal/0.2.0/src/progress.typ b/packages/preview/lineal/0.2.0/src/progress.typ index 78166541b8..9045c6e1ba 100644 --- a/packages/preview/lineal/0.2.0/src/progress.typ +++ b/packages/preview/lineal/0.2.0/src/progress.typ @@ -1,51 +1,72 @@ -#import "@preview/touying:0.5.3": utils +#import "@preview/touying:0.7.4": utils #import "brand.typ": colour +#import "navigation/progress-model.typ": progress-ratios, normalize-progress, section-bounds, finite-number -// Use slide counters so title slides and overlays do not skew progress. -#let progress-ratios(current, total, start: 1, end: auto, variant: "exact") = { - assert(variant in ("broad", "near", "exact"), message: "progress: unknown variant") - if total <= 0 { return (broad: (1, 0), near: (0, 1, 0), exact: (0, 1, 0, 0)).at(variant) } - let current = calc.clamp(current, 0, total) - let before = calc.clamp(start - 1, 0, current) - let end = if end == auto { total } else { calc.clamp(end, current, total) } - let parts = ( - broad: (current, total - current), - near: (before, end - before, total - end), - exact: (before, current - before, end - current, total - end), +// Touying 0.7.4 places authored structural headings between these two markers. +// Selecting this range excludes headings created inside an outline or slide body +// while retaining authored sections with outlined:false or touying:hidden. +#let authored-sections(markers) = { + let sections = () + let start = none + for marker in markers { + if marker.value.kind == "touying-new-slide" { start = marker } + if marker.value.kind == "touying-new-subslide" and start != none { + sections += query(selector(heading.where(level: 1)).after(start.location()).before(marker.location())) + start = none + } + } + sections +} + +#let section-counts(markers) = { + let pages = markers.filter(marker => marker.value.kind == "touying-new-page") + authored-sections(markers).map(section => { + let page = section.location().position().page + let beginning = pages.find(marker => marker.location().position().page == page) + assert(beginning != none, message: "progress: section is missing its Touying page marker") + // The page marker precedes Touying's counter update. A heading's own counter + // value cannot distinguish a counted card from a frozen card. + (page: page, before: utils.slide-counter.at(beginning.location()).first()) + }) +} + +/// Data adapter for the renderer. Call from a context expression. +#let get-progress-state(variant: "exact", ratios: none) = { + let total = utils.last-slide-counter.final().first() + let actual-current = utils.slide-counter.get().first() + let raw = section-bounds(section-counts(query()), here().position().page, total) + // Touying counts appendix slides in its current counter but excludes them from + // the main-deck total. Saturate only this semantic boundary, not bad input. + let completed = actual-current >= total + let current = if completed { total } else { actual-current } + let bounds = ( + start: if completed { calc.min(raw.start, total + 1) } else { raw.start }, + end: if completed { calc.min(raw.end, total) } else { raw.end }, ) - parts.at(variant).map(value => value / total) + let values = if ratios == none { + progress-ratios(current, total, ..bounds, variant: variant) + } else { normalize-progress(ratios, variant: variant) } + (current: current, actual-current: actual-current, total: total, before: bounds.start - 1, ..bounds, values: values) } #let lineal-progress-bar( variant: "exact", height: 2pt, ratios: none, accent: colour.primary, remaining: colour.primary-light, ) = context { - assert(variant in ("broad", "near", "exact"), message: "progress: unknown variant") - let total = utils.last-slide-counter.final().first() - let current = utils.slide-counter.get().first() - let sections = query(heading.where(level: 1)) - let previous = sections.filter(section => section.location().position().page <= here().position().page) - let future = sections.filter(section => section.location().position().page > here().position().page) - let start = if previous.len() > 0 { - utils.slide-counter.at(previous.last().location()).first() - } else { 1 } - let end = if future.len() > 0 { - utils.slide-counter.at(future.first().location()).first() - 1 - } else { total } - let values = if ratios == none { - progress-ratios(current, total, start: start, end: end, variant: variant) - } else { ratios } + assert(type(height) == length, + message: "progress: height must be a finite positive length") + let height = height.to-absolute() + assert(finite-number(height / 1pt) and height > 0pt, + message: "progress: height must be a finite positive length") + let state = get-progress-state(variant: variant, ratios: ratios) let fills = ( broad: (accent, remaining), near: (black.transparentize(60%), accent, remaining), exact: (black.transparentize(60%), accent, remaining, black.transparentize(80%)), ).at(variant) - assert(type(values) == array and values.len() == fills.len(), - message: "progress: ratios must match the variant's segment count") - assert(values.all(value => type(value) in (int, float) and value >= 0) and values.sum() > 0, - message: "progress: ratios must be non-negative numbers with a positive total") - let segments = values.enumerate().filter(pair => pair.last() > 0) - grid(columns: segments.map(pair => pair.last() / values.sum() * 100%), rows: height, + if state.total == 0 and ratios == none { fills = fills.map(_ => remaining) } + let segments = state.values.enumerate().filter(pair => pair.last() > 0) + grid(columns: segments.map(pair => pair.last() * 1fr), rows: height, ..segments.map(pair => rect(width: 100%, height: height, fill: fills.at(pair.first()), stroke: none)), ) } diff --git a/packages/preview/lineal/0.2.0/src/theme.typ b/packages/preview/lineal/0.2.0/src/theme.typ index ed138e3fd5..76ddb7887b 100644 --- a/packages/preview/lineal/0.2.0/src/theme.typ +++ b/packages/preview/lineal/0.2.0/src/theme.typ @@ -1,4 +1,4 @@ -#import "@preview/touying:0.5.3": * +#import "@preview/touying:0.7.4": * #import "brand.typ": colour #import "outline.typ": lineal-outline @@ -45,7 +45,7 @@ weight: "bold", size: 1em, if title != auto { - utils.fit-to-width.with(grow: false, 100%, title) + utils.fit-to-width(grow: false, 100%, title) } else { utils.call-or-display(self, self.store.header) } @@ -109,30 +109,38 @@ -#let new-section-slide(level: 1, numbered: true, body) = touying-slide-wrapper(self => { +#let new-section-slide(level: 1, numbered: true, depth: auto, body) = touying-slide-wrapper(self => { + assert(type(level) == int and level > 0, message: "section slide: level must be a positive integer") + assert(type(numbered) == bool, message: "section slide: numbered must be a boolean") + let outline-body = lineal-outline( + title: none, + filter: hd => hd.relation != none and not hd.relation.unrelated, + depth: if depth == auto { level + 1 } else { depth }, + ink: self.colors.primary, accent: self.colors.primary, + transform: (hd, it) => { + set text(size: 1.25em, fill: self.colors.primary, weight: "bold") if hd.relation != none and hd.relation.same + set text(fill: self.colors.primary) if hd.relation != none and hd.relation.child + set text(fill: text.fill.transparentize(60%)) if hd.relation != none and hd.relation.sibling + + hd.heading.body + } + ) let slide-body = { set align(horizon) show: pad.with(left: 5%, right: 10%) - - lineal-outline( - title: none, - filter: hd => hd.relation != none and not hd.relation.unrelated, - depth: 2, - transform: (hd, it) => { - set text(size: 1.25em, fill: self.colors.primary, weight: "bold") if hd.relation != none and hd.relation.same - set text(fill: self.colors.primary) if hd.relation != none and hd.relation.child - set text(fill: text.fill.transparentize(60%)) if hd.relation != none and hd.relation.sibling - - // Extract just the heading text without numbering - let heading-text = hd.heading.body - heading-text - } - ) - + // Measure only the generated outline, without replaying Touying's counter + // preamble or hiding the author's pause/only markers inside a context. + layout(size => context { + let used = measure(block(width: size.width, outline-body)).height + assert(used <= size.height, + message: "section outline exceeds the available height; reduce its depth or use separate contents pages") + outline-body + }) body } self = utils.merge-dicts( self, + config-common(freeze-slide-counter: not numbered), config-page(fill: self.colors.neutral-lightest), ) touying-slide(self: self, slide-body) @@ -163,6 +171,8 @@ footer-left: self => pad(self.info.logo, y: -.2em), footer-right: self => self.info.title, footer-progress: true, + section-depth: 2, + section-numbered: true, ..args, body, ) = { @@ -176,7 +186,8 @@ ), config-common( slide-fn: slide, - new-section-slide-fn: new-section-slide, + new-section-slide-fn: new-section-slide.with(depth: section-depth, numbered: section-numbered), + receive-body-for-new-section-slide-fn: true, ), config-methods( init: (self: none, body) => { diff --git a/packages/preview/lineal/0.2.0/src/title.typ b/packages/preview/lineal/0.2.0/src/title.typ index 48d4b90024..9e6efedb04 100644 --- a/packages/preview/lineal/0.2.0/src/title.typ +++ b/packages/preview/lineal/0.2.0/src/title.typ @@ -1,4 +1,4 @@ -#import "@preview/touying:0.5.3": * +#import "@preview/touying:0.7.4": * #import "brand.typ": colour From 1b2a0870f43cff20de53eff8173f48cbcee381e0 Mon Sep 17 00:00:00 2001 From: ellsphillips Date: Sat, 17 Oct 2026 17:57:25 +0100 Subject: [PATCH 3/3] style(lineal): keep packaged source whitespace clean --- packages/preview/lineal/0.2.0/src/layouts.typ | 8 ++++---- packages/preview/lineal/0.2.0/src/theme.typ | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/preview/lineal/0.2.0/src/layouts.typ b/packages/preview/lineal/0.2.0/src/layouts.typ index 1caaea522a..d3f21f30c8 100644 --- a/packages/preview/lineal/0.2.0/src/layouts.typ +++ b/packages/preview/lineal/0.2.0/src/layouts.typ @@ -53,7 +53,7 @@ columns: 2, column-gutter: 1em, align: horizon + center, - + block( height: 100%, width: 100%, @@ -64,7 +64,7 @@ #set text(fill: white) #content1 ], - + block( height: 100%, width: 100%, @@ -90,7 +90,7 @@ grid( columns: 2, column-gutter: 1em, - + content, block( @@ -125,7 +125,7 @@ columns: (1fr,) * 4, column-gutter: 1em, align: top + left, - + ..contents.pos().enumerate().map(((index, content)) => { let lightness = 20% * index grid-item(colour.primary.lighten(lightness))[ diff --git a/packages/preview/lineal/0.2.0/src/theme.typ b/packages/preview/lineal/0.2.0/src/theme.typ index 76ddb7887b..0c13a7d55f 100644 --- a/packages/preview/lineal/0.2.0/src/theme.typ +++ b/packages/preview/lineal/0.2.0/src/theme.typ @@ -57,7 +57,7 @@ utils.call-or-display(self, self.store.header-right) ), ) - + } let footer(self) = { set align(bottom)