Merge pull request 'feat(categories): categories standard guide page (#117)' (#129) from issue-117-categories-standard-guide into main

This commit is contained in:
maximus 2026-04-21 01:07:05 +00:00
commit 115f707823
10 changed files with 539 additions and 0 deletions

View file

@ -3,6 +3,7 @@
## [Non publié] ## [Non publié]
### Ajouté ### Ajouté
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115) - **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)
## [0.8.3] - 2026-04-19 ## [0.8.3] - 2026-04-19

View file

@ -3,6 +3,7 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115) - **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)
## [0.8.3] - 2026-04-19 ## [0.8.3] - 2026-04-19

View file

@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage"; import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
@ -112,6 +113,10 @@ export default function App() {
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route
path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />}
/>
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} /> <Route path="/changelog" element={<ChangelogPage />} />
</Route> </Route>

View file

@ -0,0 +1,214 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface CategoryTaxonomyTreeProps {
nodes: TaxonomyNode[];
expanded: Set<number>;
onToggle: (id: number) => void;
searchQuery: string;
}
interface NodeRowProps {
node: TaxonomyNode;
depth: number;
expanded: Set<number>;
onToggle: (id: number) => void;
visibleIds: Set<number> | null;
}
function NodeRow({
node,
depth,
expanded,
onToggle,
visibleIds,
}: NodeRowProps) {
const { t } = useTranslation();
const label = t(node.i18n_key, { defaultValue: node.name });
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id);
// Filter children by visibility set (search mode) if provided.
const visibleChildren = useMemo(() => {
if (visibleIds === null) return node.children;
return node.children.filter((child) => visibleIds.has(child.id));
}, [node.children, visibleIds]);
const typeLabel =
node.type === "income"
? t("categoriesSeed.guidePage.type.income")
: node.type === "transfer"
? t("categoriesSeed.guidePage.type.transfer")
: t("categoriesSeed.guidePage.type.expense");
const tooltipText = [
`${t("categoriesSeed.guidePage.tooltip.key")}: ${node.i18n_key}`,
`${t("categoriesSeed.guidePage.tooltip.type")}: ${typeLabel}`,
`${t("categoriesSeed.guidePage.tooltip.id")}: ${node.id}`,
].join("\n");
// On screen: show children only if expanded.
// In print: @media print in styles.css overrides display:none to show everything.
const childrenHidden = !isExpanded;
return (
<li className="taxonomy-node">
<div
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-[var(--muted)] transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }}
title={tooltipText}
>
{hasChildren ? (
<button
type="button"
onClick={() => onToggle(node.id)}
aria-label={
isExpanded
? t("categoriesSeed.guidePage.collapseAll")
: t("categoriesSeed.guidePage.expandAll")
}
aria-expanded={isExpanded}
className="print:hidden shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="shrink-0 w-[14px]" aria-hidden="true" />
)}
<span
className="shrink-0 inline-block h-3 w-3 rounded-sm border border-[var(--border)]"
style={{ backgroundColor: node.color }}
aria-hidden="true"
/>
<span
className={
depth === 0
? "font-semibold text-[var(--foreground)]"
: depth === 1
? "font-medium text-[var(--foreground)]"
: "text-[var(--foreground)]"
}
>
{label}
</span>
{depth === 0 && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({node.children.length})
</span>
)}
</div>
{visibleChildren.length > 0 && (
<ul
className={`list-none m-0 p-0 taxonomy-children ${
childrenHidden ? "taxonomy-children-collapsed" : ""
}`}
>
{visibleChildren.map((child) => (
<NodeRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
)}
</li>
);
}
/**
* Build the set of node IDs whose visible subtree matches the query.
* A node is kept if its translated name contains the query OR any of its descendants match.
*/
export function collectVisibleIds(
roots: TaxonomyNode[],
normalizedQuery: string,
translate: (key: string, fallback: string) => string,
): Set<number> {
const visible = new Set<number>();
if (normalizedQuery.length === 0) {
const walk = (n: TaxonomyNode) => {
visible.add(n.id);
n.children.forEach(walk);
};
roots.forEach(walk);
return visible;
}
const walk = (node: TaxonomyNode): boolean => {
const label = translate(node.i18n_key, node.name);
const selfMatches = normalize(label).includes(normalizedQuery);
let anyChildMatches = false;
for (const child of node.children) {
if (walk(child)) anyChildMatches = true;
}
if (selfMatches || anyChildMatches) {
visible.add(node.id);
return true;
}
return false;
};
roots.forEach(walk);
return visible;
}
// Case-and-accent insensitive normalization for search.
export function normalize(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export default function CategoryTaxonomyTree({
nodes,
expanded,
onToggle,
searchQuery,
}: CategoryTaxonomyTreeProps) {
const { t } = useTranslation();
const normalizedQuery = normalize(searchQuery.trim());
const visibleIds = useMemo(() => {
if (normalizedQuery.length === 0) return null;
return collectVisibleIds(nodes, normalizedQuery, (key, fallback) =>
t(key, { defaultValue: fallback }),
);
}, [nodes, normalizedQuery, t]);
const visibleRoots =
visibleIds === null ? nodes : nodes.filter((r) => visibleIds.has(r.id));
if (visibleRoots.length === 0) {
return (
<p className="text-sm text-[var(--muted-foreground)] p-4">
{t("categoriesSeed.guidePage.noResults")}
</p>
);
}
return (
<ul className="list-none m-0 p-0">
{visibleRoots.map((root) => (
<NodeRow
key={root.id}
node={root}
depth={0}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
);
}

View file

@ -0,0 +1,38 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderTree, ChevronRight } from "lucide-react";
/**
* Card that surfaces category-related entries in the Settings page.
* For now: a single link to the read-only "standard categories guide"
* (Livraison 1 of the IPC category refactor).
*/
export default function CategoriesCard() {
const { t } = useTranslation();
return (
<Link
to="/settings/categories/standard"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FolderTree size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.standardGuideTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.standardGuideDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
);
}

View file

@ -564,6 +564,12 @@
"title": "User Guide", "title": "User Guide",
"description": "Learn how to use all features of the app" "description": "Learn how to use all features of the app"
}, },
"categoriesCard": {
"title": "Category management",
"description": "Organize your expenses and income the way you want.",
"standardGuideTitle": "Standard category structure",
"standardGuideDescription": "Browse the CPI taxonomy (read-only)"
},
"logs": { "logs": {
"title": "Logs", "title": "Logs",
"clear": "Clear", "clear": "Clear",
@ -1229,6 +1235,38 @@
"personnel": "Personal loan repayment" "personnel": "Personal loan repayment"
}, },
"internes": "Internal transfers" "internes": "Internal transfers"
},
"guidePage": {
"title": "Standard categories guide",
"subtitle": "Based on the Statistics Canada CPI classification",
"backToSettings": "Back to settings",
"intro": {
"title": "Why this structure?",
"body": "Statistics Canada's official household expenditure classification (CPI basket) identifies the main budget components of a Canadian household. This structure covers the full range of financial flows of a typical Quebec household, makes it easier to benchmark against external references, and produces clearer reports."
},
"counter": "{{roots}} root categories · {{subcategories}} subcategories · {{leaves}} leaves · {{total}} categories in total",
"searchPlaceholder": "Search a category...",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"print": "Export as PDF",
"printHint": "Use the print dialog to save as PDF.",
"noResults": "No category matches your search.",
"tooltip": {
"key": "i18n key",
"type": "Type",
"id": "Identifier"
},
"type": {
"income": "Income",
"expense": "Expense",
"transfer": "Transfer"
},
"legend": {
"title": "Legend",
"root": "Root category",
"subcategory": "Subcategory",
"leaf": "Leaf (final category)"
}
} }
} }
} }

