Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions claude-notes/plans/2026-07-29-listing-scss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Vendor and integrate quarto-listing.scss (bd-57y4)

**Strand:** bd-57y4 (P2; discovered from L3 phase 7, bd-ml8z — see D5 in
`claude-notes/plans/2026-05-06-listings-L3-resolve-transform.md`)
**Created:** 2026-07-29
**Branch:** `braid/bd-57y4-listing-scss` (based on the blog-scaffold
branch, PR #434 — independent code, but the blog scaffold is the natural
e2e fixture)

## Overview

Q2 ships the listing JS pair (`list.min.js` + `quarto-listing.js`) as
Project-scoped artifacts but never compiled Q1's `quarto-listing.scss`,
so listing cards/tables/category chips render with default browser
styling. This strand vendors the SCSS and composes it into the theme-CSS
bundle as a `SassLayer`, restoring Q1 visual parity for listings and the
categories sidebar (the L5 markup already exists and consumes these
classes).

## Design decisions

1. **Unconditional layer (deviation from the strand sketch, matching
Q1).** The strand suggested adding the layer "when at least one
listing is rendered", but:
- Q1 attaches `listingSassBundle()` gated only on
`formatHasBootstrap(format)` — *not* on `pageHasListings`
(`website-listing.ts:284,424`); only the JS/postprocessor slots are
page-conditional. Q2 parity = unconditional.
- `CompileThemeCssStage` runs at stage 11, before the listing
transforms populate `ctx.resolved_listings` (stage 13) — the only
sound predicate would be raw `meta.listing`, which needs a new
`ThemeConfig` flag plus a cache-key discriminator
(`compile_theme_css.rs:234-238`), and would fork a website's theme
CSS into two fingerprints (listing vs non-listing pages), worse
for caching than one shared file.
- Cost of unconditional: a few KB of CSS on non-listing docs — the
same cost Q1 accepts.
2. **Vendor verbatim** to `resources/scss/html/templates/
quarto-listing.scss` (auto-embedded via `TEMPLATES_DIR` include_dir,
auto-hashed by quarto-sass `build.rs`). The file's
`/*-- scss:variables --*/` marker is *invalid* (only
uses/functions/defaults/mixins/rules parse), so its `!default` block
lands in the functions band — the same known quirk as
`title-block.scss` (`bundle.rs:178-189`, quarto-cli#13960). Q1's
parser has the identical behavior; do NOT "fix" the marker.
3. **HTML paths only** — the layer is pushed at every HTML assembly
site (`assemble_theme_scss`, native+wasm `compile_with_doc_vars`,
native+wasm `compile_default_css`); **not** `assemble_reveal_scss`
(no listings in slide decks).
4. **`$theme-name` parity (small extra).** Q1 sets `$theme-name` so the
listing SCSS's per-theme override map (cyborg/darkly/slate/… chip
borders and form colors) activates. Q2 never defines it; the layer's
own `$theme-name: null !default` then makes every override fall back
to defaults — correct for custom themes, a visible gap for the dark
built-ins. Since Sass `!default` also fires on *null* values, a
later-band `$theme-name: "<name>" !default` emitted for built-in
themes activates the map. Implement if it stays a ~few-line change
in the theme defaults; otherwise file a follow-up strand and ship
without it.

## Work Items

### Phase 1: tests first (TDD)

