Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ follow-ups carried over from the v1.6.7 senior review (see
[ROADMAP.md](ROADMAP.md) and the private taskboard). No breaking
changes are planned.

### Public API

- Four new `BusinessTheme` factory presets `@since 1.6.8`:
`BusinessTheme.nordic()` (Scandinavian minimal — cool whites +
slate-blue accent + generous whitespace, for design-studio
reports and product launch decks),
`BusinessTheme.editorial()` (warm cream surface + deep ink +
brick-red accent on a serif body, for long-form proposals and
annual reports),
`BusinessTheme.cinematic()` (inverted dark navy surface with
light text + bright copper accent, for investor pitch decks and
product launch one-pagers), and
`BusinessTheme.monochrome()` (pure black-on-white with a single
bold yellow accent, for brutalist editorial layouts where
typographic contrast carries the identity). Pure additions —
no change to the existing `classic()` / `modern()` /
`executive()` presets. japicmp gate against v1.6.7 reports
`semver PATCH` (compatible additions only).

### Build

- Bumped `jackson-bom` 2.21.3 → 2.21.4 (broken 2.22.0 skipped via
Expand Down
114 changes: 112 additions & 2 deletions src/main/java/com/demcha/compose/document/theme/BusinessTheme.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@
* an invoice, a proposal, and a status report rendered through the same theme
* look like a single product instead of three independently styled documents.</p>
*
* <p>Use one of the built-in presets for an immediate style:
* {@link #classic()}, {@link #modern()}, {@link #executive()}.</p>
* <p>Use one of the built-in presets for an immediate style. The
* original three are tuned for formal documents:
* {@link #classic()}, {@link #modern()}, {@link #executive()}.
* Four contemporary additions cover the modern document-design
* spectrum: {@link #nordic()} (Scandinavian minimal),
* {@link #editorial()} (warm magazine), {@link #cinematic()}
* (dark moody surface), and {@link #monochrome()} (brutalist
* black-and-white with one accent).</p>
*
* @param name human-readable theme identifier (used for diagnostics)
* @param palette color tokens
Expand Down Expand Up @@ -119,6 +125,110 @@ public static BusinessTheme executive() {
return new BusinessTheme("executive", palette, spacing, text, table, null);
}

/**
* "Nordic" theme — cool near-white surface, deep slate-blue
* primary, dusty slate accent, generous spacing. Tuned for
* design studios, product reports, and clean startup decks
* where whitespace is the dominant visual element.
*
* @return nordic theme
* @since 1.6.8
*/
public static BusinessTheme nordic() {
DocumentPalette palette = DocumentPalette.builder()
.primary(new Color(36, 50, 64)) // deep slate-blue
.accent(new Color(96, 118, 142)) // dusty slate
.surface(new Color(252, 253, 254)) // cool near-white
.surfaceMuted(new Color(240, 243, 246))
.textPrimary(new Color(36, 50, 64))
.textMuted(new Color(108, 120, 134))
.rule(new Color(220, 226, 232)) // very subtle cool line
.build();
SpacingScale spacing = new SpacingScale(6.0, 12.0, 18.0, 28.0, 44.0);
TextScale text = textScale(palette, FontName.HELVETICA, FontName.HELVETICA_BOLD,
26, 16, 12, 10, 9);
TablePreset table = tablePreset(palette, spacing);
return new BusinessTheme("nordic", palette, spacing, text, table, null);
}

/**
* "Editorial" theme — warm cream surface, deep ink primary,
* brick-red accent on a serif body. Tuned for long-form
* proposals, annual reports, and brand decks that want a
* magazine feel.
*
* @return editorial theme
* @since 1.6.8
*/
public static BusinessTheme editorial() {
DocumentPalette palette = DocumentPalette.builder()
.primary(new Color(22, 22, 22)) // deep ink
.accent(new Color(160, 60, 50)) // brick red
.surface(new Color(250, 245, 235)) // warm cream
.surfaceMuted(new Color(240, 232, 218))
.textPrimary(new Color(22, 22, 22))
.textMuted(new Color(95, 90, 85)) // warm grey
.rule(new Color(200, 190, 175))
.build();
SpacingScale spacing = SpacingScale.defaultScale();
TextScale text = textScale(palette, FontName.TIMES_ROMAN, FontName.TIMES_ROMAN,
30, 18, 14, 11, 9);
TablePreset table = tablePreset(palette, spacing);
return new BusinessTheme("editorial", palette, spacing, text, table, palette.surface());
}

/**
* "Cinematic" theme — deep navy surface with light text and a
* bright copper accent. Inverts the usual dark-on-light document
* convention; tuned for investor pitch decks, product launch
* one-pagers, and presentations that need a moody premium feel.
*
* @return cinematic theme
* @since 1.6.8
*/
public static BusinessTheme cinematic() {
DocumentPalette palette = DocumentPalette.builder()
.primary(new Color(245, 248, 252)) // near-white (text on dark)
.accent(new Color(220, 130, 50)) // bright copper
.surface(new Color(16, 24, 36)) // deep navy SURFACE
.surfaceMuted(new Color(28, 36, 48)) // slightly lighter navy panel
.textPrimary(new Color(245, 248, 252))
.textMuted(new Color(160, 170, 188)) // muted light blue-grey
.rule(new Color(54, 64, 78)) // subtle on-dark rule
.build();
SpacingScale spacing = new SpacingScale(4.0, 8.0, 14.0, 24.0, 40.0);
TextScale text = textScale(palette, FontName.HELVETICA, FontName.HELVETICA_BOLD,
30, 18, 14, 11, 9);
TablePreset table = tablePreset(palette, spacing);
return new BusinessTheme("cinematic", palette, spacing, text, table, palette.surface());
}

/**
* "Monochrome" theme — pure black on white with a single bold
* yellow accent. Tuned for design-studio one-pagers, fashion-
* magazine-style covers, and brutalist editorial layouts where
* typographic contrast is the entire identity.
*
* @return monochrome theme
* @since 1.6.8
*/
public static BusinessTheme monochrome() {
DocumentPalette palette = DocumentPalette.builder()
.primary(new Color(0, 0, 0)) // pure black
.accent(new Color(240, 196, 25)) // bold yellow
.surface(new Color(255, 255, 255)) // pure white
.surfaceMuted(new Color(244, 244, 244))
.textPrimary(new Color(0, 0, 0))
.textMuted(new Color(115, 115, 115)) // medium grey
.rule(new Color(0, 0, 0)) // bold rules
.build();
SpacingScale spacing = new SpacingScale(4.0, 8.0, 12.0, 20.0, 36.0);
TextScale text = textScale(palette, FontName.HELVETICA, FontName.HELVETICA_BOLD,
32, 20, 14, 11, 9);
TablePreset table = tablePreset(palette, spacing);
return new BusinessTheme("monochrome", palette, spacing, text, table, null);
}

/**
* Returns a copy of this theme with the page background overridden.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,87 @@ void allThreeBuiltInThemesAreDistinct() {
assertThat(modern.palette()).isNotEqualTo(executive.palette());
assertThat(classic.text().h1()).isNotEqualTo(modern.text().h1());
}

// --- Contemporary themes added in v1.6.8 -------------------------------

@Test
void nordicThemeHasAllTokensNonNullAndUsesGenerousSpacing() {
BusinessTheme theme = BusinessTheme.nordic();
assertThat(theme.name()).isEqualTo("nordic");
assertThat(theme.palette()).isNotNull();
assertThat(theme.spacing()).isNotNull();
assertThat(theme.text()).isNotNull();
assertThat(theme.table()).isNotNull();
assertThat(theme.pageBackground()).isNull();
// Nordic is tuned for whitespace — every step is at least as wide
// as the default scale, and md is strictly wider.
SpacingScale standard = SpacingScale.defaultScale();
assertThat(theme.spacing().md()).isGreaterThan(standard.md());
assertThat(theme.spacing().xl()).isGreaterThan(standard.xl());
}

@Test
void editorialThemeUsesTimesRomanBodyAndCreamPageBackground() {
BusinessTheme theme = BusinessTheme.editorial();
assertThat(theme.name()).isEqualTo("editorial");
assertThat(theme.text().body().fontName().name()).isEqualTo("Times-Roman");
assertThat(theme.text().h1().fontName().name()).isEqualTo("Times-Roman");
// Cream page background distinguishes editorial from the
// strictly-white classic theme.
assertThat(theme.pageBackground()).isNotNull();
assertThat(theme.pageBackground()).isEqualTo(theme.palette().surface());
}

@Test
void cinematicThemeInvertsPaletteToLightTextOnDarkSurface() {
BusinessTheme theme = BusinessTheme.cinematic();
assertThat(theme.name()).isEqualTo("cinematic");
// The defining trait: surface is dark, primary/text is light.
Color surface = theme.palette().surface().color();
Color textPrimary = theme.palette().textPrimary().color();
int surfaceLuminance = (surface.getRed() + surface.getGreen() + surface.getBlue()) / 3;
int textLuminance = (textPrimary.getRed() + textPrimary.getGreen() + textPrimary.getBlue()) / 3;
assertThat(surfaceLuminance).isLessThan(64); // genuinely dark surface
assertThat(textLuminance).isGreaterThan(192); // genuinely light text
// And the surface doubles as the page background so the moody
// look fills the page edges too.
assertThat(theme.pageBackground()).isEqualTo(theme.palette().surface());
}

@Test
void monochromeThemeIsPureBlackOnWhiteWithBoldYellowAccent() {
BusinessTheme theme = BusinessTheme.monochrome();
assertThat(theme.name()).isEqualTo("monochrome");
assertThat(theme.palette().primary().color()).isEqualTo(Color.BLACK);
assertThat(theme.palette().surface().color()).isEqualTo(Color.WHITE);
// The single accent is the entire identity of the theme — assert
// it leans yellow (R and G high, B low) rather than pinning the
// exact RGB, so a future shade tweak does not break the test.
Color accent = theme.palette().accent().color();
assertThat(accent.getRed()).isGreaterThan(200);
assertThat(accent.getGreen()).isGreaterThan(150);
assertThat(accent.getBlue()).isLessThan(80);
}

@Test
void allSevenBuiltInThemesArePairwiseDistinctByPalette() {
BusinessTheme[] all = {
BusinessTheme.classic(),
BusinessTheme.modern(),
BusinessTheme.executive(),
BusinessTheme.nordic(),
BusinessTheme.editorial(),
BusinessTheme.cinematic(),
BusinessTheme.monochrome()
};
for (int i = 0; i < all.length; i++) {
for (int j = i + 1; j < all.length; j++) {
assertThat(all[i].palette())
.as("Themes '%s' and '%s' must have distinct palettes",
all[i].name(), all[j].name())
.isNotEqualTo(all[j].palette());
assertThat(all[i].name()).isNotEqualTo(all[j].name());
}
}
}
}