Source code for recon_gen.common.theme

"""QuickSight theme — registry default + builder.

Per N.1.g, the registry holds ONLY the ``default`` preset (a neutral
blue/grey professional palette). Per-instance brand palettes
(formerly ``sasquatch-bank``, ``sasquatch-bank-investigation``) moved
to inline ``theme:`` blocks on the L2 YAML — apps consume the L2
theme via ``resolve_l2_theme(l2_instance)``.

The ``ThemePreset`` dataclass itself lives in ``common/l2/theme.py``
— theme is an L2 model concept; this module re-exports for back-compat
and provides the ``build_theme(cfg)`` QuickSight Theme constructor.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from recon_gen.common.config import Config
from recon_gen.common.l2.theme import ThemePreset
from recon_gen.common.models import (
    DataColorPalette,
    FontFamily,
    Gutter,
    Margin,
    ResourcePermission,
    SheetStyle,
    Theme,
    ThemeConfiguration,
    Tile,
    TileBorder,
    TileLayout,
    Typography,
    UIColorPalette,
)

__all__ = [
    "DEFAULT_PRESET",
    "ThemePreset",
    "build_theme",
    "resolve_l2_theme",
]


if TYPE_CHECKING:
    from recon_gen.common.l2 import L2Instance


# ---------------------------------------------------------------------------
# Default preset — blues and greys
# ---------------------------------------------------------------------------

# Primary blues (dark → light)
_NAVY = "#1B2A4A"
_DARK_BLUE = "#2E5090"
_MEDIUM_BLUE = "#4A7DC7"
_LIGHT_BLUE = "#7BAAF7"
_PALE_BLUE = "#C5DAF7"

# Greys
_CHARCOAL = "#2D2D2D"
_DARK_GREY = "#4A4A4A"
_MEDIUM_GREY = "#8C8C8C"
_LIGHT_GREY = "#D9D9D9"
_OFF_WHITE = "#F5F6FA"
_WHITE = "#FFFFFF"

# Semantic
_SUCCESS_GREEN = "#2E7D32"
_WARNING_AMBER = "#E65100"
_DANGER_RED = "#C62828"

DEFAULT_PRESET = ThemePreset(
    theme_name="QuickSight Gen Theme",
    version_description="Auto-generated dashboard theme",
    analysis_name_prefix=None,
    data_colors=[
        _DARK_BLUE,
        "#E07B39",       # warm orange contrast
        "#3A9E6F",       # teal green
        _MEDIUM_BLUE,
        "#8E5EA2",       # muted purple
        "#E6B422",       # gold
        "#4BC0C0",       # cyan
        _MEDIUM_GREY,    # neutral fallback
    ],
    empty_fill_color=_LIGHT_GREY,
    gradient=[_PALE_BLUE, _DARK_BLUE],
    primary_bg=_WHITE,
    secondary_bg=_OFF_WHITE,
    primary_fg=_CHARCOAL,
    secondary_fg=_DARK_GREY,
    accent=_DARK_BLUE,
    accent_fg=_WHITE,
    link_tint="#E8EFF9",
    danger=_DANGER_RED,
    danger_fg=_WHITE,
    warning=_WARNING_AMBER,
    warning_fg=_WHITE,
    success=_SUCCESS_GREEN,
    success_fg=_WHITE,
    dimension=_MEDIUM_BLUE,
    dimension_fg=_WHITE,
    measure=_NAVY,
    measure_fg=_WHITE,
)


[docs] def resolve_l2_theme(l2_instance: "L2Instance | None") -> ThemePreset | None: """Pick the theme to render with for an L2-fed app (N.1 / N.4.k). Returns the L2 instance's inline theme block when present (the N.1 path); ``None`` otherwise — the silent-fallback contract (N.4.k). Callers that consume the return for accent colors (e.g., Getting Started rich text) should fall through to ``DEFAULT_PRESET.accent`` so on-canvas colors stay sensible when no L2 theme is declared. The dashboard-level fallback is AWS QuickSight's CLASSIC theme (no Theme resource emitted by ``build_theme`` when ``None``). """ if l2_instance is not None and l2_instance.theme is not None: return l2_instance.theme return None
# --------------------------------------------------------------------------- # Theme builder # ---------------------------------------------------------------------------
[docs] def build_theme(cfg: Config, theme: ThemePreset | None) -> Theme | None: """Build the QuickSight Theme resource for ``theme`` (N.4.k). Returns ``None`` when ``theme`` is ``None`` — the silent-fallback contract: an L2 instance with no inline ``theme:`` block deploys against AWS QuickSight's CLASSIC theme without emitting a custom Theme resource. The CLI skips ``theme.json`` write + skips the deploy step in that case. When ``theme`` is set, builds a complete Theme honoring permissions + tags from ``cfg``. """ if theme is None: return None preset = theme theme_id = cfg.prefixed("theme") permissions = None if cfg.principal_arns: theme_actions = [ "quicksight:DescribeTheme", "quicksight:DescribeThemeAlias", "quicksight:DescribeThemePermissions", "quicksight:ListThemeAliases", "quicksight:ListThemeVersions", "quicksight:UpdateTheme", "quicksight:UpdateThemeAlias", "quicksight:UpdateThemePermissions", "quicksight:DeleteTheme", "quicksight:DeleteThemeAlias", "quicksight:CreateThemeAlias", ] permissions = [ ResourcePermission(Principal=arn, Actions=theme_actions) for arn in cfg.principal_arns ] return Theme( AwsAccountId=cfg.aws_account_id, ThemeId=theme_id, Name=preset.theme_name, BaseThemeId="CLASSIC", Tags=cfg.tags(), Configuration=ThemeConfiguration( DataColorPalette=DataColorPalette( Colors=preset.data_colors, EmptyFillColor=preset.empty_fill_color, MinMaxGradient=preset.gradient, ), UIColorPalette=UIColorPalette( PrimaryBackground=preset.primary_bg, SecondaryBackground=preset.secondary_bg, PrimaryForeground=preset.primary_fg, SecondaryForeground=preset.secondary_fg, Accent=preset.accent, AccentForeground=preset.accent_fg, Danger=preset.danger, DangerForeground=preset.danger_fg, Warning=preset.warning, WarningForeground=preset.warning_fg, Success=preset.success, SuccessForeground=preset.success_fg, Dimension=preset.dimension, DimensionForeground=preset.dimension_fg, Measure=preset.measure, MeasureForeground=preset.measure_fg, ), Sheet=SheetStyle( Tile=Tile( Border=TileBorder(Show=True), ), TileLayout=TileLayout( Gutter=Gutter(Show=True), Margin=Margin(Show=True), ), ), Typography=Typography( FontFamilies=[ FontFamily(FontFamily="Amazon Ember"), FontFamily(FontFamily="sans-serif"), ], ), ), Permissions=permissions, VersionDescription=preset.version_description, )