- [x] T1 `quarto-sass` unit tests (mirror the highlight/copy-code
regression pattern at `compile.rs:857-876`): compiled theme CSS
contains `.quarto-listing` and listing-category rules for (a) the
default-css path and (b) the themed/doc-vars path. One assertion
per assembly path touched.
- [x] T2 e2e integration test (brand_render.rs shape, using
`listing_pipeline.rs`'s `render_project` harness): render a
website with a listing page → concatenated `.css` under the
output tree contains `.quarto-listing` / `listing-category`
selectors.
- [x] T3 ($theme-name, if implemented) unit test: compiling theme
`darkly` yields the darkly category-chip override (border color
`$gray-600`) rather than the default.
- [x] T4 run tests, record expected failures.

### Phase 2: implementation

- [x] I1 copy `external-sources/quarto-cli/src/resources/projects/
website/listing/quarto-listing.scss` →
`resources/scss/html/templates/quarto-listing.scss` (verbatim);
update `resources/scss/README.md`'s vendored list.
- [x] I2 `load_listing_layer()` in `quarto-sass/src/bundle.rs` next to
`load_copy_code_layer`; push at the HTML assembly sites
(`compile.rs:88-98`, `:226-243`, `:359-370`, wasm `:498-515` and
wasm `compile_default_css`).
- [x] I3 ($theme-name) emit `$theme-name: "<name>" !default;` into the
defaults band for built-in bootstrap themes.
- [x] I4 make Phase-1 tests green; full `-p quarto-sass` +
`-p quarto-core` suites (preview/render CSS parity tests must
stay green — the layer is unconditional so fingerprints move
uniformly).

### Phase 3: verification + handoff

- [x] V1 `cargo build --workspace` + `cargo nextest run --workspace`.
- [x] V2 full `cargo xtask verify` (quarto-sass feeds the WASM leg; the
hub preview compiles SCSS through the dart-sass JS bridge — the
layer must ride the assembled string, which SassLayer composition
guarantees).
- [x] V3 e2e: `q2 create project blog myblog && q2 render myblog`,
inspect `_site/` theme CSS for `.quarto-listing` rules and load
the listing page to confirm card layout (image-right float,
category chips, pagination styling); check a dark theme (darkly)
if T3/I3 landed.
- [x] V4 update this plan, close bd-57y4 (note the unconditional-layer
deviation in the close reason), report.

## Implementation record (2026-07-29)

- T4 recorded failures: 3 quarto-sass unit tests + the e2e test all
failed before wiring (missing `.quarto-listing` selectors).
- I3 came **for free**: Q2's vendored bootstrap layer already defines
`$theme: "<name>" !default` per built-in theme and derives
`$theme-name` in `_bootstrap-variables.scss`, so the listing
override map fires with no new code. One subtlety, captured in the
darkly test: the override's `$gray-600` resolves to the
*bootstrap-default* value (#6c757d), not darkly's, because the
file's invalid `scss:variables` marker parks its `!default` block in
the functions band ahead of theme defaults — Q1's parser has the
identical band order, so this is parity, not a bug.
- V3 e2e (real binary): `q2 render` of the bd-r1by4u2a blog scaffold →
`site_libs/quarto/quarto-theme-<fp>.css` contains the card layout
(`div.quarto-post .thumbnail{flex-basis:30%…}`,
`.quarto-listing-default…`, category-chip rules) and is linked from
the listing page; markup classes (`quarto-post image-right`,
`listing-categories`) match. Output inspected.
- Tests: quarto-sass 210/210; e2e + both preview/render CSS-parity
guards green (the layer is unconditional, so fingerprints moved
uniformly in both pipelines).
- V1/V2 (2026-07-29): workspace 10794/10794; full `cargo xtask verify`
"All verification steps passed!". One legitimate baseline update: the
phase5 single-doc byte-identity fixture's `styles.css` hash
re-captured per its documented procedure (listing rules now in every
HTML theme compile; `doc.html` hash unchanged — no listing selector
matches a single-doc body).
Original file line number Diff line number Diff line change
Expand Up @@ -262,5 +262,11 @@
# the compiled CSS (this branch's title-block/tables/code/print/misc/13g rules +
# #407's ul.task-list/checkbox rules), so styles.css is re-captured to reflect
# BOTH rule sets. doc.html unchanged.
# Re-captured 2026-07-29 (bd-57y4): styles.css hash updated because the
# listing SCSS layer (Q1's quarto-listing.scss — cards, table layout,
# category chips, pagination) is now composed into every HTML theme
# compile, unconditionally like Q1. doc.html hash unchanged: no
# `.quarto-listing*` selector matches anything in a single-doc body,
# and `<head>` is unaffected.
doc.html c85d97d01136c864f9bd54ce0c5a15dd649c029a8f44aa3608819282199e3e57
doc_files/styles.css a54a2e6e2cf7beb3b74e1579ed08eb0af881ed9de390a582143a0d58ea34ca99
doc_files/styles.css 701063255f83fe84581ee7b0a02bdd620bfa4365c276d8b6585396d401926200
41 changes: 41 additions & 0 deletions crates/quarto-core/tests/integration/listing_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -968,3 +968,44 @@ fn front_matter_image_rebase_nested_host() {
);
assert!(dir.join("_site/posts/cover.png").exists());
}

/// The listing SCSS layer must reach the *rendered site's* theme CSS
/// (bd-57y4). quarto-sass unit tests can't see whether the pipeline
/// wired the layer; this is the brand_render.rs-shaped guard. The
/// layer is unconditional (Q1 parity), so any HTML render carries it —
/// asserted here on a listing project where it visibly matters.
#[test]
fn listing_css_rules_land_in_rendered_theme_css() {
let (dir, _outputs) = render_project(|p| {
write(
&p.join("_quarto.yml"),
"project:\n type: website\n output-dir: _site\nwebsite:\n title: \"Blog\"\nformat:\n html:\n theme: cosmo\n",
);
write(
&p.join("index.qmd"),
"---\ntitle: Blog\nlisting:\n contents: posts\nformat: html\n---\n",
);
write(
&p.join("posts/first.qmd"),
"---\ntitle: First\ndate: 2026-01-15\ncategories: [news]\nformat: html\n---\n\nBody.\n",
);
});

let mut css = String::new();
for entry in walkdir::WalkDir::new(dir.join("_site"))
.into_iter()
.filter_map(Result::ok)
{
if entry.path().extension().and_then(|e| e.to_str()) == Some("css") {
css.push_str(&read(entry.path()));
}
}
assert!(
css.contains(".quarto-listing"),
"rendered theme CSS must contain .quarto-listing rules"
);
assert!(
css.contains(".listing-category"),
"rendered theme CSS must contain category-chip rules"
);
}
31 changes: 31 additions & 0 deletions crates/quarto-sass/src/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,37 @@ pub fn load_embed_example_layer() -> Result<SassLayer, SassError> {
parse_layer(content, Some("embed-example.scss"))
}

/// Load the listing (cards / table / category chips / pagination) SCSS
/// layer (bd-57y4).
///
/// Reads `quarto-listing.scss` — a verbatim vendor of Q1's
/// `projects/website/listing/quarto-listing.scss`. Like Q1's
/// `listingSassBundle()`, the layer is attached **unconditionally** to
/// every HTML (bootstrap) compile, not gated on a page having listings:
/// the theme-CSS stage runs before listing resolution, and keeping the
/// layer unconditional keeps one shared theme CSS per site. Slide
/// formats do not include it.
///
/// Known parity quirk carried over on purpose: the file's
/// `/*-- scss:variables --*/` marker is not a recognized boundary (in
/// Q1 or here), so its `!default` block rides along in the `functions`
/// band — same as `title-block.scss` (quarto-cli#13960). Do not "fix"
/// the marker without a deliberate parity decision.
///
/// The per-theme override map inside keys off `$theme-name`, emitted
/// for built-in themes by [`crate::themes::process_theme_specs`].
pub fn load_listing_layer() -> Result<SassLayer, SassError> {
use crate::resources::TEMPLATES_RESOURCES;

let content = TEMPLATES_RESOURCES
.read_str(Path::new("quarto-listing.scss"))
.ok_or_else(|| SassError::CompilationFailed {
message: "quarto-listing.scss not found in templates resources".to_string(),
})?;

parse_layer(content, Some("quarto-listing.scss"))
}

/// Load the reveal.js framework layer.
///
/// This is the reveal analogue of [`load_bootstrap_framework`]: it provides the
Expand Down
Loading
Loading