View file

@ -564,6 +564,12 @@
"title": "Guide d'utilisation", "title": "Guide d'utilisation",
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application" "description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
}, },
"categoriesCard": {
"title": "Gestion des catégories",
"description": "Organisez vos dépenses et revenus selon vos besoins.",
"standardGuideTitle": "Structure standard des catégories",
"standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)"
},
"logs": { "logs": {
"title": "Journaux", "title": "Journaux",
"clear": "Effacer", "clear": "Effacer",
@ -1229,6 +1235,38 @@
"personnel": "Remboursement prêt perso" "personnel": "Remboursement prêt perso"
}, },
"internes": "Transferts internes" "internes": "Transferts internes"
},
"guidePage": {
"title": "Guide des catégories standard",
"subtitle": "Inspiré de la classification IPC de Statistique Canada",
"backToSettings": "Retour aux paramètres",
"intro": {
"title": "Pourquoi cette structure ?",
"body": "La classification officielle des dépenses des ménages au Canada (Statistique Canada, panier IPC) identifie les grandes composantes du budget d'un ménage. Cette structure permet de couvrir l'ensemble des flux financiers d'un ménage québécois typique, de comparer vos dépenses à des références externes et d'obtenir des rapports plus lisibles."
},
"counter": "{{roots}} catégories racines · {{subcategories}} sous-catégories · {{leaves}} feuilles · {{total}} catégories au total",
"searchPlaceholder": "Rechercher une catégorie...",
"expandAll": "Tout déplier",
"collapseAll": "Tout replier",
"print": "Exporter en PDF",
"printHint": "Utilisez la boîte de dialogue d'impression pour enregistrer en PDF.",
"noResults": "Aucune catégorie ne correspond à votre recherche.",
"tooltip": {
"key": "Clé i18n",
"type": "Type",
"id": "Identifiant"
},
"type": {
"income": "Revenu",
"expense": "Dépense",
"transfer": "Transfert"
},
"legend": {
"title": "Légende",
"root": "Catégorie racine",
"subcategory": "Sous-catégorie",
"leaf": "Feuille (catégorie finale)"
}
} }
} }
} }

