@5mts/canopy 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ # Dropdowns and accordions
2
+
3
+ `disclosure()` adds a button that opens and closes content. Use it with
4
+ the SCSS below; JavaScript alone won't hide anything. Set colors, spacing,
5
+ and borders in your theme.
6
+
7
+ ## Dropdown menu
8
+
9
+ This makes `#sub-nav-menu` a dropdown below 768px. The menu must contain a
10
+ `ul`. Put the JavaScript in your theme:
11
+
12
+ ```js
13
+ import { onGroveRender } from '@5mts/canopy/ready.js';
14
+ import { disclosure } from '@5mts/canopy/disclosure.js';
15
+
16
+ onGroveRender(() => {
17
+ const nav = document.querySelector('#sub-nav-menu');
18
+ if (nav) disclosure(nav, { label: 'Menu', toggleClass: 'my-toggle', panel: 'ul' });
19
+ });
20
+ ```
21
+
22
+ Add this to your SCSS:
23
+
24
+ ```scss
25
+ @use 'pkg:@5mts/canopy/patterns' as *;
26
+
27
+ .my-toggle { display: none; }
28
+
29
+ #sub-nav-menu {
30
+ @media (max-width: 767px) {
31
+ @include disclosure-dropdown('my-toggle') {
32
+ .my-toggle { @include disclosure-glyph; }
33
+ ul {
34
+ flex-direction: column;
35
+ background: white;
36
+ }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ Change `767px` to choose when the dropdown appears. Remove the media query
43
+ to use a dropdown at every width. Keep `toggleClass` and the mixin's first
44
+ argument identical. Repeated calls update the label without adding another button.
45
+
46
+ To close the menu on an outside click or Escape, pass
47
+ `closeOnOutsideClick: true` and `closeOnEscape: true` to `disclosure()`.
48
+
49
+ ## Collapsible transcript
50
+
51
+ This example finds a container whose first child is an `h2` containing only
52
+ "Transcript". In the CMS, start each transcript with that heading.
53
+
54
+ ```js
55
+ import { onGroveElement } from '@5mts/canopy/ready.js';
56
+ import { disclosure } from '@5mts/canopy/disclosure.js';
57
+
58
+ onGroveElement('h2:first-child', (heading) => {
59
+ if (heading.textContent.trim().toLowerCase() !== 'transcript') return;
60
+
61
+ const wrapper = heading.parentElement;
62
+ disclosure(wrapper, { label: 'Transcript', toggleClass: 'transcript-toggle' });
63
+ wrapper.classList.add('collapsible-transcript');
64
+ });
65
+ ```
66
+
67
+ ```scss
68
+ @use 'pkg:@5mts/canopy/patterns' as *;
69
+
70
+ .collapsible-transcript {
71
+ @include disclosure-accordion('transcript-toggle', $preserve: 'h2');
72
+ .transcript-toggle { @include disclosure-glyph; }
73
+ }
74
+ ```
75
+
76
+ The heading stays visible when closed. Omit `$preserve: 'h2'` to hide it
77
+ with the transcript. The container should hold only the transcript: its
78
+ other direct children will also collapse.
79
+
80
+ ## SCSS options
81
+
82
+ - `disclosure-dropdown($toggle-class, $panel: 'ul', $panel-display: flex)`
83
+ opens the panel as a full-width overlay. If you change `$panel`, change
84
+ the JavaScript `panel` option too.
85
+ - `disclosure-accordion($toggle-class, $preserve: ())` hides direct children
86
+ when closed, except the button and any selectors in `$preserve`.
87
+ - `disclosure-glyph($closed: '▼', $open: '▲')` adds an open/closed indicator
88
+ to the button.
89
+
90
+ Browsers without CSS `:has()` support leave the content expanded.
@@ -0,0 +1,112 @@
1
+ # Run JavaScript pasted into the CMS
2
+
3
+ Grove can change pages without reloading the browser. Canopy runs your code
4
+ when the elements it needs appear, including after those page changes.
5
+
6
+ Your site must load `global.js` once. If it doesn't yet, follow
7
+ [site setup](#site-setup).
8
+
9
+ ## Code for one page
10
+
11
+ Paste this into a page's HTML field. It adds a working "Back to top" button:
12
+
13
+ ```html
14
+ <button type="button" class="back-to-top">Back to top</button>
15
+ <script type="text/canopy">
16
+ canopy.on('groveElement', '.back-to-top', button => {
17
+ button.addEventListener('click', () => window.scrollTo({ top: 0 }));
18
+ });
19
+ </script>
20
+ ```
21
+
22
+ Keep `type="text/canopy"`. Canopy runs the script when it appears and stops
23
+ its registered callbacks when it leaves the page. Returning to the page
24
+ runs a newly inserted script again.
25
+
26
+ `groveElement` runs once per matching element. Use it for adding buttons,
27
+ changing text, or attaching click handlers.
28
+
29
+ ## Code for every page
30
+
31
+ Use a normal `<script>` in a site-wide header or footer slot:
32
+
33
+ ```html
34
+ <script>
35
+ canopy.on('groveElement', '.back-to-top', button => {
36
+ button.addEventListener('click', () => window.scrollTo({ top: 0 }));
37
+ });
38
+ </script>
39
+ ```
40
+
41
+ This handles each `.back-to-top` button as it appears across page changes.
42
+ Include the button HTML wherever you want it. The setup below must come
43
+ before site-wide snippets.
44
+
45
+ ## Site setup
46
+
47
+ Add this once in the site-wide header, before other canopy scripts. Replace
48
+ `VERSION` with the published version you want to use.
49
+
50
+ ```html
51
+ <script>
52
+ window.canopy = window.canopy || {
53
+ on: (...args) => (window.canopyActions = window.canopyActions || []).push(args),
54
+ };
55
+ </script>
56
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@5mts/canopy@VERSION/global.js"></script>
57
+ ```
58
+
59
+ The first script holds calls until canopy loads. Use a fixed version so
60
+ updates don't change your site unexpectedly.
61
+
62
+ If your theme already includes `import '@5mts/canopy/global.js'`, keep the
63
+ first script and omit the CDN script. Load `global.js` only once.
64
+
65
+ Sites whose Content Security Policy blocks `eval` cannot run `text/canopy`
66
+ scripts. Use a permitted normal script with `{ scope: 'page' }` instead.
67
+ Without `global.js`, `text/canopy` scripts do nothing.
68
+
69
+ ## Choose when code runs
70
+
71
+ | Action | Runs |
72
+ |---|---|
73
+ | `groveElement` | Once per element matching your selector |
74
+ | `groveRender` | When ready, then after content or navigation changes |
75
+ | `groveNavigate` | On load and whenever the path or query changes |
76
+
77
+ `groveNavigate` ignores hash changes; returning to an earlier URL runs it
78
+ again. The new page's elements may not exist yet, so use `groveElement`
79
+ when working with them.
80
+
81
+ With `groveRender`, check before changing text or adding/removing elements.
82
+ Those changes trigger another run; repeating them can freeze the page:
83
+
84
+ ```js
85
+ canopy.on('groveRender', () => {
86
+ const heading = document.querySelector('#section-title');
87
+ if (heading && heading.textContent !== 'Local news') {
88
+ heading.textContent = 'Local news';
89
+ }
90
+ });
91
+ ```
92
+
93
+ ## Limit a normal script to its current page
94
+
95
+ Pass `{ scope: 'page' }` last:
96
+
97
+ ```js
98
+ canopy.on('groveElement', '.back-to-top', button => {
99
+ button.addEventListener('click', () => window.scrollTo({ top: 0 }));
100
+ }, { scope: 'page' });
101
+ ```
102
+
103
+ This checks the path and query where the call was made. The callback stays
104
+ registered and can run again when you return. In a normal script, calls
105
+ apply site-wide unless you add this option. Calls from tag managers, the
106
+ console, or other callbacks follow the same rule.
107
+
108
+ Once canopy has loaded, `canopy.on()` returns a function you can call to
109
+ stop that registration. Calls made before it loads can't return this function.
110
+
111
+ For debugging, look for `[canopy]` errors in the browser console. Check
112
+ action names first: unknown names throw an error.
@@ -0,0 +1,52 @@
1
+ # Move content
2
+
3
+ `relocate()` moves matching elements when the page loads and after Grove
4
+ updates it. Register each move once in your theme's JavaScript.
5
+
6
+ ## Move the podcast subscribe links
7
+
8
+ This places the links inside the first `.my-sidebar-slot` element:
9
+
10
+ ```js
11
+ import { grove } from '@5mts/canopy/selectors.js';
12
+ import { relocate } from '@5mts/canopy/relocate.js';
13
+
14
+ relocate({ source: grove('podcast-subscribe'), dest: '.my-sidebar-slot' });
15
+ ```
16
+
17
+ Use any CSS selector for `source` and `dest`. All matching sources move;
18
+ if the destination is missing, they stay where they are.
19
+
20
+ `position` controls placement:
21
+
22
+ | Value | Placement |
23
+ |---|---|
24
+ | `beforeend` (default) | Inside, after existing content |
25
+ | `afterbegin` | Inside, before existing content |
26
+ | `beforebegin` | Before the destination |
27
+ | `afterend` | After the destination |
28
+
29
+ ## Prevent a flash before the move
30
+
31
+ Optional SCSS hides the source until it moves:
32
+
33
+ ```scss
34
+ @use 'pkg:@5mts/canopy' as *;
35
+ @use 'pkg:@5mts/canopy/patterns' as *;
36
+
37
+ @include grove-selector('podcast-subscribe') {
38
+ @include relocate-flash-guard($dest: '.my-sidebar-slot');
39
+ }
40
+ ```
41
+
42
+ Use the same destination selector in both examples. The source stays visible
43
+ on pages without a destination. If JavaScript fails, it reappears after
44
+ five seconds.
45
+
46
+ Options: `$fallback-delay: 5s`, `$duration: 0.4s`, and `$shift: -0.5rem`.
47
+ These control the fallback reveal's delay, duration, and vertical movement.
48
+ Reduced-motion settings remove the animation, but keep the delayed reveal.
49
+
50
+ Moved elements get `data-canopy-relocated`; destinations get the `canopy-relocated`
51
+ class. The SCSS uses the attribute to reveal each element after it moves,
52
+ including new content loaded during navigation.
@@ -0,0 +1,57 @@
1
+ # Put the sidebar on the left
2
+
3
+ `sidebar-left` moves Grove's sidebar to the left on wide screens and fixes
4
+ its margins. You choose which pages it affects and how much space separates
5
+ the columns.
6
+
7
+ ## Pages with a sidebar menu
8
+
9
+ This puts the sidebar on the left from 768px up, and above the content on
10
+ smaller screens:
11
+
12
+ ```scss
13
+ @use 'pkg:@5mts/canopy/patterns' as *;
14
+
15
+ html:has(#sub-nav-menu) {
16
+ @include sidebar-left($gap: 2rem, $stacked: sidebar-first);
17
+ }
18
+ ```
19
+
20
+ Replace `html:has(#sub-nav-menu)` to select different pages. Set column
21
+ widths in your theme.
22
+
23
+ ## Show and episode pages
24
+
25
+ These layouts use different widths for side-by-side columns:
26
+
27
+ ```scss
28
+ @use 'pkg:@5mts/canopy/patterns' as *;
29
+
30
+ @include sidebar-left($container: 'show-landing-container', $gap: 2rem);
31
+ @include sidebar-left($container: 'episode-container', $gap: 2rem);
32
+ ```
33
+
34
+ | `$container` | Sidebar moves left at |
35
+ |---|---|
36
+ | `'page-container'` (default) | 768px |
37
+ | `'show-landing-container'` | 768px |
38
+ | `'episode-container'` | 1024px |
39
+
40
+ For article, blog post, and live blog layouts, pass `$breakpoint: 1024px`
41
+ with `'page-container'` to match Grove. `'show-container'` combines layouts
42
+ with different widths, so it requires an explicit `$breakpoint`.
43
+
44
+ ## Options
45
+
46
+ - `$gap` sets the sidebar's right margin at every width. Omit it to leave
47
+ that margin unchanged.
48
+ - `$breakpoint` overrides the width in the table.
49
+ - `$stacked: sidebar-first` puts the sidebar above the content below that
50
+ width. `$stacked: sidebar-last` puts it below. Omit `$stacked` to keep
51
+ Grove's normal stacking.
52
+
53
+ If your `$breakpoint` is wider than Grove's, set `$stacked` too. This keeps
54
+ the columns stacked until your chosen width.
55
+
56
+ For a sidebar menu with current-page styling and a mobile dropdown, see
57
+ [section navigation](subnav.md).
@@ -0,0 +1,71 @@
1
+ # Section navigation
2
+
3
+ `subnav()` labels a menu for screen readers and marks the current page's
4
+ link. Use it with `disclosure()` for a dropdown on small screens.
5
+
6
+ ## JavaScript
7
+
8
+ This expects a menu container with `id="sub-nav-menu"` and a `ul` of links.
9
+ Add it to your theme's JavaScript:
10
+
11
+ ```js
12
+ import { onGroveRender } from '@5mts/canopy/ready.js';
13
+ import { subnav } from '@5mts/canopy/subnav.js';
14
+ import { disclosure } from '@5mts/canopy/disclosure.js';
15
+
16
+ onGroveRender(() => {
17
+ const nav = document.querySelector('#sub-nav-menu');
18
+ if (!nav) return;
19
+
20
+ const current = subnav(nav, { label: 'Section', currentClass: 'current-link' });
21
+
22
+ disclosure(nav, {
23
+ label: current?.textContent.trim() || 'Section',
24
+ panel: 'ul',
25
+ toggleClass: 'section-toggle',
26
+ closeOnOutsideClick: true,
27
+ closeOnEscape: true,
28
+ });
29
+ });
30
+ ```
31
+
32
+ The button shows the current page's link text, or "Section" if no link
33
+ matches. Remove the `disclosure()` call and import for an expanded menu.
34
+
35
+ ## SCSS
36
+
37
+ This bolds the current link and turns the menu into a dropdown below 768px:
38
+
39
+ ```scss
40
+ @use 'pkg:@5mts/canopy/patterns' as *;
41
+
42
+ .section-toggle { display: none; }
43
+
44
+ #sub-nav-menu {
45
+ a.current-link { font-weight: bold; }
46
+
47
+ @media (max-width: 767px) {
48
+ @include disclosure-dropdown('section-toggle') {
49
+ .section-toggle { @include disclosure-glyph; }
50
+ ul {
51
+ flex-direction: column;
52
+ background: white;
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Change the width, colors, and spacing for your site. More options:
60
+ [dropdowns and accordions](disclosure.md).
61
+
62
+ ## Current-page and accessibility details
63
+
64
+ - Matching links get `aria-current="page"` and `currentClass` (default:
65
+ `canopy-current`). Links must be on the same site. Matching ignores query
66
+ strings, hashes, and trailing slashes.
67
+ - `subnav()` returns the first matching link, or `null`.
68
+ - It adds a navigation role and label where needed, and preserves an
69
+ existing `aria-label`. Pass a container around the list, such as a `nav`
70
+ or `div`; a bare `ul` or `ol` won't get a navigation role or label.
71
+ - Lists get `role="list"` so Safari recognizes them when bullets are hidden.
package/global.js ADDED
@@ -0,0 +1,106 @@
1
+ // Enable <script type="text/canopy"> snippets and window.canopy.on().
2
+ //
3
+ // Load once in your theme:
4
+ // import '@5mts/canopy/global.js'
5
+ // Or use a module script:
6
+ // <script type="module" src=".../@5mts/canopy/global.js"></script>
7
+ //
8
+ // Page snippets run when their script element appears. Inside a snippet,
9
+ // canopy.on() callbacks stop when Grove removes the script from the page.
10
+ // Each snippet has its own variables and can appear before this module loads.
11
+ //
12
+ // window.canopy.on('groveRender', callback) runs on every page. Its action
13
+ // names match ready.js functions without the initial "on": groveRender,
14
+ // groveElement, groveNavigate. Add {scope: "page"} as the last argument to
15
+ // limit callbacks to the current path and query, including return visits.
16
+ // Calls made before this module loads need the setup in examples/inline-snippets.md.
17
+
18
+ import { onGroveRender, onGroveElement, onGroveNavigate } from "./ready.js";
19
+
20
+ const api = { onGroveRender, onGroveElement, onGroveNavigate };
21
+
22
+ function resolve(action) {
23
+ const method = api["on" + action[0].toUpperCase() + action.slice(1)];
24
+ if (!method) throw new Error(`[canopy] unknown action: '${action}'`);
25
+ return method;
26
+ }
27
+
28
+ // Read and remove the optional final {scope} argument.
29
+ function takeScope(args, fallback) {
30
+ let scope = fallback;
31
+ if (args.length && typeof args[args.length - 1] === "object") {
32
+ scope = args.pop().scope ?? fallback;
33
+ }
34
+ if (scope !== "page" && scope !== "site") {
35
+ throw new Error(`[canopy] unknown scope: '${scope}' (use "page" or "site")`);
36
+ }
37
+ return scope;
38
+ }
39
+
40
+ // Return the function that stops this callback. Invalid actions throw an error.
41
+ function on(action, ...args) {
42
+ if (takeScope(args, "site") === "page") {
43
+ const here = location.pathname + location.search;
44
+ args = args.map(a => typeof a !== "function" ? a : (...xs) => {
45
+ if (location.pathname + location.search === here) return a(...xs);
46
+ });
47
+ }
48
+ return resolve(action)(...args);
49
+ }
50
+
51
+ // Log errors and continue with the remaining queued calls.
52
+ function run(entry) {
53
+ try { on(...entry); }
54
+ catch (err) { console.error("[canopy] dropped queued action:", err); }
55
+ }
56
+
57
+ // Page snippets
58
+
59
+ const seen = new WeakSet();
60
+ const records = [];
61
+
62
+ function execute(el) {
63
+ const record = { el, offs: [], dead: false };
64
+ records.push(record);
65
+ const facade = {
66
+ on: (action, ...args) => {
67
+ if (record.dead) return () => {}; // don't add callbacks after leaving the page
68
+ if (takeScope(args, "page") === "site") return on(action, ...args, { scope: "site" });
69
+ const off = resolve(action)(...args);
70
+ record.offs.push(off);
71
+ return off;
72
+ },
73
+ };
74
+ try {
75
+ // Give each snippet its own variables and page-scoped canopy.on().
76
+ // If the site's Content Security Policy blocks eval, use a plain script
77
+ // with {scope: "page"} instead of text/canopy.
78
+ new Function("canopy", el.textContent)(facade);
79
+ } catch (err) {
80
+ console.error("[canopy] page snippet failed:", err);
81
+ }
82
+ }
83
+
84
+ // Run first so removed snippets stop before their callbacks can run again.
85
+ onGroveRender(() => {
86
+ for (const el of document.querySelectorAll('script[type="text/canopy"]')) {
87
+ if (seen.has(el)) continue;
88
+ seen.add(el);
89
+ execute(el);
90
+ }
91
+ for (let i = records.length - 1; i >= 0; i--) {
92
+ if (!records[i].el.isConnected) {
93
+ records[i].dead = true;
94
+ records[i].offs.forEach(off => off());
95
+ records.splice(i, 1);
96
+ }
97
+ }
98
+ });
99
+
100
+ // Keep existing references to window.canopy working.
101
+ window.canopy = Object.assign(window.canopy ?? {}, { on });
102
+
103
+ // Run queued calls now and new calls as they arrive.
104
+ const queued = Array.isArray(window.canopyActions) ? window.canopyActions : [];
105
+ window.canopyActions = { push: (...entries) => entries.forEach(run) };
106
+ queued.forEach(run);
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@5mts/canopy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "A lightweight toolkit for customizing the styling and presentation of Grove sites",
5
+ "author": "Five Mountains",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/5mts/canopy.git"
9
+ },
10
+ "homepage": "https://github.com/5mts/canopy#readme",
11
+ "bugs": "https://github.com/5mts/canopy/issues",
12
+ "type": "module",
13
+ "sass": "_index.scss",
14
+ "main": "_index.scss",
15
+ "exports": {
16
+ ".": {
17
+ "sass": "./_index.scss"
18
+ },
19
+ "./patterns": {
20
+ "sass": "./patterns/_index.scss"
21
+ },
22
+ "./patterns.scss": {
23
+ "sass": "./patterns/_index.scss"
24
+ },
25
+ "./selectors.js": "./selectors.js",
26
+ "./ready.js": "./ready.js",
27
+ "./global.js": "./global.js",
28
+ "./relocate.js": "./relocate.js",
29
+ "./disclosure.js": "./disclosure.js",
30
+ "./subnav.js": "./subnav.js",
31
+ "./_selectors.scss": "./_selectors.scss",
32
+ "./_layout.scss": "./_layout.scss"
33
+ },
34
+ "files": [
35
+ "_index.scss",
36
+ "_selectors.scss",
37
+ "_layout.scss",
38
+ "patterns/",
39
+ "examples/",
40
+ "selectors.js",
41
+ "ready.js",
42
+ "global.js",
43
+ "relocate.js",
44
+ "disclosure.js",
45
+ "subnav.js",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "build:selectors": "node scripts/generate-selectors-js.mjs",
51
+ "prepublishOnly": "npm run build:selectors"
52
+ },
53
+ "license": "MIT",
54
+ "keywords": [
55
+ "grove",
56
+ "sass",
57
+ "scss",
58
+ "adapter",
59
+ "theme"
60
+ ],
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }
@@ -0,0 +1,24 @@
1
+ // Fix Grove's audio player at the bottom of the screen. Set $offset for its
2
+ // bottom position and $reserve-footer for space below the footer content.
3
+ // Footer padding applies at $reserve-at and wider (768px by default).
4
+
5
+ @use '../selectors' as *;
6
+ @use '../layout' as *;
7
+
8
+ @mixin audio-player-bottom($offset: null, $reserve-footer: null, $reserve-at: grove-bp('md')) {
9
+ @include grove-selector('audio-player') {
10
+ position: fixed;
11
+ top: auto;
12
+ @if $offset != null {
13
+ bottom: $offset;
14
+ }
15
+ }
16
+
17
+ @if $reserve-footer != null {
18
+ @media (min-width: $reserve-at) {
19
+ .Page-footer {
20
+ padding-bottom: $reserve-footer;
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,77 @@
1
+ // Arrange a promo list in two or three columns. Use two-column-list() or
2
+ // three-column-list(). Both use two columns from $two-col-at (768px);
3
+ // three-column-list() adds a third from $three-col-at (1240px).
4
+ // Hides bylines, puts audio labels first, and removes image margins.
5
+
6
+ @use '../selectors' as *;
7
+ @use '../layout' as *;
8
+
9
+ @mixin column-list(
10
+ $columns: 3,
11
+ $gap: 1.5rem,
12
+ $tile-padding: 20px 10px,
13
+ $row-margin: 0 -10px,
14
+ $two-col-at: grove-bp('md'),
15
+ $three-col-at: grove-bp('lg')
16
+ ) {
17
+ display: flex;
18
+ flex-direction: row;
19
+ flex-wrap: wrap;
20
+ margin: $row-margin;
21
+
22
+ @include grove-selector('promo') {
23
+ display: flex;
24
+ flex-direction: column;
25
+ margin: 0;
26
+ }
27
+
28
+ // Tile spacing.
29
+ & > * {
30
+ padding: $tile-padding;
31
+ margin: 0;
32
+ border-bottom: none;
33
+ }
34
+
35
+ @include grove-selector('promo-content') {
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: $gap;
39
+
40
+ @include grove-selector('byline') {
41
+ display: none;
42
+ }
43
+
44
+ @include grove-selector('promo-audio-label') {
45
+ order: 1;
46
+ }
47
+ @include grove-selector(('promo-title', 'promo-content', 'description')) {
48
+ order: 2;
49
+ }
50
+ }
51
+
52
+ // Two columns.
53
+ @media (min-width: $two-col-at) {
54
+ & > * {
55
+ flex-basis: 50%;
56
+ max-width: 50%;
57
+ }
58
+ }
59
+
60
+ // Three columns, if requested.
61
+ @if $columns >= 3 {
62
+ @media (min-width: $three-col-at) {
63
+ & > * {
64
+ flex-basis: 33%;
65
+ max-width: 33%;
66
+ }
67
+ }
68
+ }
69
+
70
+ @include grove-selector('promo-media') {
71
+ margin: 0;
72
+ }
73
+ }
74
+
75
+ // Both shortcuts accept column-list's named options, such as $gap.
76
+ @mixin two-column-list($args...) { @include column-list(2, $args...); }
77
+ @mixin three-column-list($args...) { @include column-list(3, $args...); }