♻️ (bootsier): Reorganiza en capas token/class/bs

Sustituye `attrs/` y `classes/` por tres capas diferenciadas:
- `token/`: primitivas CSS (BreakPoint, Color, ScaleSize, etc.).
- `class/`: tipos compuestos (Border, Background, Margin, etc.).
- `bs/*/props.rs`: props tipadas para cada componente de Bootstrap.

El patrón de construcción es `suffix()` -> `push_to()` -> `to_class()`.
`push_to()` es `pub` en `class/` y `bs/*/props.rs` para que los temas
derivados puedan acceder a los mismos mecanismos de construcción.
This commit is contained in:
Manuel Cillero 2026-06-29 20:32:20 +02:00
parent 64db114eb0
commit f37db56ec5
47 changed files with 1307 additions and 1201 deletions

View file

@ -0,0 +1,103 @@
use pagetop::prelude::*;
use crate::theme::token::Color;
/// Esquema de color para los bordes ([`Border`](crate::theme::class::Border)).
///
/// - `Theme(Color)` y `Subtle(Color)` usan la paleta de colores temáticos ([`Color`]).
/// - `Black` y `White` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorBorder {
/// No define ninguna clase.
#[default]
Default,
/// Genera la clase `border-{color}`.
Theme(Color),
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
/// Color blanco.
White,
}
impl ColorBorder {
// Devuelve el sufijo de la clase `border-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Theme(_) => Some(""),
Self::Subtle(_) => Some("-subtle"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
}
}
// Añade la clase `border-*` a la cadena de clases.
#[inline]
pub(crate) fn push_to(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Subtle(c) => {
classes.push_str("border-");
classes.push_str(c.as_str());
}
_ => classes.push_str("border"),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `border-*` correspondiente al color de borde.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let theme = token::ColorBorder::Theme(token::Color::Primary).to_class();
/// assert_eq!(theme, "border-primary");
///
/// let subtle = token::ColorBorder::Subtle(token::Color::Warning).to_class();
/// assert_eq!(subtle, "border-warning-subtle");
///
/// let black = token::ColorBorder::Black.to_class();
/// assert_eq!(black, "border-black");
///
/// let none = token::ColorBorder::Default.to_class();
/// assert_eq!(none, "");
/// ```
#[inline]
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
impl From<Color> for ColorBorder {
/// Convierte un [`Color`] en [`ColorBorder::Theme`].
///
/// Permite pasar un [`Color`] directamente donde se espera un [`ColorBorder`], sin necesidad
/// de envolver el valor en [`ColorBorder::Theme`]. Es el atajo habitual para los colores
/// temáticos.
///
/// Para los demás esquemas (`Subtle`, `Black`, `White`) se sigue usando [`ColorBorder`]
/// directamente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let border: token::ColorBorder = token::Color::Success.into();
/// assert_eq!(border.to_class(), "border-success");
/// ```
fn from(color: Color) -> Self {
Self::Theme(color)
}
}

View file

@ -0,0 +1,61 @@
use pagetop::prelude::*;
/// Define los puntos de ruptura (*breakpoints*) para aplicar diseño *responsive*.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BreakPoint {
/// **Menos de 576px**. Dispositivos muy pequeños: teléfonos en modo vertical.
#[default]
None,
/// **576px o más**. Dispositivos pequeños: teléfonos en modo horizontal.
SM,
/// **768px o más**. Dispositivos medianos: tabletas.
MD,
/// **992px o más**. Dispositivos grandes: puestos de escritorio.
LG,
/// **1200px o más**. Dispositivos muy grandes: puestos de escritorio grandes.
XL,
/// **1400px o más**. Dispositivos extragrandes: puestos de escritorio más grandes.
XXL,
}
impl BreakPoint {
/// Devuelve la identificación del punto de ruptura (`"sm"`, `"md"`, etc.), o `""` para `None`.
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "",
Self::SM => "sm",
Self::MD => "md",
Self::LG => "lg",
Self::XL => "xl",
Self::XXL => "xxl",
}
}
// Añade el punto de ruptura con un prefijo y un sufijo (opcional) a la cadena de clases.
//
// - Para `None`: `prefix` o `prefix-suffix` (si `suffix` no está vacío).
// - Para `SM..XXL`: `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
#[inline]
pub(crate) fn push_to(self, classes: &mut String, prefix: &str, suffix: &str) {
if prefix.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::None => classes.push_str(prefix),
_ => {
classes.push_str(prefix);
classes.push('-');
classes.push_str(self.as_str());
}
}
if !suffix.is_empty() {
classes.push('-');
classes.push_str(suffix);
}
}
}