View file

@ -0,0 +1,187 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../components/categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../services/categoryTaxonomyService";
function countNodes(nodes: TaxonomyNode[]): {
roots: number;
subcategories: number;
leaves: number;
} {
let roots = 0;
let subcategories = 0;
let leaves = 0;
for (const root of nodes) {
roots += 1;
for (const child of root.children) {
if (child.children.length === 0) {
// direct leaf under a root (rare but possible)
leaves += 1;
} else {
subcategories += 1;
for (const leaf of child.children) {
if (leaf.children.length === 0) leaves += 1;
else subcategories += 1;
}
}
}
}
return { roots, subcategories, leaves };
}
function collectAllIds(nodes: TaxonomyNode[]): number[] {
const ids: number[] = [];
const walk = (n: TaxonomyNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
nodes.forEach(walk);
return ids;
}
export default function CategoriesStandardGuidePage() {
const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => {
// Start with roots collapsed (user can expand as needed); counter and search still work.
return new Set<number>();
});
const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]);
const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => {
setExpanded(new Set(collectAllIds(taxonomy.roots)));
};
const handleCollapseAll = () => {
setExpanded(new Set());
};
const handlePrint = () => {
// window.print() opens the browser print dialog; @media print rules strip chrome.
window.print();
};
const allExpanded = expanded.size > 0;
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Back link (hidden in print) */}
<div className="print:hidden">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("categoriesSeed.guidePage.backToSettings")}
</Link>
</div>
{/* Header */}
<header className="space-y-1">
<h1 className="text-2xl font-bold">
{t("categoriesSeed.guidePage.title")}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.guidePage.subtitle")}
</p>
</header>
{/* Intro card */}
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<h2 className="text-lg font-semibold">
{t("categoriesSeed.guidePage.intro.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.guidePage.intro.body")}
</p>
</section>
{/* Counter + toolbar */}
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
<p
className="text-sm text-[var(--muted-foreground)]"
aria-live="polite"
>
{t("categoriesSeed.guidePage.counter", {
roots: counts.roots,
subcategories: counts.subcategories,
leaves: counts.leaves,
total,
})}
</p>
{/* Toolbar: search + actions (hidden in print) */}
<div className="print:hidden flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? (
<>
<ChevronsDownUp size={16} />
{t("categoriesSeed.guidePage.collapseAll")}
</>
) : (
<>
<ChevronsUpDown size={16} />
{t("categoriesSeed.guidePage.expandAll")}
</>
)}
</button>
<button
type="button"
onClick={handlePrint}
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
title={t("categoriesSeed.guidePage.printHint")}
>
<Printer size={16} />
{t("categoriesSeed.guidePage.print")}
</button>
</div>
</div>
</section>
{/* Tree */}
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print">
<CategoryTaxonomyTree
nodes={taxonomy.roots}
expanded={expanded}
onToggle={toggleNode}
searchQuery={search}
/>
</section>
</div>
);
}

View file

@ -23,6 +23,7 @@ import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard"; import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard"; import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner"; import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
import CategoriesCard from "../components/settings/CategoriesCard";
export default function SettingsPage() { export default function SettingsPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -121,6 +122,9 @@ export default function SettingsPage() {
</div> </div>
</Link> </Link>
{/* Categories card — entry to the standard categories guide */}
<CategoriesCard />
{/* Changelog card */} {/* Changelog card */}
<Link <Link
to="/changelog" to="/changelog"

View file

@ -133,6 +133,19 @@ body {
*, *::before, *::after { *, *::before, *::after {
transition: none !important; transition: none !important;
} }
/* Force the full categories taxonomy tree to expand on print */
.taxonomy-children-collapsed {
display: block !important;
}
.taxonomy-node {
break-inside: avoid;
}
}
/* Screen-only: collapse state hides unexpanded children */
.taxonomy-children-collapsed {
display: none;
} }
/* Scrollbar styling */ /* Scrollbar styling */