diff --git a/backend/locales/en.json b/backend/locales/en.json
index 8563f493e..160cb7e40 100644
--- a/backend/locales/en.json
+++ b/backend/locales/en.json
@@ -1597,6 +1597,7 @@
"common.actions.optimize": "Optimize",
"common.actions.page": "Page",
"common.actions.preview": "Preview",
+ "common.actions.print": "Print",
"common.actions.proceed": "Proceed",
"common.actions.properties": "Properties",
"common.actions.refresh": "Refresh",
@@ -2282,7 +2283,6 @@
"history.downloadFailed": "Failed to download this version.",
"history.downloadVersion": "Download Version",
"history.emptyPage": "Nothing",
- "history.exportPdf": "Export to PDF",
"history.inline": "Inline",
"history.loadFailed": "Failed to load the page history.",
"history.none": "No history has been recorded for this page yet.",
diff --git a/backend/modules/extensions/puppeteer/definition.yml b/backend/modules/extensions/puppeteer/definition.yml
index e012a2c47..e8ee6c1c6 100644
--- a/backend/modules/extensions/puppeteer/definition.yml
+++ b/backend/modules/extensions/puppeteer/definition.yml
@@ -1,9 +1,9 @@
key: puppeteer
title: Puppeteer
description: >-
- Headless Chromium browser. Required to export pages as PDF and to render content elements on the
- server, such as Mermaid or PlantUML diagrams. Installing it downloads a Chromium build of a few
- hundred megabytes, unless the server already provides one through PUPPETEER_EXECUTABLE_PATH.
+ Headless Chromium browser. Required to re-render a page from its source on the server: the markdown
+ renderer lives in the browser, so the server drives one. Installing it downloads a Chromium build of
+ a few hundred megabytes, unless the server already provides one through PUPPETEER_EXECUTABLE_PATH.
website: 'https://pptr.dev'
detect:
type: module
diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js
index 37b387d72..688dcf422 100644
--- a/blocks/block-tabs/component.js
+++ b/blocks/block-tabs/component.js
@@ -165,6 +165,43 @@ Content of the second tab.
margin-bottom: 0;
}
+ /*
+ ON PAPER
+ --------
+
+ The strip goes. A tabset shows one panel at a time because a screen has one place to put it,
+ and on paper every panel prints, one after another, each under a bar carrying its own label --
+ so a row of buttons naming panels that are all right there is a control with nothing left to
+ control.
+
+ Only this half is here. Showing the panels and drawing those bars belongs to the app's print
+ stylesheet, because the panels are slotted light DOM and which one is showing is an inline
+ display this block writes onto them: nothing in here reaches either. See the block-tab rules in
+ the print section of the app's css/_page-contents.scss.
+
+ The frame stays, and it is the reason for the one thing that does change here: with the strip
+ gone it is all that says these sections were one set, so it keeps its border and its radius and
+ gives up only the shadow, which prints as a grey smudge along two edges and lifts nothing.
+
+ What the panel gives up is its padding, down from 16px/20px to 8px all round. On screen that
+ inset is what holds a panel's content off the frame around it; on paper it is 40px of the
+ measure spent on white space that the label bar and the spine inside it already mark out --
+ and it is spent twice, since each tab pads itself off its own spine as well. Eight is what
+ keeps the content from touching the frame, and no more than that.
+ */
+ @media print {
+ .strip {
+ display: none;
+ }
+ .tabs,
+ :host([dark]) .tabs {
+ box-shadow: none;
+ }
+ .panel {
+ padding: 8px;
+ }
+ }
+
:host {
--tabs-border: #e0e0e0;
--tabs-strip-bg: linear-gradient(to bottom, #fdfdfd, #eeeeee);
@@ -179,13 +216,27 @@ Content of the second tab.
--tabs-active-label: var(--tabs-active-fg);
--tabs-panel-bg: #fff;
}
- :host([dark]) {
- --tabs-border: rgba(255, 255, 255, 0.15);
- --tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d);
- --tabs-inactive-fg: rgba(255, 255, 255, 0.7);
- /* -> A mix of --q-primary, so a re-themed site's own hue comes with it. See block-index. */
- --tabs-active-label: var(--color-primary-light);
- --tabs-panel-bg: #1e232a;
+ /*
+ The dark palette, and only where there is a lit screen to read it on.
+
+ The dark attribute comes off the body class, which says nothing about the medium -- so without
+ this a page printed from the dark theme printed a near-black panel, and the app prints the text
+ inside it in black ink (the article's palette is light on paper, by the same reasoning as here:
+ see the note on the dark block in css/_page-contents.scss). Black on #1e232a is a panel of
+ content that cannot be read at all.
+
+ Stated as a media query rather than unpicked token by token in the print block above, so that a
+ token added here later cannot quietly miss it.
+ */
+ @media not print {
+ :host([dark]) {
+ --tabs-border: rgba(255, 255, 255, 0.15);
+ --tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d);
+ --tabs-inactive-fg: rgba(255, 255, 255, 0.7);
+ /* -> A mix of --q-primary, so a re-themed site's own hue comes with it. See block-index. */
+ --tabs-active-label: var(--color-primary-light);
+ --tabs-panel-bg: #1e232a;
+ }
}
`
}
diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue
index 887abdfb8..2a0e6ab92 100644
--- a/frontend/src/components/PageHeader.vue
+++ b/frontend/src/components/PageHeader.vue
@@ -126,9 +126,9 @@
dense
icon="la:print"
color="grey"
- aria-label="Print"
+ :aria-label="t(`common.actions.print`)"
@click="printPage">
- Print
+ {{ t('common.actions.print') }}
- {{ t('history.exportPdf') }}
+ :aria-label="t(`common.actions.print`)"
+ @click="printPage">
+ {{ t('common.actions.print') }}
diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss
index 367f9bbb4..ef669ed05 100644
--- a/frontend/src/css/_page-contents.scss
+++ b/frontend/src/css/_page-contents.scss
@@ -219,80 +219,96 @@
*/
isolation: isolate;
+ /*
+ The dark palette, and only where there is a lit screen to read it on.
+
+ `@media not print` rather than a print block that puts each of these back one at a time: this is
+ two dozen tokens and it grows, and the print block below would have to shadow every one of them --
+ at a higher specificity, since `.body--dark .page-contents` outranks this block -- or quietly print
+ whichever it had missed. It missed most of them: a page printed from the dark theme came out with
+ invisible table and code rules (white at 16% opacity, on white paper), the lighter link blue, and
+ the GitHub-dark syntax colours.
+
+ So the condition is stated once, here, where it is also the true one: this palette is for a screen
+ that emits light. Paper does not, and what it falls back to is the light palette declared above,
+ which is the one the print block below was written against.
+ */
@at-root .body--dark & {
- --content-ink: rgba(255, 255, 255, 0.87);
- --content-ink-muted: rgba(255, 255, 255, 0.62);
- --content-ink-faint: rgba(255, 255, 255, 0.42);
+ @media not print {
+ --content-ink: rgba(255, 255, 255, 0.87);
+ --content-ink-muted: rgba(255, 255, 255, 0.62);
+ --content-ink-faint: rgba(255, 255, 255, 0.42);
- /* -> The mid-tone brand blue is too dim on a dark surface; see `--color-primary-light` */
- --content-link: var(--color-primary-light);
- --content-h1: var(--color-primary-light);
+ /* -> The mid-tone brand blue is too dim on a dark surface; see `--color-primary-light` */
+ --content-link: var(--color-primary-light);
+ --content-h1: var(--color-primary-light);
- --content-rule: rgba(255, 255, 255, 0.16);
- --content-rule-strong: rgba(255, 255, 255, 0.32);
- --content-h2-rule: rgba(255, 255, 255, 0.45);
+ --content-rule: rgba(255, 255, 255, 0.16);
+ --content-rule-strong: rgba(255, 255, 255, 0.32);
+ --content-h2-rule: rgba(255, 255, 255, 0.45);
- /* Matches the app's own `dark-4` panel, so code sits a step above the page rather than below it */
- --content-surface-code: #161b22;
- --content-surface-alt: rgba(255, 255, 255, 0.07);
+ /* Matches the app's own `dark-4` panel, so code sits a step above the page rather than below it */
+ --content-surface-code: #161b22;
+ --content-surface-alt: rgba(255, 255, 255, 0.07);
- /*
- The head keeps its colours -- see the light block -- but the shadow under the table cannot: a 5%
- black drop is invisible on a dark page. Deepened, and no wider, so it still reads as the panel
- being lifted rather than as a glow around it.
- */
- --content-table-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 3px 10px rgba(0, 0, 0, 0.32);
+ /*
+ The head keeps its colours -- see the light block -- but the shadow under the table cannot: a 5%
+ black drop is invisible on a dark page. Deepened, and no wider, so it still reads as the panel
+ being lifted rather than as a glow around it.
+ */
+ --content-table-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 3px 10px rgba(0, 0, 0, 0.32);
- /* -> Lifted off the page rather than sunk into it, which is the same intent as the light theme's
- pair: the dark page is already darker than anything a row could be tinted towards */
- --content-table-row: rgba(255, 255, 255, 0.02);
- --content-table-row-alt: rgba(255, 255, 255, 0.055);
+ /* -> Lifted off the page rather than sunk into it, which is the same intent as the light theme's
+ pair: the dark page is already darker than anything a row could be tinted towards */
+ --content-table-row: rgba(255, 255, 255, 0.02);
+ --content-table-row-alt: rgba(255, 255, 255, 0.055);
- /* -> Back to `.card-header`'s own near end: on a dark page the bar reads as dark as it measures,
- and the extra step the light theme needs would leave the head sitting above the page */
- --content-table-head: #1e232a;
+ /* -> Back to `.card-header`'s own near end: on a dark page the bar reads as dark as it measures,
+ and the extra step the light theme needs would leave the head sitting above the page */
+ --content-table-head: #1e232a;
- --content-mark: #6b5d13;
+ --content-mark: #6b5d13;
- /* -> Lifted off the dark page, and mixed from the lighter blue for the same reason links are */
- --content-links-row: rgba(255, 255, 255, 0.05);
- --content-links-row-hover: color-mix(in srgb, var(--color-primary-light) 14%, transparent);
- /* -> Barely there: on a dark page the same light that reads as a rounded top edge reads as a bar */
- --content-links-sheen: rgba(255, 255, 255, 0.045);
- /* -> Deepened, and no wider, for the reason the table's is: a 7% black drop is invisible here */
- --content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
+ /* -> Lifted off the dark page, and mixed from the lighter blue for the same reason links are */
+ --content-links-row: rgba(255, 255, 255, 0.05);
+ --content-links-row-hover: color-mix(in srgb, var(--color-primary-light) 14%, transparent);
+ /* -> Barely there: on a dark page the same light that reads as a rounded top edge reads as a bar */
+ --content-links-sheen: rgba(255, 255, 255, 0.045);
+ /* -> Deepened, and no wider, for the reason the table's is: a 7% black drop is invisible here */
+ --content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
- /*
- Lighter than the light theme's, because the box has to be seen against a dark page -- but held
- well below the white of the tick on it, which is what makes the tick readable.
- */
- --content-tick: rgba(255, 255, 255, 0.38);
- /* -> A white box would be a lit square on a dark page; the empty one is just its outline */
- --content-tick-empty: transparent;
-
- --content-info: #6ab0ff;
- --content-info-wash: rgba(106, 176, 255, 0.12);
- --content-success: #4ecf9a;
- --content-success-wash: rgba(78, 207, 154, 0.12);
- --content-warning: #f3b661;
- --content-warning-wash: rgba(243, 182, 97, 0.12);
- --content-danger: #ff8b8b;
- --content-danger-wash: rgba(255, 139, 139, 0.12);
- --content-important: #c4a7ff;
- --content-important-wash: rgba(196, 167, 255, 0.12);
-
- --content-code-comment: #8b949e;
- --content-code-keyword: #ff7b72;
- --content-code-string: #a5d6ff;
- --content-code-number: #79c0ff;
- --content-code-title: #d2a8ff;
- --content-code-type: #ffa657;
- --content-code-attr: #79c0ff;
- --content-code-meta: #8b949e;
- --content-code-addition: #56d364;
- --content-code-addition-wash: rgba(46, 160, 67, 0.15);
- --content-code-deletion: #ffa198;
- --content-code-deletion-wash: rgba(248, 81, 73, 0.15);
+ /*
+ Lighter than the light theme's, because the box has to be seen against a dark page -- but held
+ well below the white of the tick on it, which is what makes the tick readable.
+ */
+ --content-tick: rgba(255, 255, 255, 0.38);
+ /* -> A white box would be a lit square on a dark page; the empty one is just its outline */
+ --content-tick-empty: transparent;
+
+ --content-info: #6ab0ff;
+ --content-info-wash: rgba(106, 176, 255, 0.12);
+ --content-success: #4ecf9a;
+ --content-success-wash: rgba(78, 207, 154, 0.12);
+ --content-warning: #f3b661;
+ --content-warning-wash: rgba(243, 182, 97, 0.12);
+ --content-danger: #ff8b8b;
+ --content-danger-wash: rgba(255, 139, 139, 0.12);
+ --content-important: #c4a7ff;
+ --content-important-wash: rgba(196, 167, 255, 0.12);
+
+ --content-code-comment: #8b949e;
+ --content-code-keyword: #ff7b72;
+ --content-code-string: #a5d6ff;
+ --content-code-number: #79c0ff;
+ --content-code-title: #d2a8ff;
+ --content-code-type: #ffa657;
+ --content-code-attr: #79c0ff;
+ --content-code-meta: #8b949e;
+ --content-code-addition: #56d364;
+ --content-code-addition-wash: rgba(46, 160, 67, 0.15);
+ --content-code-deletion: #ffa198;
+ --content-code-deletion-wash: rgba(248, 81, 73, 0.15);
+ }
}
/* -> The page's own margins are the container's business; the first and last block give theirs up */
@@ -1850,6 +1866,13 @@
// PRINT
// ---------------------------------------------------------------------------
+ /*
+ Written against the light palette above and reached from either theme, because the dark one is
+ screen-only -- see the note on it.
+
+ The chrome around the article is dealt with in `css/_print.scss`, which is the rest of this story:
+ what is hidden, and what is left of the shell once it stops being a viewport.
+ */
@media print {
/* Washes and tints cost ink and print muddy; the structure has to come from the rules instead */
--content-ink: #000;
@@ -1869,6 +1892,18 @@
/* -> And the banding, which on paper is grey ink over every other row for no gain */
--content-table-row: transparent;
--content-table-row-alt: transparent;
+ /*
+ The article column prints with no padding of its own -- the sheet's own margin is the margin
+ now, see `_print.scss` -- so the rule under the page title has nothing left to reach back
+ through. Left at 1rem it would overhang the text by exactly the padding that is gone.
+ */
+ /*
+ `0px` and not `0`: the title's rule positions itself with `calc(-1 * var(--content-bleed))`, and
+ a unitless zero makes that a NUMBER rather than a length. The declaration is then invalid, `left`
+ falls back to `auto` against a `right: 0`, and a rule with no width to shrink to disappears --
+ which is exactly how the page title lost the rule under it on paper.
+ */
+ --content-bleed: 0px;
font-size: 11pt;
line-height: 1.55;
@@ -1892,9 +1927,174 @@
break-after: avoid;
}
- /* -> On paper there is no "leaves the wiki": every link is equally unfollowable */
+ /*
+ The rule under a title and a section heading is a gradient, which is to say a BACKGROUND -- and a
+ print dialog leaves background graphics off by default, so the one device that makes the sections
+ of a long page findable was the one thing that did not survive being printed. Same pair as a done
+ task item further up, for the same reason.
+ */
+ h1::after,
+ h2::after {
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ /* -> A single line of a paragraph stranded on either side of a break; three is the usual floor */
+ p,
+ li {
+ orphans: 3;
+ widows: 3;
+ }
+
+ /*
+ Code wraps.
+
+ On screen a long line scrolls sideways inside its panel and nothing is lost. On paper the panel is
+ as wide as the sheet and everything past that edge is simply gone -- silently, which is the worst
+ way for a code sample to be wrong. So the rule the screen keeps a few hundred lines up ("code does
+ not wrap: a wrapped line is a different line, and indentation stops meaning anything") is the
+ wrong trade here and is inverted: a wrapped line is still readable, an absent one is not.
+
+ `overflow-wrap: break-word` breaks only where a single unbroken run is wider than the panel by
+ itself -- a URL in a comment, a base64 literal, a long path -- so ordinary code still gives way at
+ its spaces and its indentation survives.
+
+ The `width: max-content` the code carries has to go with it: it sizes the block to its longest
+ line, which leaves `pre-wrap` nothing to wrap against.
+ */
+ pre {
+ overflow-x: visible;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ }
+ pre.codeblock > code {
+ width: auto;
+ }
+
+ /*
+ And the line-number gutter goes with it.
+
+ Neither the digits nor a highlighted line's wash is part of the text: they are an absolutely
+ positioned layer of empty rows, each exactly `1lh` tall, that lands on its line by geometry alone.
+ A wrapped line is taller than one row, so from the first wrap onwards every number sits against
+ the wrong line -- and a code sample whose numbers lie is worse than one with no numbers at all.
+
+ The padding the gutter had reserved goes back to the panel's own, so a numbered block does not
+ print with an empty 3.6rem margin down its left side.
+ */
+ pre.codeblock .line-numbers-rows {
+ display: none;
+ }
+ pre.codeblock.line-numbers {
+ --code-pad-left: var(--code-pad-x);
+ }
+
+ /*
+ Every panel of a tabset, one after another.
+
+ A tabset shows one panel at a time because a screen has one place to put it. Paper has as many
+ places as it needs, and a panel that does not print is content the reader is never told exists --
+ so they all print, each under a bar carrying its own label. `block-tabs` hides its strip for the
+ same reason, in its own stylesheet, where the strip lives.
+
+ Here rather than in that block because of where the panels live. `block-tab` has no shadow root at
+ all -- its content is page content, styled by this file, which is the whole point of it -- and
+ which panel is showing is an inline `display` its parent writes onto the element. So this is at
+ once the only stylesheet that reaches them and the only weight that beats an inline style.
+
+ `[label]` guards the bar: the label is an attribute, and a tab written without one would otherwise
+ print an empty rule.
+ */
+ block-tab {
+ display: block !important;
+ }
+ block-tab + block-tab {
+ margin-top: 1.5em;
+ }
+
+ /*
+ A solid bar and a spine down the side, and they are the same colour on purpose: at the top of a
+ panel the two meet and read as one shape turning the corner, which is what marks out where a
+ panel starts AND how far down the sheet it runs. That second half is the harder one -- a rule
+ under a label says where a panel begins and nothing at all about where it ends, and on paper
+ there is no strip left above to hold the set together.
+
+ This is the one place here that spends solid ink, and it is a deliberate departure from the note
+ a few rules up about a table's head: there, the bold text and the ruled grid already say "head",
+ so the near-black bar was ink buying nothing. Here the bar is the ONLY thing separating one
+ panel's content from the next one's, and drawn as a hairline and small caps it was too quiet to
+ do it.
+
+ Dark grey rather than the near-black the table's head uses on screen: it is enough for white to
+ read on (about 9:1) and costs a third less ink over a bar that repeats once per panel.
+
+ The bar's negative left margin is what takes it back over the padding so that it meets the spine
+ rather than starting inboard of it.
+
+ `print-color-adjust` is what makes any of it appear, for the reason a done task item carries the
+ same pair: a print dialog leaves background graphics OFF by default, so the fill would print as
+ nothing and the white label as white on white -- the panel losing its heading altogether, which
+ is worse than the quiet version this replaces. The property is inherited, so declaring it here
+ covers the bar as well as the spine.
+ */
+ block-tab[label] {
+ --content-tab-bar: #3a4049;
+ /*
+ Held as a property, and in `rem`, because the bar's negative margin has to cancel this padding
+ EXACTLY. Written as `0.9em` in both places it did not: an `em` here resolves against the
+ panel's font size and the same `em` on the bar against the bar's own smaller one, so the two
+ came out 13.2px and 10.7px and left a 2px seam of white between the spine and the bar.
+ */
+ --content-tab-pad: 0.75rem;
+
+ border-left: 4px solid var(--content-tab-bar);
+ padding-left: var(--content-tab-pad);
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+ block-tab[label]::before {
+ content: attr(label);
+ display: block;
+ margin: 0 0 0.9em calc(-1 * var(--content-tab-pad));
+ padding: 0.35em var(--content-tab-pad);
+ background-color: var(--content-tab-bar);
+ color: #fff;
+ font-size: 0.8125em;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ /*
+ The marker becomes the address.
+
+ On screen an external link only has to SAY that it leaves the wiki -- where it goes is in the
+ status bar, one hover away, and a click away after that. On paper there is neither: a link is
+ worth exactly the address printed beside it, so the arrow gives way to the href itself.
+
+ Still the renderer's class rather than a selector on the href, for the reason the screen rule
+ gives: whether an address is off-site is a question about its host, and that is settled at
+ render time. A relative link spelled out in full would be a path nobody can type.
+
+ Everything the screen rule set up for a masked glyph has to be undone, since this is the same
+ pseudo-element carrying text now rather than a shape -- the mask would otherwise cut the
+ address down to a 0.8em square of nothing.
+
+ A link whose text is already its own URL prints it twice. That is the cost of the rule, and it
+ is a good deal cheaper than a printed reference nobody can follow.
+ */
a.is-external-link::after {
- content: none;
+ content: ' (' attr(href) ')';
+ display: inline;
+ width: auto;
+ height: auto;
+ margin-left: 0;
+ background-color: transparent;
+ mask-image: none;
+ opacity: 1;
+ font-size: 0.85em;
+ /* -> A URL has no spaces to break at and would otherwise run off the edge of the sheet */
+ word-break: break-all;
}
}
}
diff --git a/frontend/src/css/_print.scss b/frontend/src/css/_print.scss
new file mode 100644
index 000000000..f0c6d7796
--- /dev/null
+++ b/frontend/src/css/_print.scss
@@ -0,0 +1,300 @@
+/*
+ THE PRINTED PAGE
+ ================
+
+ What the page view and the version view become on paper. This file is the app SHELL's half of it --
+ the chrome a page is drawn in. Two other places hold the rest, each because the rules there have to
+ sit next to the ones they override: `_page-contents.scss` typesets the article, over the content
+ palette declared at the top of that same file, and `SiteBanner.vue` handles the banner, for the
+ cascade reason set out at the foot of this file.
+
+ Two problems, and they are different ones.
+
+ The first is that the shell IS a viewport. `WLayout` is `height: 100vh; overflow: hidden`, and the
+ article scrolls inside `.page-container-scrl` rather than the window -- which is what keeps the
+ header, the sidebar and the action rail still while a reader scrolls. On paper that means exactly one
+ screenful is printed and the rest of the page is clipped away with nothing to say it happened. So
+ every fixed height and every scroll container has to be unwound back into normal flow before
+ anything else here is worth doing.
+
+ The second is that most of what is on screen is not the document. Someone printing a page wants the
+ page: its title, where it sits, when it was last written to, and its contents. The navigation, the
+ contents column, the action rail, the footer and the floating buttons are how they GOT here, not
+ what they are taking away.
+
+ `!important` throughout, deliberately, and it is not laziness: what these rules overrule is inline
+ styles (both views write `height: 100%` on the article column in the markup) and scoped rules from
+ single-file components, which carry a data attribute and so outrank a plain class whatever the order.
+ A print stylesheet that loses those fights prints a blank sheet and gives no clue why.
+
+ Colours are the exception -- there `!important` would be the wrong tool, because the rules being
+ beaten are per-theme ones written as `.body--dark .page-header-title`. Naming both theme classes
+ matches that specificity, and `app.scss` loads this file last so the tie goes to print.
+*/
+
+@media print {
+ /* ---------------------------------------------------------------------------
+ THE SHEET
+ --------------------------------------------------------------------------- */
+
+ /*
+ The margin the content gets.
+
+ Nothing here draws a running header or a page number: the browser prints its own -- title, URL,
+ date, page count -- outside this margin, and they are the reader's to keep or turn off in the print
+ dialog. CSS could only compete with them, and not well, since no engine this app targets fills a
+ page margin box with the document's own URL.
+ */
+ @page {
+ margin: 16mm 14mm;
+ }
+
+ /* ---------------------------------------------------------------------------
+ THE SHELL STOPS BEING A VIEWPORT
+ --------------------------------------------------------------------------- */
+
+ html,
+ body {
+ height: auto !important;
+ overflow: visible !important;
+ /* -> Every layout paints `body` in the dark theme; on paper the sheet is the ground */
+ background: #fff !important;
+ }
+
+ /*
+ The shell's grid becomes a stack.
+
+ Its three columns exist to seat the drawers beside the page, and both drawers are gone below -- but
+ that is not enough on its own: a grid item is stretched to its track, and the track is sized off a
+ viewport height that means nothing here.
+ */
+ .w-layout--page,
+ .w-layout--container {
+ display: block !important;
+ height: auto !important;
+ overflow: visible !important;
+ }
+
+ /*
+ And every box between the shell and the article gives up whatever it was doing to make one screenful
+ fit -- the flex column, the claimed height, the scroll container. What is left is blocks in normal
+ flow, which is the only arrangement that can run over a page break.
+ */
+ .w-page-container,
+ .w-page,
+ .page-container,
+ .page-container-scrl,
+ .page-container-body,
+ .w-scroll-area {
+ display: block !important;
+ height: auto !important;
+ min-height: 0 !important;
+ max-height: none !important;
+ overflow: visible !important;
+ flex: none !important;
+ }
+
+ /*
+ The article column's own padding goes: `@page` above is the margin now, and a second one inside it
+ only narrows the measure. `--content-bleed` is the padding this one is reaching back through (see
+ `_page-contents.scss`), so the print block there zeroes it to match -- left at 1rem the rule under
+ the page title would overhang the text by exactly the padding that is no longer there.
+ */
+ .page-container-body {
+ /*
+ Sides only, so the article keeps the full measure -- but not the top.
+
+ `.page-contents > :first-child` has its top margin zeroed, since on screen the column's own
+ padding is the space above the article. With that padding gone the first block sat against the
+ header's rule, and a page opening on an h1 -- which is most of them -- put 22pt of display type
+ hard against a line drawn 2px thick. This is the margin that block would otherwise have brought
+ with it.
+ */
+ padding: 1.25rem 0 0 !important;
+ }
+
+ /* ---------------------------------------------------------------------------
+ WHAT DOES NOT GO ON PAPER
+ --------------------------------------------------------------------------- */
+
+ /*
+ Navigation, the contents column, the action rail, the relation buttons under the article, the
+ footer, and every button that floats over the page -- all of them ways to go somewhere else, which
+ is the one thing a sheet of paper cannot do. The relations are the one that reads as content rather
+ than chrome, and they are not: they are the next page and the previous one, drawn as buttons.
+
+ The last five are the transient overlays, and they are here because printing is not a moment anyone
+ prepares for: a tooltip is on screen the instant the Print button is clicked, and Ctrl+P works just
+ as well with a dialog open. Each would otherwise print over the first page.
+ */
+ .w-header,
+ .w-drawer,
+ .w-drawer-scrim,
+ .page-sidebar,
+ .page-sidebar-scrim,
+ .page-actions,
+ .page-header-actions,
+ .page-relations,
+ .w-footer,
+ .w-page-scroller,
+ .corner-btn,
+ .w-tooltip,
+ .w-menu,
+ .w-notifications,
+ .w-loading,
+ .w-dialog-backdrop,
+ .w-dialog-viewport {
+ display: none !important;
+ }
+
+ /* ---------------------------------------------------------------------------
+ WHAT DOES, FLATTENED
+ --------------------------------------------------------------------------- */
+
+ /*
+ The title bar keeps the title, the description and the page's icon, and loses the gradient it sits
+ in. Its 95px is a measurement for a bar in a window; here it is a heading, so it takes the height of
+ what is in it and stays with the page it names.
+ */
+ .page-header {
+ height: auto;
+ break-after: avoid;
+ }
+
+ /*
+ And the words sit closer to the rule under them.
+
+ The 16px below the title column is pitched for a bar in a window, where the gradient it sits on is
+ what ends the header and the space is how the title is given room inside it. On paper the rule ends
+ the header, so the space under the words is doing nothing but pushing the article down the sheet.
+ Halved rather than dropped: the description still needs to clear the line under it.
+
+ Reached through `:has()` because the column is a stack of layout utilities and the two things in it
+ are the only stable names in the markup -- the same column, class for class, in both headers.
+ */
+ .page-header > div:has(> .page-header-title) {
+ padding-bottom: 0.5rem;
+ }
+
+ /* -> Same: the trail says where this page lives, which is no use on the sheet before it */
+ .page-breadcrumbs {
+ break-after: avoid;
+ }
+
+ /*
+ The home crumb becomes the site's own mark.
+
+ On screen that first crumb is a house because it is a BUTTON -- the way back to the root of the
+ wiki. Nothing on a sheet of paper goes anywhere, so what is left is a house standing at the head of
+ a path for no reason. The favicon in its place answers the question a printout actually raises,
+ which is which wiki this page was printed from, and it does it in the width the house was already
+ taking.
+
+ Matched on `data-icon` rather than on the crumb's position: `WIcon` writes the reference it drew
+ onto every branch it renders, so this says "the home icon" instead of "whatever is first", and it
+ stops matching by itself if that crumb ever stops being a house.
+
+ Drawn as a background on the link rather than swapped in the markup, because the swap is a
+ statement about paper and nothing else -- `WBreadcrumbs` is a shared component and this is not a
+ behaviour worth giving it a prop for. `print-color-adjust` because a background is exactly what a
+ print dialog drops by default, and the whole point here is that the mark appears.
+
+ 1.25em is the house's own size: `.w-breadcrumbs__el-icon` sets 125%, so the glyph draws at 1.25em
+ of the bar's text. The crumb carries no label, so it needs no margin either.
+ */
+ .page-breadcrumbs [data-icon='la:home'] {
+ display: none;
+ }
+ .page-breadcrumbs .w-breadcrumbs__el:has([data-icon='la:home'])::before {
+ content: '';
+ display: inline-block;
+ width: 1.25em;
+ height: 1.25em;
+ background-image: url('/_site/current/favicon');
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+ print-color-adjust: exact;
+ -webkit-print-color-adjust: exact;
+ }
+
+ /*
+ The indigo frame the version view draws down both sides and along the bottom goes.
+
+ On screen it is a surround sized to the window and drawn once, and it is how that view says a
+ snapshot is being read. On paper there is no window to frame: it becomes a 5px band of colour down
+ the edge of every sheet, saying what the bar at the top of the first one has already said in words
+ -- which is why that bar keeps a heavier rule here than the page view's.
+ */
+ .page-version {
+ border-width: 0;
+ }
+
+ /*
+ Ink on paper, whichever theme the screen was in.
+
+ Without this a page printed from the dark theme comes out with a white title on white paper -- the
+ header and the bars paint their own foregrounds per theme, and hiding their backgrounds is what
+ leaves the foreground stranded. Both theme classes are named because that is the specificity those
+ rules were written at.
+ */
+ .body--light,
+ .body--dark {
+ .page-breadcrumbs {
+ padding-left: 0;
+ padding-right: 0;
+ background: none;
+ border-bottom: 1px solid #ccc;
+ color: #000;
+ }
+
+ /*
+ The version view's bar keeps a heavier rule than the page view's, which is the one piece of
+ emphasis worth the ink here: on screen it is indigo against grey chrome and unmistakable, and
+ printed flat it would say "this is a record of a page and not the page" in the same voice as a
+ breadcrumb trail. A printout that reads as current when it is not is the failure this view has to
+ avoid.
+ */
+ .page-breadcrumbs--version {
+ border-bottom: 2px solid #000;
+ }
+
+ /*
+ A rule closes the header, in place of the gradient and the hairline it wears on screen.
+
+ Without one the title, the description and the article's first paragraph were three blocks of
+ text with nothing but space between them, and the space alone did not say which of them was the
+ page's name -- the very thing the gradient does on screen.
+
+ A hairline, and the trail's own grey: the same line the trail draws, top and bottom of one
+ masthead. It was 2px, on the reasoning that twice the weight would say which of the two closes
+ the block -- but a rule the full width of the sheet is the widest mark on it, and weight there
+ buys emphasis nobody asked for. The space around it does that work instead.
+
+ 1px is also the only width that survives the trip. On paper it is an absolute 1/96in whatever the
+ printer's resolution, but a print PREVIEW is rasterised at the display's scale factor, and on a
+ fractionally-scaled screen 2px lands on 2.5 or 3 device pixels and reads as a bar rather than a
+ rule. A hairline is the one weight both media agree on.
+ */
+ .page-header {
+ background: none;
+ border-top: 0;
+ border-bottom: 1px solid #ccc;
+ }
+
+ .page-header-title {
+ color: #000;
+ }
+ .page-header-subtitle {
+ color: #333;
+ }
+ }
+
+ /*
+ The site banner is the third place with print rules of its own, and the reason is the cascade
+ rather than tidiness: `SiteBanner.vue` ships its styles in the page view's own lazily-imported
+ chunk, which the browser adds AFTER this stylesheet. A rule here would tie with it on specificity
+ and lose on order, so the banner's print treatment sits in that component -- and what it does
+ there, in one line, is keep the notice and drop the ink.
+ */
+}
diff --git a/frontend/src/css/app.scss b/frontend/src/css/app.scss
index f23cb7a4d..431e56661 100644
--- a/frontend/src/css/app.scss
+++ b/frontend/src/css/app.scss
@@ -2,4 +2,5 @@
@use 'animation';
@use 'page-chrome';
@use 'page-contents';
-
+/* -> Last, so that where a print rule ties with a screen rule on specificity, print wins */
+@use 'print';
diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue
index a535a6866..f35b78191 100644
--- a/frontend/src/pages/Index.vue
+++ b/frontend/src/pages/Index.vue
@@ -129,7 +129,15 @@
ref="pageContents"
v-html="pageStore.render"
@click="onContentClick" />
-
+
+