diff --git a/docs/_exts/pylabrobot_cards/__init__.py b/docs/_exts/pylabrobot_cards/__init__.py new file mode 100644 index 00000000000..df0020efb85 --- /dev/null +++ b/docs/_exts/pylabrobot_cards/__init__.py @@ -0,0 +1 @@ +from .directives import setup # re-export for Sphinx diff --git a/docs/_exts/pylabrobot_cards/directives.py b/docs/_exts/pylabrobot_cards/directives.py new file mode 100644 index 00000000000..25c330373ef --- /dev/null +++ b/docs/_exts/pylabrobot_cards/directives.py @@ -0,0 +1,182 @@ +from docutils.parsers.rst import Directive, directives +from docutils import nodes +from .nodes import plr_card_grid_placeholder + + +def _split_tags(raw): + if not raw: + return [] + return [t.strip() for t in raw.split(",") if t.strip()] + + +def _ensure_env_map(env): + if not hasattr(env, "plr_cards"): + env.plr_cards = {} + return env.plr_cards + + +class _BaseCardDirective(Directive): + has_content = False + required_arguments = 0 + optional_arguments = 0 + final_argument_whitespace = False + option_spec = { + "header": directives.unchanged_required, + "card_description": directives.unchanged, + "image": directives.unchanged, + "image_hover": directives.unchanged, + "link": directives.unchanged_required, + "tags": directives.unchanged, + } + + def run(self): + env = self.state.document.settings.env + docname = env.docname + cards_map = _ensure_env_map(env) + cards = cards_map.setdefault(docname, []) + cards.append({ + "header": self.options.get("header", ""), + "desc": self.options.get("card_description", ""), + "image": self.options.get("image", ""), + "image_hover": self.options.get("image_hover", ""), + "link": self.options.get("link", ""), + "tags": _split_tags(self.options.get("tags", "")), + }) + # No visible node; actual rendering happens in the grid placeholder. + return [] + + +class PyLabRobotCard(_BaseCardDirective): + """ + Add a card. Aliases: + - .. customcarditem:: (compat) + - .. plrcard:: (PyLabRobot) + Options: + :header: Title (required) + :card_description: Short text + :image: path/to/image.png + :link: path/to/page.html (required) + :tags: Tag1, Tag2 + """ + + +class _BaseGridDirective(Directive): + has_content = False + + def run(self): + return [plr_card_grid_placeholder("")] + + +class PyLabRobotCardGrid(_BaseGridDirective): + """ + Insert a grid of the cards collected in this page. Aliases: + - .. cardgrid:: (compat) + - .. plrcardgrid:: (PyLabRobot) + """ + + +def _purge(app, env, docname): + if hasattr(env, "plr_cards") and docname in env.plr_cards: + del env.plr_cards[docname] + + +def _merge(app, env, docnames, other): + if not hasattr(other, "plr_cards"): + return + if not hasattr(env, "plr_cards"): + env.plr_cards = {} + env.plr_cards.update(other.plr_cards) + + +def _page_ctx(app, pagename, templatename, context, doctree): + env = app.builder.env + per_page = getattr(env, "plr_cards", {}).get(pagename, []) + all_tags = [] + for cards in getattr(env, "plr_cards", {}).values(): + for c in cards: + all_tags.extend(c.get("tags", [])) + all_tags = sorted(set(all_tags), key=str.lower) + context["plr_cards"] = per_page + context["plr_cards_all_tags"] = all_tags + +import os +from docutils import nodes + + +def _replace_placeholders(app, doctree, fromdocname): + env = app.builder.env + per_page = getattr(env, "plr_cards", {}).get(fromdocname, []) + + # prefix like "", "../", "../../" depending on nesting of fromdocname + docdir = os.path.dirname(fromdocname).replace("\\", "/") + depth = 0 if docdir == "" else docdir.count("/") + 1 + prefix = "../" * depth + + def is_url(p): + return p.startswith(("http://", "https://")) + + cards_render = [] + for c in per_page: + img = (c.get("image") or "").replace("\\", "/") + img_hover = (c.get("image_hover") or "").replace("\\", "/") + + image_url = "" + image_hover_url = "" + + # Resolve main image + if img: + if is_url(img) or img.startswith("/"): + image_url = img # absolute http(s) or site-root path + else: + if img.startswith("_static/"): + image_url = prefix + img + else: + image_url = prefix + img + + # Resolve hover image + if img_hover: + if is_url(img_hover) or img_hover.startswith("/"): + image_hover_url = img_hover + else: + if img_hover.startswith("_static/"): + image_hover_url = prefix + img_hover + else: + image_hover_url = prefix + img_hover + + cards_render.append({ + **c, + "image_url": image_url, + "image_hover_url": image_hover_url, + }) + + page_tags = sorted({t for c in per_page for t in c.get("tags", [])}, key=str.lower) + + for node in doctree.traverse(plr_card_grid_placeholder): + html = app.builder.templates.render("plr_card_grid.html", { + "cards": cards_render, + "all_tags": page_tags, + }) + raw = nodes.raw("", html, format="html") + node.replace_self(raw) + + +def setup(app): + from sphinx.application import Sphinx # noqa: F401 + + # Register directives (compat + PLR names) + app.add_directive("customcarditem", PyLabRobotCard) # compat + app.add_directive("plrcard", PyLabRobotCard) # PLR + app.add_directive("cardgrid", PyLabRobotCardGrid) # compat + app.add_directive("plrcardgrid", PyLabRobotCardGrid) # PLR + + # Events + app.connect("env-purge-doc", _purge) + app.connect("env-merge-info", _merge) + app.connect("html-page-context", _page_ctx) + app.connect("doctree-resolved", _replace_placeholders) + + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_exts/pylabrobot_cards/nodes.py b/docs/_exts/pylabrobot_cards/nodes.py new file mode 100644 index 00000000000..3a0e2cf87c5 --- /dev/null +++ b/docs/_exts/pylabrobot_cards/nodes.py @@ -0,0 +1,6 @@ +from docutils import nodes + + +class plr_card_grid_placeholder(nodes.General, nodes.Element): + """Placeholder node replaced by rendered card grid HTML.""" + pass diff --git a/docs/_static/cookbook_img/hi.png b/docs/_static/cookbook_img/hi.png new file mode 100644 index 00000000000..b224131f42d Binary files /dev/null and b/docs/_static/cookbook_img/hi.png differ diff --git a/docs/_static/cookbook_img/recipe_01_core_move.gif b/docs/_static/cookbook_img/recipe_01_core_move.gif new file mode 100644 index 00000000000..6dc47babac7 Binary files /dev/null and b/docs/_static/cookbook_img/recipe_01_core_move.gif differ diff --git a/docs/_static/cookbook_img/recipe_01_core_move_static.png b/docs/_static/cookbook_img/recipe_01_core_move_static.png new file mode 100644 index 00000000000..dee6a8327ab Binary files /dev/null and b/docs/_static/cookbook_img/recipe_01_core_move_static.png differ diff --git a/docs/_static/plr_cards.css b/docs/_static/plr_cards.css new file mode 100644 index 00000000000..abdba0e18bd --- /dev/null +++ b/docs/_static/plr_cards.css @@ -0,0 +1,223 @@ +/* ==== PyLabRobot Card Grid Styling ==== */ + +/* ==== Filter Bar ==== */ + +.plr-card-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.plr-tag-buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 0.6rem; +} + +.plr-filter-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + padding: 0.45rem 1.1rem; + border: 1px solid var(--pst-color-border, #ccc); + border-radius: 20px; + background: var(--pst-color-surface, #f7f7f7); + font-size: 0.9rem; + font-weight: 500; + text-align: center; + white-space: nowrap; + cursor: pointer; + transition: all 0.15s ease-in-out; + color: var(--pst-color-text, #222); + min-width: 100px; + text-decoration: none !important; +} + +.plr-filter-btn:hover { + background: var(--pst-color-hover, #e9eef7); + border-color: var(--pst-color-primary, #1a4ed8); + color: var(--pst-color-primary, #1a4ed8); + transform: translateY(-1px); +} + +.plr-filter-btn.active { + background: var(--pst-color-primary, #1a4ed8); + color: #fff; + border-color: var(--pst-color-primary, #1a4ed8); + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(26, 78, 216, 0.25); +} + +/* ==== Grid Layout ==== */ + +.plr-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 1rem; + margin-top: 1rem; + width: 100%; + position: relative; +} + +/* ==== Card Core ==== */ + +.plr-card { + display: flex; + flex-direction: column; + border: 1px solid var(--pst-color-border, #e0e0e0); + border-radius: 12px; + background: var(--pst-color-background, #fff); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + transition: transform 0.15s ease, box-shadow 0.25s ease, border-color 0.25s ease; + overflow: hidden; + opacity: 1; + transform: scale(1); + height: 100%; + animation: plrFadeIn 0.4s ease both; +} + +.plr-card.hidden { + opacity: 0; + transform: scale(0.97); + pointer-events: none; + transition: opacity 0.25s ease, transform 0.25s ease; +} + +.plr-card:hover { + transform: translateY(-4px); + border-color: var(--pst-color-primary, #1a4ed8); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); +} + +.plr-card a { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: row; + align-items: stretch; + height: 100%; +} + +/* ==== Content Layout ==== */ + +.plr-card-content { + flex: 1; + display: flex; + align-items: stretch; + justify-content: space-between; + min-height: 220px; + width: 100%; +} + +.plr-card-text { + flex: 1 1 60%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 1.5rem 1.7rem; +} + +.plr-card-text h4 { + margin: 0 0 0.6rem 0; + font-size: 1.2rem; + font-weight: 600; + color: var(--pst-color-primary, #1a4ed8); + line-height: 1.3; +} + +.plr-card-text p { + margin: 0; + margin-top: 0.25rem; + color: var(--pst-color-text, #333); + font-size: 0.95rem; + line-height: 1.55; + visibility: visible; + opacity: 1; + display: block; +} + +/* ==== Image ==== */ + +.plr-card-image { + flex: 0 0 320px; + overflow: hidden; + position: relative; + border-left: 1px solid var(--pst-color-border, #e0e0e0); + background: var(--pst-color-surface, #fafafa); + transition: transform 0.35s ease, box-shadow 0.35s ease; +} + +/* Force repaint on hover image switch */ +.plr-card-image img { + position: relative; + z-index: 2; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0; + transition: transform 0.35s ease, filter 0.3s ease, opacity 0.25s ease-in-out; + will-change: transform, filter, opacity; + isolation: isolate; + contain: paint; + backface-visibility: hidden; +} + +/* Image visual feedback */ +.plr-card:hover .plr-card-image img { + transform: scale(1.05); + filter: brightness(1.05); +} + +/* Add depth with subtle overlay */ +.plr-card-image::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.05), transparent); + opacity: 0; + transition: opacity 0.3s ease; + z-index: 1; +} + +.plr-card:hover .plr-card-image::after { + opacity: 1; +} + +/* ==== Animations ==== */ + +@keyframes plrFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ==== Responsive ==== */ + +@media (max-width: 900px) { + .plr-card a { + flex-direction: column; + } + + .plr-card-text { + padding: 1rem 1.25rem; + } + + .plr-card-image { + width: 100%; + height: 220px; + border-left: none; + border-top: 1px solid var(--pst-color-border, #e0e0e0); + } + + .plr-filter-btn { + font-size: 0.85rem; + padding: 0.4rem 0.9rem; + min-width: auto; + } +} diff --git a/docs/_static/plr_cards.js b/docs/_static/plr_cards.js new file mode 100644 index 00000000000..48d30257fc3 --- /dev/null +++ b/docs/_static/plr_cards.js @@ -0,0 +1,141 @@ +(function () { + function $(sel, root) { return (root || document).querySelector(sel); } + function $all(sel, root) { return [].slice.call((root || document).querySelectorAll(sel)); } + + /* -------------------- FILTERING -------------------- */ + + function applyFilters() { + var activeTags = $all(".plr-filter-btn.active") + .map(b => b.getAttribute("data-tag")) + .filter(t => t && t !== "All"); + + var searchEl = $("#plr-card-search"); + var q = (searchEl ? searchEl.value : "").trim().toLowerCase(); + + var cards = $all(".plr-card"); + var visibleCount = 0; + + cards.forEach(function (card) { + var tags = (card.getAttribute("data-tags") || "").split(/\s+/).filter(Boolean); + var title = (card.getAttribute("data-title") || "").toLowerCase(); + var desc = (card.getAttribute("data-desc") || "").toLowerCase(); + + // Tag logic: show if card has *all* active tags + var tagOk = activeTags.length === 0 || activeTags.every(t => tags.includes(t)); + var textOk = !q || title.includes(q) || desc.includes(q); + + if (tagOk && textOk) { + card.classList.remove("hidden"); + card.style.display = ""; + visibleCount++; + } else { + card.classList.add("hidden"); + card.style.display = "none"; + } + }); + + var viewAll = $("#plr-view-all"); + if (viewAll) { + viewAll.style.display = visibleCount < cards.length ? "flex" : "none"; + } + } + + function onClickTag(e) { + var btn = e.target.closest(".plr-filter-btn"); + if (!btn) return; + + var tag = btn.getAttribute("data-tag"); + + if (tag === "All") { + // Reset all + $all(".plr-filter-btn").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + } else { + // Toggle active state + btn.classList.toggle("active"); + // If any tag active, deactivate “All” + var allBtn = $(".plr-filter-btn[data-tag='All']"); + if (allBtn) { + if ($all(".plr-filter-btn.active").some(b => b.getAttribute("data-tag") !== "All")) + allBtn.classList.remove("active"); + else + allBtn.classList.add("active"); + } + } + + applyFilters(); + } + + function onSearch() { applyFilters(); } + + function resetFilters() { + $all(".plr-filter-btn").forEach(b => b.classList.remove("active")); + var first = $(".plr-filter-btn[data-tag='All']"); + if (first) first.classList.add("active"); + var search = $("#plr-card-search"); + if (search) search.value = ""; + applyFilters(); + try { window.scrollTo({ top: 0, behavior: "smooth" }); } catch (_) {} + } + + /* -------------------- HOVER IMAGE SWAP -------------------- */ + + function setupDelegatedImageHover() { + var grid = $(".plr-card-grid"); + if (!grid) return; + + $all(".plr-card-image img[data-hover]").forEach(function (img) { + var h = img.getAttribute("data-hover"); + if (h) { var pre = new Image(); pre.src = h; } + if (!img.getAttribute("data-src-original")) { + img.setAttribute("data-src-original", img.getAttribute("src") || ""); + } + }); + + grid.addEventListener("pointerenter", function (e) { + var img = e.target.closest(".plr-card-image img"); + if (!img) return; + var hoverSrc = img.getAttribute("data-hover"); + if (!hoverSrc) return; + if (!img.getAttribute("data-src-original")) { + img.setAttribute("data-src-original", img.getAttribute("src") || ""); + } + if (img.getAttribute("src") !== hoverSrc) { + img.setAttribute("src", hoverSrc); + } + }, true); + + grid.addEventListener("pointerleave", function (e) { + var img = e.target.closest(".plr-card-image img"); + if (!img) return; + var original = img.getAttribute("data-src-original"); + if (!original) return; + if (img.getAttribute("src") !== original) { + img.setAttribute("src", original); + } + }, true); + } + + /* -------------------- INIT -------------------- */ + + function init() { + var menu = $(".plr-filter-menu"); + if (menu && !menu.querySelector(".plr-filter-btn.active")) { + var first = menu.querySelector(".plr-filter-btn[data-tag='All']"); + if (first) first.classList.add("active"); + } + if (menu) menu.addEventListener("click", onClickTag); + + var search = $("#plr-card-search"); + if (search) search.addEventListener("input", onSearch); + + var viewAllBtn = $("#plr-view-all button"); + if (viewAllBtn) viewAllBtn.addEventListener("click", resetFilters); + + applyFilters(); + setupDelegatedImageHover(); + } + + if (document.readyState !== "loading") init(); + else document.addEventListener("DOMContentLoaded", init); +})(); diff --git a/docs/_templates/plr_card_grid.html b/docs/_templates/plr_card_grid.html new file mode 100644 index 00000000000..bd943b236ff --- /dev/null +++ b/docs/_templates/plr_card_grid.html @@ -0,0 +1,39 @@ +{# plr_card_grid.html — standalone snippet, no layout inheritance #} + +