diff --git a/packages/preview/modernpro-coverletter/1.0.3/LICENSE b/packages/preview/modernpro-coverletter/1.0.3/LICENSE new file mode 100644 index 0000000000..042b15d169 --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Academic Template Collective + +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/modernpro-coverletter/1.0.3/README.md b/packages/preview/modernpro-coverletter/1.0.3/README.md new file mode 100644 index 0000000000..4b0273e827 --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/README.md @@ -0,0 +1,464 @@ +# modernpro-coverletter + +An academic-first Typst package for cover letters and research, teaching, or +personal statements. It shares its design system and `profile` shape with +[modernpro-cv](https://typst.app/universe/package/modernpro-cv), so a CV, a +letter, and a statement read as one application while each source file remains +self-contained. + +The default design is intentionally complete. Most users only need to provide +their profile, the recipient, and the letter text. + +All examples use explicit Placeholder entities from Exampleland and reserved +`.invalid` domains; none maps to a real person, place, institution, or project. + +## Preview + +### Academic cover letter + +[![Academic cover letter](screenshots/coverletter.png)](screenshots/coverletter.png) + +### Research statement + +[![Research statement](screenshots/statement.png)](screenshots/statement.png) + +## Choose a document + +| Document | Start from | Best for | +| --- | --- | --- | +| Cover letter | `coverletter.typ` | A role, fellowship, grant, or programme application addressed to a recipient | +| Statement | `statement.typ` | Research, teaching, diversity, service, or personal statements with section headings | + +Both documents keep an editable `profile` dictionary at the top of their own +file. Copy the same values when you want matching headers, without adding a +shared-file dependency. + +## Quick start + +Create and compile a project with the Typst CLI: + +```bash +typst init @preview/modernpro-coverletter:1.0.3 +cd modernpro-coverletter +typst compile coverletter.typ +typst compile statement.typ +``` + +The generated project contains: + +```plain +modernpro-coverletter/ +├── coverletter.typ <- recipient, letter body, and closing +└── statement.typ <- title, headings, and statement body +``` + +Use `typst watch coverletter.typ` or `typst watch statement.typ` for automatic +recompilation while editing. + +### First edit checklist + +1. Replace the placeholder identity at the top of the document you are using. +2. In `coverletter.typ`, replace every recipient field and write the letter in + short block paragraphs. +3. In `statement.typ`, rename the title and headings to match the requested + document. +4. Remove example claims rather than adapting them as if they were factual. +5. Compile to PDF and inspect the header, first body line, closing, and page + breaks. + +## Minimal academic cover letter + +Keep your identity beside the letter content in the same source file: + +```typst +#import "@preview/modernpro-coverletter:1.0.3": * + +#let profile = ( + name: [Dr. Nova Placeholder], + role: [Lecturer in Speculative Systems], + address: [Sample City, Exampleland], + contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + (text: [nova.candidate.invalid], link: "https://nova.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +#show: coverletter.with( + profile: profile, + recipient: ( + name: [Professor Taylor Demo], + role: [Chair, Fictional Search Committee], + department: [School of Speculative Policy], + organization: [Placeholder Institute], + address: [Demo Harbour, Exampleland], + date: [1 Imaginarymonth 20ZZ], + subject: [Application for Lecturer in Speculative Governance], + greeting: [Dear Professor Demo and Members of the Fictional Committee,], + ), +) + +Write the opening paragraph here. + +Write the research, teaching, or professional case in short block paragraphs. + +Close by explaining the fit and thanking the committee. +``` + +The default closing is `Sincerely,` followed by the profile name. Override it +only when needed: + +```typst +closing: ( + salutation: [Best regards,], + supplements: ([Enclosure: Curriculum vitae],), +) +``` + +## The three settings + +Everything beyond `profile` and `recipient` is optional: + +| Setting | Values | Purpose | +| --- | --- | --- | +| `profile` | `name`, optional `role`, `address`, `contacts` | Who you are | +| `preset` | `"compact"`, `"default"`, `"relaxed"` | The whole vertical rhythm | +| `accent` | any colour | The one colour in the document | + +```typst +#show: coverletter.with( + profile: profile, + recipient: (name: [Recipient Name],), + preset: "compact", + accent: rgb("#1e3a5f"), +) +``` + +A preset coordinates header rows, recipient and subject spacing, title spacing, +heading-to-body gaps, line spacing, and paragraph spacing at once. Choose a +preset rather than tuning gaps individually. + +## Recipient fields + +The `recipient` group uses ordinary letter terminology: + +| Field | Purpose | +| --- | --- | +| `name` | Recipient or committee chair | +| `role` | Role or position | +| `department` | Department, school, or unit | +| `organization` | University, company, or institution | +| `address` | City or postal address | +| `postcode` | Optional separate postcode line | +| `date` | Letter date; omit it to use today's date | +| `subject` | Sentence-case application subject | +| `greeting` | Opening greeting | + +Every recipient field is optional. If `date` is omitted, the template inserts +today's date; other empty fields leave no placeholder gaps. For an application, +provide at least the recipient or committee, organization, subject, and +greeting whenever they are known. + +## Statement template + +`statement` uses the same profile, header, and visual identity. First- and +second-level Typst headings are styled automatically, so a research statement +stays easy to edit: + +```typst +#import "@preview/modernpro-coverletter:1.0.3": * + +#let profile = ( + name: [Dr. Nova Placeholder], + role: [Lecturer in Speculative Systems], + address: [Sample City, Exampleland], + contacts: ( + (text: [nova\@candidate.invalid], link: "mailto:nova@candidate.invalid"), + (text: [nova.candidate.invalid], link: "https://nova.candidate.invalid"), + ), +) + +#show: statement.with( + profile: profile, + title: [Fictional Research Statement], +) + += Imaginary research agenda +Introduce the question that connects the fictional programme. + += Simulated current programme +Describe the placeholder projects, methods, and contributions. + += Future work +Set out the next phase of the programme. +``` + +Use level-one headings (`= Heading`) for the major argument. Prefer three to +five descriptive sections over many short fragments. Statements add a compact +continuation header from page 2 by default. + +## Keep every document self-contained + +The profile dictionary has the same shape as modernpro-cv, but each generated +document keeps its own copy. An application folder therefore has no shared +personal-data dependency: + +```plain +application/ +├── cv.typ +├── coverletter.typ +└── research-statement.typ +``` + +Copy the small `#let profile = (...)` block when starting another document. +This deliberate duplication makes every file portable and independently +compilable; update the copied values only when the application needs a +different role or contact order. + +## Shared academic design + +The CV, cover letter, and statement use the same tokens. + +| | | +| --- | --- | +| Family | PT Serif, falling back to Libertinus Serif — one family, two weights | +| Sizes | 8.8pt captions · 9.8pt recipient and address · 10.8pt body · 15pt statement title · 18pt name | +| Colours | `#1f2933` ink · `#667085` muted · `#1e3a5f` accent · `#dde3ea` rules | +| Margins | 2.2cm left and right · fixed 2cm top, matching modernpro-cv | +| Header | Split grid: name, role, and location left; stacked contacts right; accent rule below | +| Paragraph style | Left aligned, no first-line indent, block paragraphs | + +The matching left and right margins align the endpoints of the header rule +across the application. Its vertical position depends on the header's minimum +height and the space needed by its content. + +Block paragraphs are easier to scan than justified text carrying both an indent +and a blank gap. The default starter also avoids decorative icons, keeping text +extraction and accessibility clean. + +## Advanced configuration + +Most documents never need this section. When you do need a specific override, +optional settings are grouped by purpose: + +| Group | Use it for | +| --- | --- | +| `theme` | Fonts, semantic colours, sizes, and weights | +| `layout` | Margins, paragraph rhythm, header layout, and continuation behaviour | +| `closing` | Sign-off, signature spacing, and enclosures | + +The semantic theme keys match modernpro-cv: + +```typst +#let theme = ( + font: "PT Serif", + text: rgb("#1f2933"), + heading: rgb("#1f2933"), + muted: rgb("#667085"), + accent: rgb("#1e3a5f"), + rule: rgb("#dde3ea"), +) +``` + +Common layout keys are: + +| Key | Purpose | Default | +| --- | --- | --- | +| `preset` | Coordinated document rhythm: `"compact"`, `"default"`, or `"relaxed"` | `"default"` | +| `margin` | Page margins | Academic margins above | +| `first-line-indent` | Paragraph indent | `0em` | +| `line-spacing` | Typst paragraph leading | `0.85em` at the default preset | +| `paragraph-spacing` | Space between paragraphs | `1.55em` at the default preset | +| `justify` | Fully justify body text | `false` | +| `header-style` | `"split"` or `"centered"` | `"split"` | +| `contact-layout` | `"stacked"` or `"inline"` contacts in a split header | `"stacked"` | +| `name-align` | Horizontal alignment of the name and role | `left` in split headers; `center` in centered headers | +| `address-align` | Horizontal alignment of the address | `left` in split headers; `center` in centered headers | +| `contact-align` | Horizontal alignment of contacts; icon lists keep labels aligned in their column | `right` in split headers; `center` in centered headers | +| `repeat-header` | Add a compact continuation header from page 2 | cover letter: `false`; statement: `true` | +| `page-numbering` | Add current and total page count to continuation headers | `true` | +| `header-height` | Minimum first-page identity area height; grows with content | `17mm` | +| `contact-separator` | Separator between inline contact items | `" · "` | +| `date-format` | Format for an automatic date | `[day] [month repr:long] [year]` | + +The full identity header is always part of the first page's document flow. Its +position does not move when continuation behaviour changes. If `repeat-header` +is enabled, later pages receive only a compact name, document label, and page +count; contact details are not repeated or placed outside the accessible first +page structure. Statements enable this behaviour by default. + +## Contacts + +The adaptive header improvements described below are available in version 1.0.3. + +A contact may be linked, unlinked, or plain content, and may carry an optional +`icon` exactly as in modernpro-cv: + +```typst +contacts: ( + (text: [name\@candidate.invalid], link: "mailto:name@candidate.invalid"), + (text: [site.candidate.invalid], link: "https://site.candidate.invalid"), + [Fictional ID 0000-0000], +) +``` + +Escape `@` as `\@` inside Typst content (`[...]`). Quoted strings such as +`"name@candidate.invalid"` do not need that escape. Two or three concise contacts +keep the header short, but five is not a limit: add the items you need in the +order you want them to appear. FontAwesome is optional and is not imported by +the package or starter. + +### Sizing and wrapping + +`layout.header-height` is a **minimum**, not a fixed height or a limit. It +defaults to 17 mm. Additional contacts and wrapped lines expand the header; +the divider, recipient block or statement title, and body move down with it. +Font sizes and row spacing stay at their configured values. + +Split headers that fit retain their bottom alignment. When content exceeds the +minimum, the identity and contact columns align at the top. Long email +addresses and URLs can break at punctuation; an individual segment wider than +its column can break between characters. The text is not truncated, no hyphens +or invisible characters are added to copied labels, and link destinations stay +unchanged. Optional icons are centered by their visible glyph bounds within +the label's first line, including wrapped labels. Font Awesome needs no +`top-edge` adjustment; existing icons with `top-edge: "baseline"` also remain +supported. + +### Header layouts + +| `layout.header-style` | `layout.contact-layout` | First-page layout | +| --- | --- | --- | +| `"split"` (default) | `"stacked"` (default) | Identity on the left; one contact per row on the right | +| `"split"` | `"inline"` | Identity on the left; contacts flowing across lines on the right | +| `"centered"` | Either value | Name, role, address, and inline contacts centered across the page | + +Inline items stay together when they fit the available width; a longer item +wraps within that width. Centered headers always use inline contacts, so +`contact-layout` only selects a layout for split headers. All of these settings +work with both `coverletter` and `statement`. Profile photos remain a CV-only +feature. + +## Common recipes + +### Add enclosures + +```typst +closing: ( + supplements: ( + [Enclosure: Curriculum vitae], + [Enclosure: Research statement], + ), +) +``` + +### Use a centered header + +This centers the name, role, address, and contacts without setting each +alignment separately: + +```typst +#show: coverletter.with( + profile: profile, + layout: (header-style: "centered"), +) +``` + +Use `statement.with` for a statement. Explicit `name-align`, `address-align`, +and `contact-align` settings still take precedence. For example, adding +`address-align: left` inside `layout` keeps only the address left-aligned. +Grouped `layout` settings take precedence over the corresponding legacy flat +arguments. + +### Use inline contacts in a split header + +```typst +#show: coverletter.with( + profile: profile, + layout: (contact-layout: "inline"), +) +``` + +### Give the header more room + +```typst +#show: coverletter.with( + profile: profile, + layout: (header-height: 24mm), +) +``` + +This reserves at least 24 mm before the divider. Taller content still expands +the header; reducing this value does not compress the contacts. For a matching +CV and letter, use the same minimum with profiles and layouts that fit inside +it. Different content or photo layouts can still require different heights. + +### Disable a statement continuation header + +```typst +#show: statement.with( + profile: profile, + title: [Research Statement], + layout: (repeat-header: false), +) +``` + +### Use an explicit date + +```typst +recipient: ( + date: [9 July 2026], +) +``` + +## Legacy compatibility + +Nothing was removed. The flat parameters `font-type`, `name`, `address`, +`contacts`, the colour parameters (`primary-colour`, `headings-colour`, +`subheadings-colour`, `date-colour`, `link-colour`), the type sizes, and the +spacing parameters all still resolve. `layout: (density: ...)` remains an +accepted spelling of `preset`. + +The recipient aliases `start-title` and `cl-title` still map to `greeting` and +`subject`. `institution` remains an alias for `organization`, and `position` +remains an alias for `role`. The legacy `salutation` argument controls the +closing sign-off. + +New documents should use `profile`, `recipient`, and the three settings shown in +the quick start. + +## Upgrading from 0.0.x + +Your existing documents keep compiling, and they will change more than a visual +redesign alone would explain. In 0.0.x the body style was applied inside a +helper function that produced no content, so Typst scoped the rules to that +helper and discarded them: letters rendered at Typst's 11pt default rather than +the configured `body-size`, and `line-spacing`, `paragraph-spacing`, `justify`, +`first-line-indent`, and `link-colour` had no effect at all. Those settings now +work. If you had compensated for the old behaviour with manual overrides, +remove them. + +## Troubleshooting + +- **The email causes a syntax error:** escape `@` as `\@` inside Typst content. +- **The header takes more space after adding contacts:** it expands to keep + every item readable. A smaller `header-height` does not shrink its content; + shorten labels or choose `preset: "compact"` if space is tight. +- **A centered header still has left- or right-aligned text:** remove explicit + `name-align`, `address-align`, or `contact-align` overrides to use the centered + defaults. +- **The first page looks crowded:** shorten the contact block and recipient + address before selecting the compact preset. +- **A statement heading is too close to a page break:** keep it as a Typst + heading; the template marks styled headings as sticky. +- **The letter unexpectedly becomes two pages:** remove repetition first, then + try `preset: "compact"`. +- **A font is unavailable:** set `theme: (font: "Libertinus Serif")`. + +## Local development + +See the [source repository's development guide](https://github.com/jxpeng98/typst-coverletter#local-development) for working-template imports, examples, and header regression checks. + +## License + +This template is released under the MIT License. See [LICENSE](LICENSE). diff --git a/packages/preview/modernpro-coverletter/1.0.3/modernpro-coverletter.typ b/packages/preview/modernpro-coverletter/1.0.3/modernpro-coverletter.typ new file mode 100644 index 0000000000..f93e4026c4 --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/modernpro-coverletter.typ @@ -0,0 +1,830 @@ +/////////////////////////////// +// modernpro-coverletter.typ +// A clean, modern academic cover letter and statement template. +// Copyright (c) 2026 +// Author: Academic Template Collective +// License: MIT +// Version: 1.0.3 +// Date: 2026-09-29 +// Email: maintainers@example.invalid +/////////////////////////////// + +// Shared design tokens for the academic suite: one serif family, two weights, +// a restrained size ladder, and four colours. Keep this block in sync with modernpro-cv.typ so a +// CV and a letter set beside each other read as one document set. +#let default-letter-style = ( + text: rgb("#1f2933"), + muted: rgb("#667085"), + heading: rgb("#1f2933"), + accent: rgb("#1e3a5f"), + rule: rgb("#dde3ea"), + + font: ("PT Serif", "Libertinus Serif"), + name-size: 18pt, + doc-title-size: 15pt, + role-size: 10.5pt, + subject-size: 10.8pt, + heading-1-size: 11pt, + heading-2-size: 10.8pt, + body-size: 10.8pt, + recipient-size: 9.8pt, + address-size: 9.8pt, + supplement-size: 9.8pt, + small-size: 8.8pt, + contact-size: 8.8pt, + contact-icon-size: 7.6pt, + contact-icon-width: 9pt, + contact-icon-gap: 4pt, + + rule-stroke: 0.4pt, +) + +#let is-filled(value) = value != none and value != [] and value != "" + +#let as-bool(value) = value == true or value == "true" + +#let _option(source, key, default) = if source == none { + default +} else { + source.at(key, default: default) +} + +#let _option-any(source, keys, default) = { + let value = default + if source != none { + for key in keys { + value = source.at(key, default: value) + } + } + value +} + +#let _first-filled(values, default: none) = { + for candidate in values { + if is-filled(candidate) { + return candidate + } + } + return default +} + +// A small set of rhythm presets keeps the public API simple while preserving +// precise overrides for long or unusually dense documents. +#let letter-rhythm(preset) = if preset == "compact" { + ( + line-spacing: 0.62em, + paragraph-spacing: 1em, + header-row-gap: 1.8pt, + header-rule-gap: 4.8pt, + header-content-gap: 7pt, + recipient-top-gap: 0.3em, + recipient-subject-gap: 0.8em, + subject-after-gap: 0.65em, + doc-title-top-gap: 0.3em, + doc-title-rule-gap: 0.3em, + doc-title-content-gap: 0.7em, + heading-1-before: 0.85em, + heading-1-after: 0.3em, + heading-2-before: 0.7em, + heading-2-after: 0.26em, + ) +} else if preset == "relaxed" or preset == "spacious" { + ( + line-spacing: 1em, + paragraph-spacing: 1.85em, + header-row-gap: 3.6pt, + header-rule-gap: 8pt, + header-content-gap: 14pt, + recipient-top-gap: 0.65em, + recipient-subject-gap: 1.4em, + subject-after-gap: 1.15em, + doc-title-top-gap: 0.65em, + doc-title-rule-gap: 0.55em, + doc-title-content-gap: 1.35em, + heading-1-before: 1.75em, + heading-1-after: 0.75em, + heading-2-before: 1.45em, + heading-2-after: 0.65em, + ) +} else { + ( + line-spacing: 0.85em, + paragraph-spacing: 1.55em, + header-row-gap: 3pt, + header-rule-gap: 7pt, + header-content-gap: 12pt, + recipient-top-gap: 0.5em, + recipient-subject-gap: 1.15em, + subject-after-gap: 0.95em, + doc-title-top-gap: 0.5em, + doc-title-rule-gap: 0.45em, + doc-title-content-gap: 1.1em, + heading-1-before: 1.45em, + heading-1-after: 0.58em, + heading-2-before: 1.2em, + heading-2-after: 0.5em, + ) +} + +#let resolve-letter-config( + font-type: none, + margin: none, + name: none, + address: none, + contacts: (), + primary-colour: none, + headings-colour: none, + subheadings-colour: none, + date-colour: none, + link-colour: none, + name-size: none, + body-size: none, + address-size: none, + contact-size: none, + recipient-size: none, + cl-title-size: none, + supplement-size: none, + line-stroke: none, + header-ascent: 1em, + first-line-indent: 0em, + line-spacing: none, + paragraph-spacing: none, + contact-separator: " · ", + name-align: none, + address-align: none, + contact-align: none, + name-weight: "bold", + body-weight: "regular", + date-format: "[day] [month repr:long] [year]", + profile: none, + theme: none, + layout: none, + preset: none, + accent: none, +) = { + let d = default-letter-style + let resolved-preset = _first-filled( + (preset, _option-any(layout, ("density", "preset"), none)), + default: "default", + ) + let rhythm = letter-rhythm(resolved-preset) + let centered = _option(layout, "header-style", "split") == "centered" + let resolved-repeat-header = as-bool(_option(layout, "repeat-header", false)) + + let resolved-margin = _option(layout, "margin", margin) + if resolved-margin == none { + // Match modernpro-cv exactly. Enabling a continuation header must never + // move the first-page masthead. + resolved-margin = ( + left: 2.2cm, + right: 2.2cm, + top: 2cm, + bottom: 1.8cm, + ) + } + + // Legacy colour parameters keep working; the modern path is theme + accent. + let resolved-accent = _first-filled( + (accent, _option(theme, "accent", none), link-colour), + default: d.accent, + ) + + ( + font: _first-filled( + (_option-any(theme, ("font", "font-type"), none), font-type), + default: d.font, + ), + margin: resolved-margin, + name: _option(profile, "name", name), + role: _option-any(profile, ("role", "headline", "position"), none), + address: _option(profile, "address", address), + contacts: _option(profile, "contacts", contacts), + + text: _first-filled((_option-any(theme, ("text", "primary-colour"), none), primary-colour), default: d.text), + muted: _first-filled((_option-any(theme, ("muted", "headings-colour"), none), headings-colour), default: d.muted), + heading: _first-filled((_option-any(theme, ("heading", "subheadings-colour"), none), subheadings-colour), default: d.heading), + date-colour: _first-filled((_option(theme, "date-colour", none), date-colour), default: d.muted), + accent: resolved-accent, + rule: _option(theme, "rule", d.rule), + + name-size: _first-filled((_option(theme, "name-size", none), name-size), default: d.name-size), + doc-title-size: _option(theme, "doc-title-size", d.doc-title-size), + role-size: _option(theme, "role-size", d.role-size), + subject-size: _first-filled((_option(theme, "subject-size", none), cl-title-size), default: d.subject-size), + heading-1-size: _option(theme, "heading-1-size", d.heading-1-size), + heading-2-size: _option(theme, "heading-2-size", d.heading-2-size), + body-size: _first-filled((_option-any(theme, ("body-size", "fontsize"), none), body-size), default: d.body-size), + recipient-size: _first-filled((_option(theme, "recipient-size", none), recipient-size), default: d.recipient-size), + address-size: _first-filled((_option(theme, "address-size", none), address-size), default: d.address-size), + supplement-size: _first-filled((_option(theme, "supplement-size", none), supplement-size), default: d.supplement-size), + small-size: _option(theme, "small-size", d.small-size), + contact-size: _first-filled((_option(theme, "contact-size", none), contact-size), default: d.contact-size), + contact-icon-size: _option(theme, "contact-icon-size", d.contact-icon-size), + contact-icon-width: _option(layout, "contact-icon-width", _option(theme, "contact-icon-width", d.contact-icon-width)), + contact-icon-gap: _option(layout, "contact-icon-gap", _option(theme, "contact-icon-gap", d.contact-icon-gap)), + + rule-stroke: _first-filled((_option(layout, "rule-stroke", none), line-stroke), default: d.rule-stroke), + header-ascent: _option(layout, "header-ascent", header-ascent), + first-line-indent: _option(layout, "first-line-indent", first-line-indent), + line-spacing: _option(layout, "line-spacing", _first-filled((line-spacing,), default: rhythm.line-spacing)), + paragraph-spacing: _option(layout, "paragraph-spacing", _first-filled((paragraph-spacing,), default: rhythm.paragraph-spacing)), + contact-separator: _option(layout, "contact-separator", contact-separator), + name-align: _first-filled( + (_option(layout, "name-align", none), name-align), + default: if centered { center } else { left }, + ), + address-align: _first-filled( + (_option(layout, "address-align", none), address-align), + default: if centered { center } else { left }, + ), + contact-align: _first-filled( + (_option(layout, "contact-align", none), contact-align), + default: if centered { center } else { right }, + ), + name-weight: _option(theme, "name-weight", name-weight), + body-weight: _option(theme, "body-weight", body-weight), + date-format: _option(layout, "date-format", date-format), + justify: as-bool(_option(layout, "justify", false)), + header-style: _option(layout, "header-style", "split"), + contact-layout: _option(layout, "contact-layout", "stacked"), + + header-row-gap: _option(layout, "header-row-gap", rhythm.header-row-gap), + header-rule-gap: _option(layout, "header-rule-gap", rhythm.header-rule-gap), + header-content-gap: _option(layout, "header-content-gap", rhythm.header-content-gap), + recipient-top-gap: _option(layout, "recipient-top-gap", rhythm.recipient-top-gap), + recipient-subject-gap: _option(layout, "recipient-subject-gap", rhythm.recipient-subject-gap), + subject-after-gap: _option(layout, "subject-after-gap", rhythm.subject-after-gap), + doc-title-top-gap: _option-any(layout, ("doc-title-top-gap", "statement-title-top-gap"), rhythm.doc-title-top-gap), + doc-title-rule-gap: _option-any(layout, ("doc-title-rule-gap", "statement-title-rule-gap"), rhythm.doc-title-rule-gap), + doc-title-content-gap: _option-any(layout, ("doc-title-content-gap", "statement-title-content-gap"), rhythm.doc-title-content-gap), + heading-1-before: _option(layout, "heading-1-before", rhythm.heading-1-before), + heading-1-after: _option(layout, "heading-1-after", rhythm.heading-1-after), + heading-2-before: _option(layout, "heading-2-before", rhythm.heading-2-before), + heading-2-after: _option(layout, "heading-2-after", rhythm.heading-2-after), + preset: resolved-preset, + repeat-header: resolved-repeat-header, + page-numbering: as-bool(_option(layout, "page-numbering", true)), + header-height: _option(layout, "header-height", 17mm), + ) +} + +// Contact rendering mirrors modernpro-cv so the same `profile` dictionary can +// be imported into a CV and a letter without editing either one. +#let _contact-has-icon(contact) = ( + type(contact) == dictionary + and ("icon" in contact) + and is-filled(contact.icon) +) + +#let _contact-label(contact, cfg) = layout(size => { + // Only oversized tokens become breakable boxes, keeping copied labels free + // of added characters. Link destinations and short labels stay intact. + set text(hyphenate: false) + show regex("\\S+"): it => context { + if measure(it).width <= size.width { it } else { + it.text.matches(regex("[^./@_-]+[./@_-]?|[./@_-]")).map(part => { + if measure(text(part.text)).width > size.width { + part.text.clusters().map(char => box(char)).join() + } else { box(part.text) } + }).join() + } + } + let label = if type(contact) == dictionary { + _option(contact, "text", []) + } else { + contact + } + let has-link = type(contact) == dictionary and ("link" in contact) and is-filled(contact.link) + let rendered = text(fill: if has-link { cfg.accent } else { cfg.muted })[#label] + if has-link { link(contact.link)[#rendered] } else { rendered } +}) + +#let _contact-icon(contact, cfg) = if _contact-has-icon(contact) { + context { + // Center the visible glyph in the label's first line, independent of the + // icon font's metrics or a caller's legacy top-edge: "baseline" setting. + let icon = { + show text: it => text(top-edge: "bounds", bottom-edge: "bounds", it) + text(cfg.contact-icon-size, fill: cfg.accent)[#_option(contact, "icon", [])] + } + box(height: measure(text(cfg.contact-size)[M]).height, align(horizon, icon)) + } +} else { + [] +} + +#let letter-contact-display(contacts, cfg) = { + context { + set text(cfg.contact-size, fill: cfg.muted) + layout(size => contacts.map(contact => { + let item = if _contact-has-icon(contact) { + grid( + columns: (auto, auto), + column-gutter: cfg.contact-icon-gap, + align: left + top, + _contact-icon(contact, cfg), + _contact-label(contact, cfg), + ) + } else { + _contact-label(contact, cfg) + } + // Keep short items together; long labels wrap inside the available width. + box(width: calc.min(size.width, measure(item).width), item) + }).join(cfg.contact-separator)) + } +} + +#let letter-contact-stack(contacts, cfg) = { + context { + set text(cfg.contact-size, fill: cfg.muted) + + let has-icons = false + for contact in contacts { + if _contact-has-icon(contact) { + has-icons = true + } + } + + if has-icons { + let cells = () + for contact in contacts { + cells += ( + align(center + top, _contact-icon(contact, cfg)), + align(left + top, _contact-label(contact, cfg)), + ) + } + grid( + columns: (cfg.contact-icon-width, auto), + column-gutter: cfg.contact-icon-gap, + row-gutter: cfg.header-row-gap, + ..cells, + ) + } else { + grid( + columns: 1fr, + row-gutter: cfg.header-row-gap, + ..contacts.map(contact => align(cfg.contact-align, _contact-label(contact, cfg))), + ) + } + } +} + +#let letter-header(cfg) = { + // Typst measures a line box down to the baseline, so descenders hang outside + // it and a tight identity stack collides. Extending the bottom edge fixes the + // whole header at once, exactly as in modernpro-cv. + set text(bottom-edge: "descender") + set par(spacing: 0pt, first-line-indent: 0em) + + let identity = ( + if is-filled(cfg.name) { + align(cfg.name-align, text(cfg.name-size, fill: cfg.heading, weight: cfg.name-weight)[#cfg.name]) + }, + if is-filled(cfg.role) { + align(cfg.name-align, text(cfg.role-size, fill: cfg.accent, weight: "bold")[#cfg.role]) + }, + if is-filled(cfg.address) { + align(cfg.address-align, text(cfg.address-size, fill: cfg.muted)[#cfg.address]) + }, + ).filter(item => item != none) + + let has-contacts = cfg.contacts != none and cfg.contacts.len() > 0 + let contact-block = if not has-contacts { + none + } else if cfg.contact-layout == "inline" { + letter-contact-display(cfg.contacts, cfg) + } else { + letter-contact-stack(cfg.contacts, cfg) + } + let header-content(vertical) = if cfg.header-style == "centered" { + [ + #align(center, grid(columns: 1fr, row-gutter: cfg.header-row-gap, ..identity)) + #if has-contacts { + v(cfg.header-row-gap) + align(cfg.contact-align)[#letter-contact-display(cfg.contacts, cfg)] + } + ] + } else { + grid( + columns: (1.08fr, 1fr), + column-gutter: 1.4em, + align: vertical, + grid(columns: 1fr, row-gutter: cfg.header-row-gap, ..identity), + align(cfg.contact-align + vertical, [ + #if contact-block != none { contact-block } + ]), + ) + } + + block(breakable: false)[ + #layout(size => context { + let content = header-content(bottom) + let minimum = measure(box(height: cfg.header-height), width: size.width, height: size.height).height + // Align tall headers from the top, keeping the name beside the first contact. + if measure(content, width: size.width).height > minimum { + content = header-content(top) + } + grid( + columns: (0pt, 1fr), + column-gutter: 0pt, + align: bottom, + box(height: cfg.header-height), + content, + ) + }) + #v(cfg.header-rule-gap) + #line(length: 100%, stroke: cfg.rule-stroke + cfg.accent) + #v(cfg.header-content-gap) + ] +} + +#let letter-continuation-header(cfg, label) = { + let document-label = if cfg.page-numbering { + [#label · #counter(page).display("1 / 1", both: true)] + } else { + label + } + block(breakable: false)[ + #grid( + columns: (1fr, auto), + column-gutter: 1em, + text(cfg.contact-size, fill: cfg.heading, weight: "bold")[#cfg.name], + text(cfg.contact-size, fill: cfg.muted)[#document-label], + ) + #v(0.3em) + #line(length: 100%, stroke: cfg.rule-stroke + cfg.rule) + ] +} + +#let recipient-block(recipient, cfg) = { + let greeting = _first-filled(( + _option(recipient, "greeting", none), + _option(recipient, "start-title", none), + )) + let subject = _first-filled(( + _option(recipient, "subject", none), + _option(recipient, "cl-title", none), + )) + let date = _option(recipient, "date", none) + let name = _option(recipient, "name", none) + let role = _first-filled(( + _option(recipient, "role", none), + _option(recipient, "position", none), + )) + let department = _option(recipient, "department", none) + let institution = _first-filled(( + _option(recipient, "organization", none), + _option(recipient, "institution", none), + )) + let address = _option(recipient, "address", none) + let postcode = _option(recipient, "postcode", none) + let displayed-date = if is-filled(date) { + date + } else { + datetime.today(offset: auto).display(cfg.date-format) + } + + v(cfg.recipient-top-gap) + block(breakable: false)[ + #set par(leading: cfg.line-spacing, spacing: 0pt) + #grid( + columns: (1fr, auto), + column-gutter: 1.5em, + [ + #if is-filled(name) { + text(cfg.recipient-size, fill: cfg.heading, weight: "bold")[#name\ ] + } + #if is-filled(role) { + text(cfg.recipient-size, fill: cfg.text)[#role\ ] + } + #if is-filled(department) { + text(cfg.recipient-size, fill: cfg.text)[#department\ ] + } + #if is-filled(institution) { + text(cfg.recipient-size, fill: cfg.text)[#institution\ ] + } + #if is-filled(address) { + text(cfg.recipient-size, fill: cfg.muted)[#address\ ] + } + #if is-filled(postcode) { + text(cfg.recipient-size, fill: cfg.muted)[#postcode] + } + ], + align(right, text(cfg.recipient-size, fill: cfg.date-colour)[#displayed-date]), + ) + ] + + v(cfg.recipient-subject-gap) + block(sticky: true)[ + #if is-filled(subject) { + text(cfg.subject-size, fill: cfg.heading, weight: "bold")[#subject] + v(cfg.subject-after-gap) + } + #if is-filled(greeting) { + text(cfg.body-size, fill: cfg.text, weight: cfg.body-weight)[#greeting] + } + ] +} + +#let render-closing(cfg, closing-cfg) = { + block(breakable: false)[ + #v(closing-cfg.closing-spacing) + #set par(first-line-indent: 0em) + + #if closing-cfg.salutation != none { + text(cfg.body-size, fill: cfg.text, weight: closing-cfg.salutation-weight)[#closing-cfg.salutation] + v(closing-cfg.signature-spacing) + } + + #if is-filled(cfg.name) { + text(cfg.body-size, fill: cfg.heading, weight: closing-cfg.signature-weight)[#cfg.name] + } + + #if closing-cfg.supplements != none { + v(closing-cfg.supplement-spacing) + let additions = if type(closing-cfg.supplements) == array { + closing-cfg.supplements + } else { + (closing-cfg.supplements,) + } + for addition in additions { + text(cfg.supplement-size, fill: cfg.muted)[#addition] + linebreak() + } + } + ] +} + +#let coverletter( + profile: none, + preset: none, + accent: none, + recipient: (:), + closing: none, + font-type: none, + margin: none, + name: none, + address: none, + contacts: (), + supplements: none, + salutation: "Sincerely,", + primary-colour: none, + headings-colour: none, + subheadings-colour: none, + date-colour: none, + link-colour: none, + name-size: none, + body-size: none, + address-size: none, + contact-size: none, + recipient-size: none, + cl-title-size: none, + supplement-size: none, + line-stroke: none, + header-ascent: 1em, + first-line-indent: 0em, + line-spacing: none, + paragraph-spacing: none, + contact-separator: " · ", + name-align: none, + address-align: none, + contact-align: none, + name-weight: "bold", + body-weight: "regular", + salutation-weight: "regular", + signature-weight: "bold", + closing-spacing: 1.2em, + signature-spacing: 0.5em, + supplement-spacing: 1em, + date-format: "[day] [month repr:long] [year]", + theme: none, + layout: none, + mainbody, +) = { + let cfg = resolve-letter-config( + font-type: font-type, + margin: margin, + name: name, + address: address, + contacts: contacts, + primary-colour: primary-colour, + headings-colour: headings-colour, + subheadings-colour: subheadings-colour, + date-colour: date-colour, + link-colour: link-colour, + name-size: name-size, + body-size: body-size, + address-size: address-size, + contact-size: contact-size, + recipient-size: recipient-size, + cl-title-size: cl-title-size, + supplement-size: supplement-size, + line-stroke: line-stroke, + header-ascent: header-ascent, + first-line-indent: first-line-indent, + line-spacing: line-spacing, + paragraph-spacing: paragraph-spacing, + contact-separator: contact-separator, + name-align: name-align, + address-align: address-align, + contact-align: contact-align, + name-weight: name-weight, + body-weight: body-weight, + date-format: date-format, + profile: profile, + theme: theme, + layout: layout, + preset: preset, + accent: accent, + ) + let closing-cfg = ( + salutation: _option(closing, "salutation", salutation), + supplements: _option(closing, "supplements", supplements), + salutation-weight: _option(closing, "salutation-weight", salutation-weight), + signature-weight: _option(closing, "signature-weight", signature-weight), + closing-spacing: _option(closing, "closing-spacing", closing-spacing), + signature-spacing: _option(closing, "signature-spacing", signature-spacing), + supplement-spacing: _option(closing, "supplement-spacing", supplement-spacing), + ) + + set page( + margin: cfg.margin, + header: if cfg.repeat-header { + context { + if counter(page).get().first() > 1 { + letter-continuation-header(cfg, [Cover letter]) + } + } + } else { + none + }, + header-ascent: cfg.header-ascent, + ) + + // These rules must live in the template body itself. Moving them into a + // helper would scope them to that helper and silently leave the letter at + // Typst's defaults instead of the configured size, leading, and spacing. + set text(cfg.body-size, font: cfg.font, fill: cfg.text, weight: cfg.body-weight) + set par( + justify: cfg.justify, + first-line-indent: cfg.first-line-indent, + leading: cfg.line-spacing, + spacing: cfg.paragraph-spacing, + ) + show link: set text(fill: cfg.accent) + + letter-header(cfg) + recipient-block(recipient, cfg) + mainbody + render-closing(cfg, closing-cfg) +} + +#let statement( + profile: none, + preset: none, + accent: none, + title: none, + supplement: none, + font-type: none, + margin: none, + name: none, + address: none, + contacts: (), + primary-colour: none, + headings-colour: none, + subheadings-colour: none, + date-colour: none, + link-colour: none, + name-size: none, + body-size: none, + address-size: none, + contact-size: none, + line-stroke: none, + header-ascent: 1em, + first-line-indent: 0em, + line-spacing: none, + paragraph-spacing: none, + contact-separator: " · ", + name-align: none, + address-align: none, + contact-align: none, + name-weight: "bold", + body-weight: "regular", + theme: none, + layout: none, + mainbody, +) = { + // Statements are normally multi-page academic documents. They receive the + // compact continuation header by default while preserving an explicit user + // choice to turn it off. + let statement-layout = if layout == none { + (repeat-header: true,) + } else if "repeat-header" in layout { + layout + } else { + layout + (repeat-header: true,) + } + + let cfg = resolve-letter-config( + font-type: font-type, + margin: margin, + name: name, + address: address, + contacts: contacts, + primary-colour: primary-colour, + headings-colour: headings-colour, + subheadings-colour: subheadings-colour, + date-colour: date-colour, + link-colour: link-colour, + name-size: name-size, + body-size: body-size, + address-size: address-size, + contact-size: contact-size, + line-stroke: line-stroke, + header-ascent: header-ascent, + first-line-indent: first-line-indent, + line-spacing: line-spacing, + paragraph-spacing: paragraph-spacing, + contact-separator: contact-separator, + name-align: name-align, + address-align: address-align, + contact-align: contact-align, + name-weight: name-weight, + body-weight: body-weight, + profile: profile, + theme: theme, + layout: statement-layout, + preset: preset, + accent: accent, + ) + + set page( + margin: cfg.margin, + header: if cfg.repeat-header { + context { + if counter(page).get().first() > 1 { + letter-continuation-header(cfg, [#_first-filled((title,), default: [Statement])]) + } + } + } else { + none + }, + header-ascent: cfg.header-ascent, + ) + + set text(cfg.body-size, font: cfg.font, fill: cfg.text, weight: cfg.body-weight) + set par( + justify: cfg.justify, + first-line-indent: cfg.first-line-indent, + leading: cfg.line-spacing, + spacing: cfg.paragraph-spacing, + ) + show link: set text(fill: cfg.accent) + + show heading.where(level: 1): it => block( + above: 0pt, + below: 0pt, + sticky: true, + )[ + #v(cfg.heading-1-before) + #text(cfg.heading-1-size, fill: cfg.heading, weight: "bold")[#it.body] + #v(cfg.heading-1-after) + ] + + show heading.where(level: 2): it => block( + above: 0pt, + below: 0pt, + sticky: true, + )[ + #v(cfg.heading-2-before) + #text(cfg.heading-2-size, fill: cfg.heading, style: "italic")[#it.body] + #v(cfg.heading-2-after) + ] + + letter-header(cfg) + + // Deliberately not a `heading`: the level-1 show rule above would apply its + // own block spacing here and override the title's own rhythm tokens. + if is-filled(title) { + v(cfg.doc-title-top-gap) + block(sticky: true, above: 0pt, below: 0pt)[ + #block(above: 0pt, below: 0pt)[ + #text( + cfg.doc-title-size, + fill: cfg.heading, + weight: "bold", + bottom-edge: "descender", + )[#title] + ] + #v(cfg.doc-title-rule-gap) + #line(length: 100%, stroke: cfg.rule-stroke + cfg.rule) + ] + v(cfg.doc-title-content-gap) + } + + mainbody + + if supplement != none { + supplement + } +} diff --git a/packages/preview/modernpro-coverletter/1.0.3/screenshots/coverletter.png b/packages/preview/modernpro-coverletter/1.0.3/screenshots/coverletter.png new file mode 100644 index 0000000000..977898cf90 Binary files /dev/null and b/packages/preview/modernpro-coverletter/1.0.3/screenshots/coverletter.png differ diff --git a/packages/preview/modernpro-coverletter/1.0.3/screenshots/statement.png b/packages/preview/modernpro-coverletter/1.0.3/screenshots/statement.png new file mode 100644 index 0000000000..e5c59b4e34 Binary files /dev/null and b/packages/preview/modernpro-coverletter/1.0.3/screenshots/statement.png differ diff --git a/packages/preview/modernpro-coverletter/1.0.3/template/coverletter.typ b/packages/preview/modernpro-coverletter/1.0.3/template/coverletter.typ new file mode 100644 index 0000000000..41755d11c1 --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/template/coverletter.typ @@ -0,0 +1,43 @@ +#import "@preview/modernpro-coverletter:1.0.3": * + +// Edit identity and contacts here. Keeping them beside the letter content +// makes this starter a self-contained document with no personal-data imports. +#let profile = ( + name: [Your Name], + role: [Your Current Role], + address: [City, Country], + contacts: ( + (text: [name\@candidate.invalid], link: "mailto:name@candidate.invalid"), + (text: [site.candidate.invalid], link: "https://site.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +// Academic cover letter. Everything below `profile` and `recipient` is optional: +// preset: "compact" | "default" | "relaxed" vertical rhythm +// accent: rgb("#1e3a5f") the one colour in the document +#show: coverletter.with( + profile: profile, + recipient: ( + name: [Recipient Name], + role: [Recipient Role], + department: [Department], + organization: [Institution], + address: [City, Country], + date: [1 January 2026], + subject: [Application for Position Title], + greeting: [Dear Members of the Committee,], + ), + closing: ( + supplements: ([Enclosure: Curriculum vitae],), + ), +) + +State the position you are applying for, your current role, and the central fit +between your work and the department. + +Describe your strongest research contribution and the next question you plan +to pursue. + +Summarize your teaching or professional contribution, then close with a concise +statement of interest. diff --git a/packages/preview/modernpro-coverletter/1.0.3/template/statement.typ b/packages/preview/modernpro-coverletter/1.0.3/template/statement.typ new file mode 100644 index 0000000000..64965a4cac --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/template/statement.typ @@ -0,0 +1,34 @@ +#import "@preview/modernpro-coverletter:1.0.3": * + +// Edit identity and contacts here. Keeping them beside the statement content +// makes this starter a self-contained document with no personal-data imports. +#let profile = ( + name: [Your Name], + role: [Your Current Role], + address: [City, Country], + contacts: ( + (text: [name\@candidate.invalid], link: "mailto:name@candidate.invalid"), + (text: [site.candidate.invalid], link: "https://site.candidate.invalid"), + (text: [Fictional ID~0000-0000], link: "https://registry.example.invalid/0000-0000"), + ), +) + +// Research, teaching, or diversity statement. Shares the header, type scale, +// and colour of the cover letter and of modernpro-cv. A compact continuation +// header with page numbering appears automatically from page 2. +#show: statement.with( + profile: profile, + title: [Research Statement], +) + += Research agenda + +Introduce the question that connects your work and explain why it matters. + += Current programme + +Describe your strongest projects, methods, and contributions. + += Future work + +Set out the next phase of the programme and the environment it needs to succeed. diff --git a/packages/preview/modernpro-coverletter/1.0.3/thumbnail.png b/packages/preview/modernpro-coverletter/1.0.3/thumbnail.png new file mode 100644 index 0000000000..f40d1ad0a2 Binary files /dev/null and b/packages/preview/modernpro-coverletter/1.0.3/thumbnail.png differ diff --git a/packages/preview/modernpro-coverletter/1.0.3/typst.toml b/packages/preview/modernpro-coverletter/1.0.3/typst.toml new file mode 100755 index 0000000000..3d9767ae0f --- /dev/null +++ b/packages/preview/modernpro-coverletter/1.0.3/typst.toml @@ -0,0 +1,15 @@ +[package] +name = "modernpro-coverletter" +version = "1.0.3" +entrypoint = "modernpro-coverletter.typ" +authors = [ "Academic Template Collective",] +license = "MIT" +description = "A clean, modern cover letter and statement template for academic and professional applications." +keywords = [ "coverletter", "cv", "academic", "job",] +categories = [ "cv", "utility",] +exclude = [ "/screenshots/",] + +[template] +path = "template" +entrypoint = "coverletter.typ" +thumbnail = "thumbnail.png"