View file

@ -0,0 +1,353 @@
use pagetop::prelude::*;
// **< Color >**************************************************************************************
/// Paleta de colores temáticos.
///
/// Equivalen a los nombres estándar definidos por Bootstrap (`primary`, `secondary`, `success`,
/// etc.). Este tipo enumerado sirve de referencia para componer las clases de color para el fondo
/// ([`Background`](crate::theme::class::Background)), bordes
/// ([`Border`](crate::theme::class::Border)) o texto ([`Text`](crate::theme::class::Text)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Color {
#[default]
Primary,
Secondary,
Success,
Info,
Warning,
Danger,
Light,
Dark,
}
impl Color {
/// Devuelve el nombre del color Bootstrap (`"primary"`, `"danger"`, etc.).
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Success => "success",
Self::Info => "info",
Self::Warning => "warning",
Self::Danger => "danger",
Self::Light => "light",
Self::Dark => "dark",
}
}
}
// **< Opacity >************************************************************************************
/// Niveles de opacidad (`opacity-*`).
///
/// Se usa normalmente para graduar la transparencia del color de fondo `bg-opacity-*`
/// ([`Background`](crate::theme::class::Background)), de los bordes `border-opacity-*`
/// ([`Border`](crate::theme::class::Border)) o del texto `text-opacity-*`
/// ([`Text`](crate::theme::class::Text)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Opacity {
/// No define ninguna clase.
#[default]
Default,
/// Permite generar clases `*-opacity-100` (100% de opacidad).
Opaque,
/// Permite generar clases `*-opacity-75` (75%).
SemiOpaque,
/// Permite generar clases `*-opacity-50` (50%).
Half,
/// Permite generar clases `*-opacity-25` (25%).
SemiTransparent,
/// Permite generar clases `*-opacity-10` (10%).
AlmostTransparent,
/// Permite generar clases `*-opacity-0` (0%, totalmente transparente).
Transparent,
}
impl Opacity {
// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Opaque => Some("-100"),
Self::SemiOpaque => Some("-75"),
Self::Half => Some("-50"),
Self::SemiTransparent => Some("-25"),
Self::AlmostTransparent => Some("-10"),
Self::Transparent => Some("-0"),
}
}
// Añade la opacidad a la cadena de clases usando el prefijo dado (`bg`, `border`, `text`, o
// vacío para `opacity-*`).
#[inline]
pub(crate) fn push_to(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
if prefix.is_empty() {
classes.push_str("opacity");
} else {
classes.push_str(prefix);
classes.push_str("-opacity");
}
classes.push_str(suffix);
}
}
/// Devuelve la clase de opacidad `opacity-*`.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(token::Opacity::Opaque.to_class(), "opacity-100");
/// assert_eq!(token::Opacity::Half.to_class(), "opacity-50");
/// assert_eq!(token::Opacity::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class, "");
class
}
}
// **< ColorBg >************************************************************************************
/// Esquema de color para el fondo ([`Background`](crate::theme::class::Background)).
///
/// - `Body`, `BodySecondary` y `BodyTertiary` siguen el esquema del tema (claro/oscuro).
/// - `Theme(Color)` y `Subtle(Color)` usan la paleta de colores temáticos ([`Color`]).
/// - `Black`, `White`, `Transparent` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorBg {
/// No define ninguna clase.
#[default]
Default,
/// Fondo predefinido del tema (`bg-body`).
Body,
/// Fondo predefinido del tema (`bg-body-secondary`).
BodySecondary,
/// Fondo predefinido del tema (`bg-body-tertiary`).
BodyTertiary,
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
Theme(Color),
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
/// Color blanco.
White,
/// Fondo transparente (`bg-transparent`).
Transparent,
}
impl ColorBg {
// Devuelve el sufijo de la clase `bg-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Body => Some("-body"),
Self::BodySecondary => Some("-body-secondary"),
Self::BodyTertiary => Some("-body-tertiary"),
Self::Theme(_) => Some(""),
Self::Subtle(_) => Some("-subtle"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
Self::Transparent => Some("-transparent"),
}
}
// Añade la clase de fondo `bg-*` a la cadena de clases.
#[inline]
pub(crate) fn push_to(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Subtle(c) => {
classes.push_str("bg-");
classes.push_str(c.as_str());
}
_ => classes.push_str("bg"),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `bg-*` correspondiente al fondo.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let body = token::ColorBg::Body.to_class();
/// assert_eq!(body, "bg-body");
///
/// let theme = token::ColorBg::Theme(token::Color::Primary).to_class();
/// assert_eq!(theme, "bg-primary");
///
/// let subtle = token::ColorBg::Subtle(token::Color::Warning).to_class();
/// assert_eq!(subtle, "bg-warning-subtle");
///
/// let transparent = token::ColorBg::Transparent.to_class();
/// assert_eq!(transparent, "bg-transparent");
///
/// let none = token::ColorBg::Default.to_class();
/// assert_eq!(none, "");
/// ```
#[inline]
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
impl From<Color> for ColorBg {
/// Convierte un [`Color`] en [`ColorBg::Theme`].
///
/// Permite pasar un [`Color`] directamente donde se espera un [`ColorBg`], sin necesidad de
/// envolver el valor en [`ColorBg::Theme`]. Es el atajo habitual para los colores temáticos.
///
/// Para los demás esquemas (`Body`, `Subtle`, `Black`, etc.) se sigue usando [`ColorBg`]
/// directamente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let bg: token::ColorBg = token::Color::Primary.into();
/// assert_eq!(bg.to_class(), "bg-primary");
/// ```
fn from(color: Color) -> Self {
Self::Theme(color)
}
}
// **< ColorText >**********************************************************************************
/// Esquema de color para el texto ([`Text`](crate::theme::class::Text)).
///
/// - `Body`, `BodyEmphasis`, `BodySecondary` y `BodyTertiary` siguen el tema (claro/oscuro).
/// - `Theme(Color)` y `Emphasis(Color)` usan la paleta de colores temáticos ([`Color`]).
/// - `Black` y `White` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorText {
/// No define ninguna clase.
#[default]
Default,
/// Color predefinido del tema (`text-body`).
Body,
/// Color de mayor contraste según el tema (`text-body-emphasis`).
BodyEmphasis,
/// Color predefinido del tema (`text-body-secondary`).
BodySecondary,
/// Color predefinido del tema (`text-body-tertiary`).
BodyTertiary,
/// Genera la clase `text-{color}`.
Theme(Color),
/// Genera la clase `text-{color}-emphasis` (mayor contraste acorde al tema).
Emphasis(Color),
/// Color negro.
Black,
/// Color blanco.
White,
}
impl ColorText {
// Devuelve el sufijo de la clase `text-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Body => Some("-body"),
Self::BodyEmphasis => Some("-body-emphasis"),
Self::BodySecondary => Some("-body-secondary"),
Self::BodyTertiary => Some("-body-tertiary"),
Self::Theme(_) => Some(""),
Self::Emphasis(_) => Some("-emphasis"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
}
}
// Añade la clase de texto `text-*` a la cadena de clases.
#[inline]
pub(crate) fn push_to(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Emphasis(c) => {
classes.push_str("text-");
classes.push_str(c.as_str());
}
_ => classes.push_str("text"),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `text-*` correspondiente al color del texto.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let body = token::ColorText::Body.to_class();
/// assert_eq!(body, "text-body");
///
/// let theme = token::ColorText::Theme(token::Color::Primary).to_class();
/// assert_eq!(theme, "text-primary");
///
/// let emphasis = token::ColorText::Emphasis(token::Color::Danger).to_class();
/// assert_eq!(emphasis, "text-danger-emphasis");
///
/// let black = token::ColorText::Black.to_class();
/// assert_eq!(black, "text-black");
///
/// let none = token::ColorText::Default.to_class();
/// assert_eq!(none, "");
/// ```
#[inline]
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
impl From<Color> for ColorText {
/// Convierte un [`Color`] en [`ColorText::Theme`].
///
/// Permite pasar un [`Color`] directamente donde se espera un [`ColorText`], sin necesidad de
/// envolver el valor en [`ColorText::Theme`]. Es el atajo habitual para los colores temáticos.
///
/// Para los demás esquemas (`Body`, `Emphasis`, `Black`, etc.) se sigue usando [`ColorText`]
/// directamente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let text: token::ColorText = token::Color::Danger.into();
/// assert_eq!(text.to_class(), "text-danger");
/// ```
fn from(color: Color) -> Self {
Self::Theme(color)
}
}

View file

@ -0,0 +1,87 @@
use pagetop::prelude::*;
// **< ScaleSize >**********************************************************************************
/// Escala discreta de tamaños para clases utilitarias de Bootstrap.
///
/// Se usa como parámetro de tamaño en [`Border`](crate::theme::class::Border),
/// [`Margin`](crate::theme::class::Margin) y [`Padding`](crate::theme::class::Padding). La
/// variante `Auto` no aplica en `Padding`.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ScaleSize {
/// Sin tamaño (no define ninguna clase).
#[default]
None,
/// Tamaño automático.
Auto,
/// Escala cero.
Zero,
/// Escala uno.
One,
/// Escala dos.
Two,
/// Escala tres.
Three,
/// Escala cuatro.
Four,
/// Escala cinco.
Five,
}
impl ScaleSize {
// Devuelve el sufijo de tamaño (`"-0"`, `"-1"`, etc.), o `None` si no define ninguna clase.
// `Auto` devuelve `Some("")` para que `push_to` emita sólo el prefijo, sin sufijo de tamaño.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Auto => Some(""),
Self::Zero => Some("-0"),
Self::One => Some("-1"),
Self::Two => Some("-2"),
Self::Three => Some("-3"),
Self::Four => Some("-4"),
Self::Five => Some("-5"),
}
}
// Añade el tamaño a la cadena de clases usando el prefijo dado.
#[inline]
pub(crate) fn push_to(self, classes: &mut String, prefix: &str) {
if !prefix.is_empty() {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(prefix);
classes.push_str(suffix);
}
}
}
}
// **< Side >***************************************************************************************
/// Lados sobre los que aplicar una clase utilitaria (respetando LTR/RTL).
///
/// Se usa como selector de lado en [`Border`](crate::theme::class::Border),
/// [`Margin`](crate::theme::class::Margin) y [`Padding`](crate::theme::class::Padding).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Side {
/// Todos los lados.
#[default]
All,
/// Lado superior.
Top,
/// Lado inferior.
Bottom,
/// Lado lógico de inicio (respetando RTL).
Start,
/// Lado lógico de fin (respetando RTL).
End,
/// Lados lógicos laterales (abreviatura *x*).
LeftAndRight,
/// Lados superior e inferior (abreviatura *y*).
TopAndBottom,
}

