feat: expand color system to 53 colors/10 palettes, add grid/slide themes, and README gallery
R-CMD-check / R CMD check (push) Successful in 3m41s

Replace the 16-color / 3-palette system with the full Civilytics design
system: 53 named colors (full navy, ember, violet ramps plus teal, plum,
moss, brass supporting hues) and 10 visualization palettes (qual,
qual_warm, qual_cool, seq_ember, seq_navy, seq_violet, seq_paper_ink,
div_navy_ember, div_violet_ember).

Enrich theme_civilytics() with grid and paper_bg parameters, add
theme_civilytics_slide() for presentations, and load all four brand fonts
(Inter, Libre Franklin, Source Serif 4, JetBrains Mono).

Add README.Rmd with rendered gallery showing all palettes, theme
variants, grid options, dark/slide modes, facets, and scale usage.
This commit is contained in:
2026-05-19 10:35:32 -06:00
parent 099fff1d1d
commit 515bae8685
29 changed files with 1209 additions and 273 deletions
+216 -83
View File
@@ -1,106 +1,229 @@
#' Civilytics brand colors
#'
#' A named character vector of all Civilytics brand colors, matching the CSS
#' custom properties defined on www.civilytics.com (`--cv-*` variables).
#' custom properties in the Civilytics design system (`--cv-*` variables).
#' Includes full ramps for navy, ember, and violet, plus supporting hues
#' (teal, plum, moss, brass) and semantic status colors.
#'
#' @format A named character vector of hex color codes.
#' @export
#'
#' @examples
#' civilytics_colors["accent"]
#' civilytics_colors[c("ink", "paper")]
#' civilytics_colors["ink"]
#' civilytics_colors[c("navy_600", "ember_600", "teal_600")]
civilytics_colors <- c(
# Paper (backgrounds) — warm off-white scale
# Neutrals — warm paper -> civic ink
paper = "#FAF7F2",
paper_2 = "#F2EDE4",
paper_3 = "#E6DFD1",
# Rules (borders/dividers)
rule = "#D6CEBD",
rule_strong = "#B8AE97",
# Ink (text / foreground) — dark-to-light navy-grey scale
ink = "#0E1A2B",
ink_2 = "#2B3A52",
ink_3 = "#5A6A82",
ink_4 = "#8C97AB",
# Navy — primary brand blue
navy = "#22406A",
navy_dark = "#1A2E4A",
# Accent — burnt orange
accent = "#C25311",
accent_dark = "#E07840",
accent_50 = "#FDF1E4",
accent_100 = "#FBE0C6",
accent_200 = "#F8C8A3"
# Navy — civic authority (primary brand blue)
navy_900 = "#0E1A2B", navy_800 = "#132339", navy_700 = "#1A2E4A",
navy_600 = "#22406A", navy_500 = "#2E5590", navy_400 = "#4A74B0",
navy_300 = "#7A9BCA", navy_200 = "#B3C6E0", navy_100 = "#DDE6F2",
navy_50 = "#EEF3FA",
# Ember — warm orange accent
ember_900 = "#451A00", ember_800 = "#6B2B00", ember_700 = "#923D00",
ember_600 = "#C25311", ember_500 = "#DB6C25", ember_400 = "#EA8A49",
ember_300 = "#F2A976", ember_200 = "#F8C8A3", ember_100 = "#FBE0C6",
ember_50 = "#FDF1E4",
# Violet — extended supporting
violet_900 = "#19102E", violet_800 = "#2A1B4D", violet_700 = "#3D2A6B",
violet_600 = "#5C3A8A", violet_500 = "#7556A8", violet_400 = "#9A7EC2",
violet_300 = "#BDA8DA", violet_200 = "#DCD0EC", violet_100 = "#F1ECF8",
# Supporting — muted editorial hues for data viz
teal_600 = "#1F6F70", teal_300 = "#7CB3B3", teal_100 = "#D3E6E6",
plum_600 = "#6B3A5E", plum_300 = "#B392A7", plum_100 = "#E7DAE1",
moss_600 = "#4A6B2F", moss_300 = "#9CB47D", moss_100 = "#DEE8CF",
brass_600 = "#B8751C", brass_100 = "#F9E6C8",
# Semantic status colors
success = "#4A6B2F",
warning = "#9A5F18",
danger = "#A6271D",
info = "#2E5590"
)
# Internal named list of visualization palettes.
# Each entry is a character vector of hex codes ordered for visual distinction.
.cv_palettes <- list(
# Qualitative: distinct hues for categorical data (up to 6 categories)
main = unname(civilytics_colors[c(
"navy_dark", "accent", "ink_3", "rule_strong", "navy", "accent_200"
)]),
# Sequential: light-to-dark navy for ordered/continuous data
sequential = unname(civilytics_colors[c(
"paper", "ink_4", "ink_3", "navy", "navy_dark"
)]),
# Diverging: orange <-> neutral <-> navy for data with a meaningful midpoint
diverging = unname(civilytics_colors[c(
"accent", "accent_200", "paper", "ink_4", "navy_dark"
)])
#' Civilytics visualization palettes
#'
#' Named list of curated color palettes for data visualization. Qualitative
#' palettes use distinct hues for categorical data; sequential palettes ramp
#' through a single hue for ordered data; diverging palettes fan out from a
#' neutral midpoint.
#'
#' @format A named list of character vectors of hex color codes.
#'
#' @section Qualitative (categorical data):
#' \describe{
#' \item{`qual`}{7 distinct hues: navy, ember, plum, violet, red, teal, ink-3}
#' \item{`qual_warm`}{Warm-leaning: ember, red, magenta, plum, violet}
#' \item{`qual_cool`}{Cool-leaning: navy, violet, plum, teal}
#' }
#'
#' @section Sequential (ordered/continuous data):
#' \describe{
#' \item{`seq_ember`}{Light to dark ember (9 stops)}
#' \item{`seq_navy`}{Light to dark navy (9 stops)}
#' \item{`seq_violet`}{Light to dark violet (9 stops)}
#' \item{`seq_paper_ink`}{Paper through ember/plum to ink (8 stops, good for heatmaps)}
#' }
#'
#' @section Diverging (data with a meaningful midpoint):
#' \describe{
#' \item{`div_navy_ember`}{Navy <-> paper <-> ember (9 stops)}
#' \item{`div_violet_ember`}{Violet <-> paper <-> ember (9 stops)}
#' }
#'
#' @export
#'
#' @examples
#' names(civilytics_palettes)
#' civilytics_palettes[["qual"]]
civilytics_palettes <- list(
# -- Qualitative --
qual = c(
"#22406A",
"#C25311",
"#6B3A5E",
"#3D2A6B",
"#A6271D",
"#1F6F70",
"#5A6A82"
),
qual_warm = c(
"#C25311",
"#A6271D",
"#923D00",
"#B8366B",
"#6B3A5E",
"#DB6C25",
"#3D2A6B"
),
qual_cool = c(
"#22406A",
"#3D2A6B",
"#6B3A5E",
"#1F6F70",
"#2E5590",
"#7556A8",
"#5A6A82"
),
# -- Sequential --
seq_ember = c(
"#FDF1E4", "#FBE0C6", "#F8C8A3", "#F2A976",
"#EA8A49", "#DB6C25", "#C25311", "#923D00", "#451A00"
),
seq_navy = c(
"#EEF3FA", "#DDE6F2", "#B3C6E0", "#7A9BCA",
"#4A74B0", "#2E5590", "#22406A", "#1A2E4A", "#0E1A2B"
),
seq_violet = c(
"#F1ECF8", "#DCD0EC", "#BDA8DA", "#9A7EC2",
"#7556A8", "#5C3A8A", "#3D2A6B", "#2A1B4D", "#19102E"
),
seq_paper_ink = c(
"#FAF7F2", "#F8C8A3", "#EA8A49", "#C25311",
"#A6271D", "#6B3A5E", "#3D2A6B", "#0E1A2B"
),
# -- Diverging --
div_navy_ember = c(
"#0E1A2B", "#22406A", "#4A74B0", "#B3C6E0",
"#FAF7F2",
"#F8C8A3", "#EA8A49", "#C25311", "#6B2B00"
),
div_violet_ember = c(
"#19102E", "#3D2A6B", "#7556A8", "#BDA8DA",
"#FAF7F2",
"#F8C8A3", "#EA8A49", "#C25311", "#6B2B00"
)
)
# Internal palette generator: returns a function(n) -> character vector.
.cv_pal_fun <- function(name) {
pal <- .cv_palettes[[name]]
if (is.null(pal)) {
stop(sprintf(
"'%s' is not a valid Civilytics palette. Choose from: %s",
name, paste(names(.cv_palettes), collapse = ", ")
), call. = FALSE)
}
function(n) {
if (n > length(pal)) grDevices::colorRampPalette(pal)(n) else pal[seq_len(n)]
}
}
#' Civilytics brand color palette
#' Get colors from a Civilytics palette
#'
#' Returns a character vector of hex codes from a named Civilytics palette.
#' Available palettes: `"main"` (qualitative, up to 6), `"sequential"` (light
#' to dark navy), `"diverging"` (orange–neutral–navy).
#' For qualitative palettes, colors beyond the palette length recycle with a
#' warning. For sequential and diverging palettes, colors are interpolated
#' via [grDevices::colorRampPalette()].
#'
#' @param name Character. Palette name: `"main"`, `"sequential"`, or
#' `"diverging"`. Defaults to `"main"`.
#' @param name Character. Palette name. See `names(civilytics_palettes)`.
#' @param n Integer or `NULL`. Number of colors to return. If `NULL`, returns
#' all colors in the palette. If `n` exceeds the number of defined stops,
#' colors are interpolated via [grDevices::colorRampPalette()].
#' all defined stops.
#' @param reverse Logical. Reverse the palette order. Default `FALSE`.
#'
#' @return A character vector of hex color codes.
#' @export
#'
#' @examples
#' civilytics_pal() # all 6 qualitative colors
#' civilytics_pal("sequential", n = 3)
#' civilytics_pal("diverging", n = 5)
civilytics_pal <- function(name = "main", n = NULL) {
f <- .cv_pal_fun(name)
len <- length(.cv_palettes[[name]])
f(if (is.null(n)) len else n)
#' civilytics_palette() # all 7 qualitative colors
#' civilytics_palette("seq_navy", n = 5) # 5-stop navy ramp
#' civilytics_palette("div_navy_ember", n = 11, reverse = TRUE)
civilytics_palette <- function(name = "qual", n = NULL, reverse = FALSE) {
pal <- civilytics_palettes[[name]]
if (is.null(pal)) {
stop(
"'", name, "' is not a valid palette. Choose from: ",
paste(names(civilytics_palettes), collapse = ", "),
call. = FALSE
)
}
if (reverse) pal <- rev(pal)
if (is.null(n)) return(pal)
if (startsWith(name, "qual")) {
if (n > length(pal)) {
warning(
"Requested ", n, " colors from '", name, "' palette (max ",
length(pal), "); recycling.",
call. = FALSE
)
pal <- rep_len(pal, n)
}
return(pal[seq_len(n)])
}
grDevices::colorRampPalette(pal)(n)
}
#' Civilytics palette function (closure)
#'
#' Returns a closure `function(n)` suitable for passing to
#' [ggplot2::discrete_scale()] or similar scale constructors.
#'
#' @param name Character. Palette name. See `names(civilytics_palettes)`.
#' @param reverse Logical. Reverse the palette order. Default `FALSE`.
#'
#' @return A function that takes integer `n` and returns `n` hex color codes.
#' @export
#'
#' @examples
#' pal_fn <- civilytics_pal("qual")
#' pal_fn(4)
civilytics_pal <- function(name = "qual", reverse = FALSE) {
function(n) civilytics_palette(name, n = n, reverse = reverse)
}
#' Civilytics color scale for ggplot2
#'
#' Applies a Civilytics brand palette to the `colour` aesthetic. Use
#' `discrete = TRUE` for categorical variables and `discrete = FALSE` for
#' continuous gradients.
#' Applies a Civilytics brand palette to the `colour` aesthetic.
#'
#' @param palette Character. Palette name passed to [civilytics_pal()].
#' Defaults to `"main"`.
#' @param discrete Logical. `TRUE` (default) for a discrete scale; `FALSE` for
#' a continuous gradient via [ggplot2::scale_color_gradientn()].
#' @param ... Additional arguments passed to the underlying ggplot2 scale
#' function.
#' @param palette Character. Palette name. Defaults to `"qual"`.
#' @param discrete Logical. `TRUE` (default) for categorical data; `FALSE`
#' for a continuous gradient via [ggplot2::scale_color_gradientn()].
#' @param reverse Logical. Reverse the palette order. Default `FALSE`.
#' @param ... Additional arguments passed to the ggplot2 scale function.
#'
#' @return A ggplot2 scale object.
#' @export
@@ -113,30 +236,34 @@ civilytics_pal <- function(name = "main", n = NULL) {
#'
#' ggplot(mpg, aes(displ, hwy, colour = cty)) +
#' geom_point() +
#' scale_color_civilytics("sequential", discrete = FALSE)
scale_color_civilytics <- function(palette = "main", discrete = TRUE, ...) {
#' scale_color_civilytics("seq_navy", discrete = FALSE)
scale_color_civilytics <- function(palette = "qual", discrete = TRUE,
reverse = FALSE, ...) {
if (discrete) {
ggplot2::discrete_scale("colour", palette = .cv_pal_fun(palette), ...)
ggplot2::discrete_scale(
"colour",
palette = civilytics_pal(palette, reverse = reverse),
...
)
} else {
pal <- civilytics_palette(palette, reverse = reverse)
ggplot2::scale_color_gradientn(
colours = grDevices::colorRampPalette(.cv_palettes[[palette]])(256),
colours = grDevices::colorRampPalette(pal)(256),
...
)
}
}
#' Civilytics fill scale for ggplot2
#'
#' Applies a Civilytics brand palette to the `fill` aesthetic. Use
#' `discrete = TRUE` for categorical variables and `discrete = FALSE` for
#' continuous gradients.
#' Applies a Civilytics brand palette to the `fill` aesthetic.
#'
#' @param palette Character. Palette name passed to [civilytics_pal()].
#' Defaults to `"main"`.
#' @param discrete Logical. `TRUE` (default) for a discrete scale; `FALSE` for
#' a continuous gradient via [ggplot2::scale_fill_gradientn()].
#' @param ... Additional arguments passed to the underlying ggplot2 scale
#' function.
#' @param palette Character. Palette name. Defaults to `"qual"`.
#' @param discrete Logical. `TRUE` (default) for categorical data; `FALSE`
#' for a continuous gradient via [ggplot2::scale_fill_gradientn()].
#' @param reverse Logical. Reverse the palette order. Default `FALSE`.
#' @param ... Additional arguments passed to the ggplot2 scale function.
#'
#' @return A ggplot2 scale object.
#' @export
@@ -149,13 +276,19 @@ scale_color_civilytics <- function(palette = "main", discrete = TRUE, ...) {
#'
#' ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
#' geom_tile() +
#' scale_fill_civilytics("sequential", discrete = FALSE)
scale_fill_civilytics <- function(palette = "main", discrete = TRUE, ...) {
#' scale_fill_civilytics("seq_ember", discrete = FALSE)
scale_fill_civilytics <- function(palette = "qual", discrete = TRUE,
reverse = FALSE, ...) {
if (discrete) {
ggplot2::discrete_scale("fill", palette = .cv_pal_fun(palette), ...)
ggplot2::discrete_scale(
"fill",
palette = civilytics_pal(palette, reverse = reverse),
...
)
} else {
pal <- civilytics_palette(palette, reverse = reverse)
ggplot2::scale_fill_gradientn(
colours = grDevices::colorRampPalette(.cv_palettes[[palette]])(256),
colours = grDevices::colorRampPalette(pal)(256),
...
)
}