View file

@ -0,0 +1,84 @@
use pagetop::prelude::*;
/// Radio para el redondeo de esquinas ([`Rounded`](crate::theme::class::Rounded)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum RoundedRadius {
/// No define ninguna clase.
#[default]
None,
/// Genera `rounded` (radio por defecto del tema).
Default,
/// Genera `rounded-0` (sin redondeo).
Zero,
/// Genera `rounded-1`.
Scale1,
/// Genera `rounded-2`.
Scale2,
/// Genera `rounded-3`.
Scale3,
/// Genera `rounded-4`.
Scale4,
/// Genera `rounded-5`.
Scale5,
/// Genera `rounded-circle`.
Circle,
/// Genera `rounded-pill`.
Pill,
}
impl RoundedRadius {
// Devuelve el sufijo para `*rounded-*`, o `None` si no define ninguna clase, o `""` para el
// redondeo por defecto.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Default => Some(""),
Self::Zero => Some("-0"),
Self::Scale1 => Some("-1"),
Self::Scale2 => Some("-2"),
Self::Scale3 => Some("-3"),
Self::Scale4 => Some("-4"),
Self::Scale5 => Some("-5"),
Self::Circle => Some("-circle"),
Self::Pill => Some("-pill"),
}
}
// Añade el redondeo de esquinas a la cadena de clases usando el prefijo dado (`rounded-top`,
// `rounded-bottom-start`, o vacío para `rounded-*`).
#[inline]
pub(crate) fn push_to(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
if prefix.is_empty() {
classes.push_str("rounded");
} else {
classes.push_str(prefix);
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `rounded-*` para el redondeo de esquinas.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// assert_eq!(token::RoundedRadius::Default.to_class(), "rounded");
/// assert_eq!(token::RoundedRadius::Zero.to_class(), "rounded-0");
/// assert_eq!(token::RoundedRadius::Scale3.to_class(), "rounded-3");
/// assert_eq!(token::RoundedRadius::Circle.to_class(), "rounded-circle");
/// assert_eq!(token::RoundedRadius::None.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class, "");
class
}
}