dataface 0.1.6.dev76__py3-none-any.whl → 0.1.6.dev82__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@ from dataface.core.registered_views.data_urls import (
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from dataface.agent_api.schema import SchemaResponse
28
+ from dataface.core.project import Project
28
29
  from dataface.core.serve.alias_index import AliasIndex
29
30
 
30
31
 
@@ -186,22 +187,15 @@ def data_paths_list(
186
187
  return result
187
188
 
188
189
 
189
- def build_alias_index_for_project(project_dir: Path) -> AliasIndex:
190
- """Build an AliasIndex from a project directory.
190
+ def build_alias_index_for_project(project: Project) -> AliasIndex:
191
+ """Build an AliasIndex from a project.
191
192
 
192
- Uses the same faces_at_root detection logic as the server. Intended for
193
- CLI commands (dft schema --data-paths) that need alias lookup without
194
- starting a server.
193
+ Intended for CLI commands (dft schema --data-paths) that need alias lookup
194
+ without starting a server.
195
195
  """
196
196
  from dataface.core.serve.alias_index import AliasIndex
197
197
 
198
- faces_dir = project_dir / "faces"
199
- faces_at_root = faces_dir.is_dir()
200
- return AliasIndex.build(
201
- project_dir=project_dir,
202
- faces_dir=faces_dir,
203
- faces_at_root=faces_at_root,
204
- )
198
+ return AliasIndex.build(project, faces_at_root=project.faces_dir.is_dir())
205
199
 
206
200
 
207
201
  def data_alias_errors_for_file(
@@ -1081,7 +1081,7 @@ Authored overlay for TitleStyle. Board and face titles.
1081
1081
  | Field | Type | Optional | Description |
1082
1082
  |-------|------|:--------:|-------------|
1083
1083
  | `font` | [FontStyle](#fontstyle) | ✓ | Title font style overrides. |
1084
- | `line_height` | float | ✓ | Line height multiplier for titles and markdown headings. Headings typically want a tighter multiplier than body prose (~1.1-1.25 vs the body 1.5-1.6). |
1084
+ | `line_height` | float | ✓ | Line height multiplier for titles and markdown headings. Headings typically want a tighter multiplier than body prose. |
1085
1085
  | `sizes` | list[float] | ✓ | Font sizes for the H1–H6 heading ramp, indexed by ``face.level - 1``. Combined with ``width_offsets`` at render time to size titles responsively by card width. |
1086
1086
  | `width_offsets` | [TitleWidthOffsetsStyle](#titlewidthoffsetsstyle) | ✓ | Additive level offsets by card width (tiny/narrow/medium/wide). Added to the title's base level before indexing ``sizes``. Consumed by chart_title_spec / face_title_spec. |
1087
1087
  | `min_height` | float | ✓ | Minimum title row height in pixels. |
@@ -16,6 +16,8 @@ from pathlib import Path
16
16
 
17
17
  from pydantic import BaseModel, Field
18
18
 
19
+ from dataface.core.project import Project
20
+
19
21
  # Cap grep/glob payloads — read_file returns full content (model must handle size).
20
22
  _MAX_GREP_MATCHES = 200
21
23
  _MAX_GLOB_MATCHES = 500
@@ -146,9 +148,9 @@ def _walk_project(root: Path) -> list[Path]:
146
148
  return result
147
149
 
148
150
 
149
- def read_file(path: str, project_dir: Path) -> ReadFileResult:
151
+ def read_file(path: str, project: Project) -> ReadFileResult:
150
152
  try:
151
- target = _resolve_within(project_dir, path)
153
+ target = _resolve_within(project.root, path)
152
154
  except ValueError as exc:
153
155
  return ReadFileResult(success=False, path=path, error=str(exc))
154
156
  if not target.is_file():
@@ -157,12 +159,14 @@ def read_file(path: str, project_dir: Path) -> ReadFileResult:
157
159
  content = target.read_text(encoding="utf-8")
158
160
  except (OSError, UnicodeDecodeError) as exc:
159
161
  return ReadFileResult(success=False, path=path, error=str(exc))
160
- return ReadFileResult(success=True, path=_rel(project_dir, target), content=content)
162
+ return ReadFileResult(
163
+ success=True, path=_rel(project.root, target), content=content
164
+ )
161
165
 
162
166
 
163
- def write_file(path: str, content: str, project_dir: Path) -> WriteFileResult:
167
+ def write_file(path: str, content: str, project: Project) -> WriteFileResult:
164
168
  try:
165
- target = _resolve_within(project_dir, path)
169
+ target = _resolve_within(project.root, path)
166
170
  except ValueError as exc:
167
171
  return WriteFileResult(success=False, path=path, error=str(exc))
168
172
  try:
@@ -172,16 +176,16 @@ def write_file(path: str, content: str, project_dir: Path) -> WriteFileResult:
172
176
  return WriteFileResult(success=False, path=path, error=str(exc))
173
177
  return WriteFileResult(
174
178
  success=True,
175
- path=_rel(project_dir, target),
179
+ path=_rel(project.root, target),
176
180
  bytes_written=len(content.encode("utf-8")),
177
181
  )
178
182
 
179
183
 
180
184
  def edit_file(
181
- path: str, old_string: str, new_string: str, project_dir: Path
185
+ path: str, old_string: str, new_string: str, project: Project
182
186
  ) -> EditFileResult:
183
187
  try:
184
- target = _resolve_within(project_dir, path)
188
+ target = _resolve_within(project.root, path)
185
189
  except ValueError as exc:
186
190
  return EditFileResult(success=False, path=path, error=str(exc))
187
191
  if not target.is_file():
@@ -205,11 +209,11 @@ def edit_file(
205
209
  target.write_text(original.replace(old_string, new_string), encoding="utf-8")
206
210
  except OSError as exc:
207
211
  return EditFileResult(success=False, path=path, error=str(exc))
208
- return EditFileResult(success=True, path=_rel(project_dir, target), replacements=1)
212
+ return EditFileResult(success=True, path=_rel(project.root, target), replacements=1)
209
213
 
210
214
 
211
- def glob_files(pattern: str, project_dir: Path) -> GlobResult:
212
- root = project_dir.resolve()
215
+ def glob_files(pattern: str, project: Project) -> GlobResult:
216
+ root = project.root.resolve()
213
217
  matches: list[str] = []
214
218
  try:
215
219
  candidates = root.glob(pattern)
@@ -228,8 +232,8 @@ def glob_files(pattern: str, project_dir: Path) -> GlobResult:
228
232
  return GlobResult(success=True, matches=matches)
229
233
 
230
234
 
231
- def grep_files(pattern: str, project_dir: Path, glob: str | None = None) -> GrepResult:
232
- root = project_dir.resolve()
235
+ def grep_files(pattern: str, project: Project, glob: str | None = None) -> GrepResult:
236
+ root = project.root.resolve()
233
237
  matches: list[GrepMatch] = []
234
238
  try:
235
239
  candidates: list[Path] = (
@@ -446,8 +446,8 @@ def apply_proposal(
446
446
  )
447
447
 
448
448
  # Ensure base dirs exist
449
- (project.root / "faces").mkdir(exist_ok=True)
450
- (project.root / "faces" / "partials").mkdir(exist_ok=True)
449
+ project.faces_dir.mkdir(exist_ok=True)
450
+ (project.faces_dir / "partials").mkdir(exist_ok=True)
451
451
 
452
452
  # Write partials — skipped by validate_paths (_*.yml convention)
453
453
  for partial_filename, partial_path, rel in partial_targets:
@@ -253,6 +253,10 @@ class ProjectSession:
253
253
  def warnings_ignore(self) -> frozenset[str]:
254
254
  return self.project.warnings_ignore
255
255
 
256
+ @property
257
+ def faces_dir(self) -> Path:
258
+ return self.project.faces_dir
259
+
256
260
  @cached_property
257
261
  def _relationship_context(self) -> RelationshipContext | None:
258
262
  """Load relationship context from the super-schema cache (lazy, per-instance).
@@ -90,7 +90,7 @@ def _validate_one_path(
90
90
  # detectors. Keep this lazy so `dft --help` doesn't pay that startup cost.
91
91
  from dataface.core.inspect.manifest_utils import INSPECT_TEMPLATE_MANIFEST
92
92
 
93
- raw_path = path if path is not None else project.root / "faces"
93
+ raw_path = path if path is not None else project.faces_dir
94
94
 
95
95
  try:
96
96
  resolved = resolve_scoped_path(raw_path, project.root)
dataface/ai/context.py CHANGED
@@ -31,7 +31,7 @@ class DatafaceAIContext:
31
31
  if path.is_absolute():
32
32
  raise ValueError(f"Dashboard path must be relative: {path}")
33
33
  if self.dashboards_directory is None:
34
- base_dir = (self.project_session.project.root / "faces").resolve()
34
+ base_dir = self.project_session.faces_dir.resolve()
35
35
  else:
36
36
  base_dir = self.dashboards_directory.resolve()
37
37
  resolved = (base_dir / path).resolve()
@@ -293,14 +293,14 @@ def _handle_get_warning_code(
293
293
  def _handle_read_file(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str, Any]:
294
294
  parsed = _files.ReadFileArgs.model_validate(args)
295
295
  return _files.read_file(
296
- parsed.path, project_dir=_render_project_dir(args, ctx)
296
+ parsed.path, project=Project(_render_project_dir(args, ctx))
297
297
  ).model_dump(mode="json", exclude_none=True)
298
298
 
299
299
 
300
300
  def _handle_write_file(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str, Any]:
301
301
  parsed = _files.WriteFileArgs.model_validate(args)
302
302
  return _files.write_file(
303
- parsed.path, parsed.content, project_dir=_render_project_dir(args, ctx)
303
+ parsed.path, parsed.content, project=Project(_render_project_dir(args, ctx))
304
304
  ).model_dump(mode="json", exclude_none=True)
305
305
 
306
306
 
@@ -310,14 +310,14 @@ def _handle_edit_file(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str,
310
310
  parsed.path,
311
311
  parsed.old_string,
312
312
  parsed.new_string,
313
- project_dir=_render_project_dir(args, ctx),
313
+ project=Project(_render_project_dir(args, ctx)),
314
314
  ).model_dump(mode="json", exclude_none=True)
315
315
 
316
316
 
317
317
  def _handle_glob_files(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str, Any]:
318
318
  parsed = _files.GlobFilesArgs.model_validate(args)
319
319
  return _files.glob_files(
320
- parsed.pattern, project_dir=_render_project_dir(args, ctx)
320
+ parsed.pattern, project=Project(_render_project_dir(args, ctx))
321
321
  ).model_dump(mode="json", exclude_none=True)
322
322
 
323
323
 
@@ -325,7 +325,7 @@ def _handle_grep_files(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str
325
325
  parsed = _files.GrepFilesArgs.model_validate(args)
326
326
  return _files.grep_files(
327
327
  parsed.pattern,
328
- project_dir=_render_project_dir(args, ctx),
328
+ project=Project(_render_project_dir(args, ctx)),
329
329
  glob=parsed.glob,
330
330
  ).model_dump(mode="json", exclude_none=True)
331
331
 
@@ -193,32 +193,31 @@ def schema_command(
193
193
  lineage_depth=lineage_depth,
194
194
  surface="cli",
195
195
  )
196
-
197
- if data_paths:
198
- if not drill_result.success:
199
- print_structured_errors(
200
- drill_result.structured_errors
201
- or [
202
- StructuredError(
203
- code=DF_UNKNOWN_INTERNAL.code,
204
- domain=DF_UNKNOWN_INTERNAL.domain,
205
- doc_url=DF_UNKNOWN_INTERNAL.doc_url,
206
- docs_topic=DF_UNKNOWN_INTERNAL.docs_topic,
207
- message="; ".join(drill_result.errors),
208
- )
209
- ]
196
+ if data_paths:
197
+ if not drill_result.success:
198
+ print_structured_errors(
199
+ drill_result.structured_errors
200
+ or [
201
+ StructuredError(
202
+ code=DF_UNKNOWN_INTERNAL.code,
203
+ domain=DF_UNKNOWN_INTERNAL.domain,
204
+ doc_url=DF_UNKNOWN_INTERNAL.doc_url,
205
+ docs_topic=DF_UNKNOWN_INTERNAL.docs_topic,
206
+ message="; ".join(drill_result.errors),
207
+ )
208
+ ]
209
+ )
210
+ raise typer.Exit(1)
211
+ from dataface.agent_api.data_paths import (
212
+ build_alias_index_for_project,
213
+ data_paths_list,
210
214
  )
211
- raise typer.Exit(1)
212
- from dataface.agent_api.data_paths import (
213
- build_alias_index_for_project,
214
- data_paths_list,
215
- )
216
215
 
217
- alias_index = build_alias_index_for_project(project_root)
218
- ep_list = data_paths_list(drill_result, alias_index=alias_index)
219
- wire = [p.to_dict() for p in ep_list]
220
- typer.echo(json.dumps(wire, indent=2))
221
- return
216
+ alias_index = build_alias_index_for_project(project_session.project)
217
+ ep_list = data_paths_list(drill_result, alias_index=alias_index)
218
+ wire = [p.to_dict() for p in ep_list]
219
+ typer.echo(json.dumps(wire, indent=2))
220
+ return
222
221
 
223
222
  if json_output:
224
223
  typer.echo(
@@ -332,8 +332,7 @@ class TitleStyle(BaseModel):
332
332
  line_height: float = Field(
333
333
  description=(
334
334
  "Line height multiplier for titles and markdown headings. Headings "
335
- "typically want a tighter multiplier than body prose (~1.1-1.25 vs "
336
- "the body 1.5-1.6)."
335
+ "typically want a tighter multiplier than body prose."
337
336
  )
338
337
  )
339
338
  sizes: list[float] = Field(
@@ -77,6 +77,8 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol
77
77
 
78
78
  from PIL import ImageFont
79
79
 
80
+ from dataface.core.compile.typography import face_is_prose
81
+
80
82
  if TYPE_CHECKING:
81
83
  from dataface.core.compile.models.face.resolved import (
82
84
  ResolvedLayoutItem,
@@ -391,14 +393,15 @@ def compact_style_kwargs(
391
393
  color comes from ``style.title.font.color``.
392
394
 
393
395
  All prose colors derive from resolved theme tokens — no hardcoded table.
396
+ Paragraph spacing is derived from the body rhythm:
397
+ ``font_size * line_height * 0.5``.
394
398
 
395
399
  Args:
396
400
  resolved_style: Optional ResolvedStyle for color resolution. Falls back to
397
401
  the global config default when None.
398
402
  heading_font_weight: Override the heading font weight. Face titles pass
399
403
  the tier-specific weight (600 narrow, 500 medium/wide).
400
- font_family: Override the body font family. Pass
401
- ``config.style.title.font.family`` to render prose in serif.
404
+ font_family: Override the body font family used by mdsvg.
402
405
  h1_size: Override the h1 pixel size. Face titles pass the
403
406
  width-resolved pixel size from ``face_title_spec`` so the rendered
404
407
  h1 lands exactly on the responsive target. When ``None``, h1 uses
@@ -419,9 +422,9 @@ def compact_style_kwargs(
419
422
  _text_font_weight = _rs.text.font.weight
420
423
  assert _text_font_weight is not None, "style.text.font.weight must be configured"
421
424
 
422
- _root_family = config.style.font.family
423
- assert _root_family is not None, "style.font.family must be configured"
424
- _default_family = apply_emoji_to_family(_root_family, config.style.font.emoji)
425
+ _text_font_family = _rs.text.font.family
426
+ assert _text_font_family is not None, "style.text.font.family must be configured"
427
+ _default_family = apply_emoji_to_family(_text_font_family, _rs.emoji_mode)
425
428
 
426
429
  title_sizes = _rs.title.sizes
427
430
  if len(title_sizes) != 6:
@@ -454,7 +457,7 @@ def compact_style_kwargs(
454
457
  "line_height": float(_rs.text.line_height),
455
458
  "heading_margin_top": 0.3,
456
459
  "heading_margin_bottom": 0.2,
457
- "paragraph_spacing": 8.0,
460
+ "paragraph_spacing": float(_text_font_size) * float(_rs.text.line_height) * 0.5,
458
461
  "text_color": _rs.font.color,
459
462
  "heading_color": _rs.title.font.color,
460
463
  "link_color": _rs.accent,
@@ -511,6 +514,13 @@ def compact_style_kwargs(
511
514
  return kwargs
512
515
 
513
516
 
517
+ def body_text_font_family(resolved_style: ResolvedStyle) -> str:
518
+ """Return the resolved body markdown family."""
519
+ family = resolved_style.text.font.family
520
+ assert family is not None, "style.text.font.family must be configured"
521
+ return family
522
+
523
+
514
524
  def greedy_column_fill(
515
525
  block_heights: list[float],
516
526
  n_cols: int,
@@ -631,15 +641,18 @@ def get_markdown_text_height(
631
641
  return 0.0
632
642
 
633
643
  from dataface.core.compile.jinja import resolve_jinja_template
634
- from dataface.core.fonts import get_inter_font_path, get_mono_font_path
644
+ from dataface.core.fonts import get_font_path, get_mono_font_path
635
645
  from mdsvg import measure as measure_markdown
636
646
 
637
647
  resolved_content = resolve_jinja_template(
638
648
  text, variable_defaults or {}, strict=False
639
649
  )
640
650
 
641
- font_path = str(get_inter_font_path())
642
- style = get_compact_style(resolved_style)
651
+ _rs = resolved_style or resolve_style(get_config().style)
652
+ font_family = body_text_font_family(_rs)
653
+
654
+ font_path = get_font_path(font_family)
655
+ style = get_compact_style(_rs)
643
656
 
644
657
  if text_style is not None and text_style.column.has_overrides:
645
658
  return columned_text_height_estimate(
@@ -667,6 +680,7 @@ def get_title_height(
667
680
  level: int = 1,
668
681
  *,
669
682
  resolved_style: ResolvedStyle | None = None,
683
+ prose: bool = False,
670
684
  ) -> float:
671
685
  """Get the actual height needed for a title by measuring it.
672
686
 
@@ -679,6 +693,7 @@ def get_title_height(
679
693
  width: Available width for the title (drives tier selection)
680
694
  variable_defaults: Optional variable defaults for Jinja resolution
681
695
  resolved_style: Optional resolved face style for title typography measurement
696
+ prose: When True, measure with the theme's prose-title family.
682
697
 
683
698
  Returns:
684
699
  Actual height in pixels for the rendered title
@@ -687,7 +702,7 @@ def get_title_height(
687
702
  return 0.0
688
703
 
689
704
  from dataface.core.compile.jinja import resolve_jinja_template
690
- from dataface.core.fonts import get_inter_font_path, get_mono_font_path
705
+ from dataface.core.fonts import get_font_path, get_mono_font_path
691
706
  from mdsvg import measure as measure_markdown
692
707
 
693
708
  # Resolve any Jinja templates using variable defaults
@@ -704,15 +719,23 @@ def get_title_height(
704
719
  # Measure the markdown to get actual dimensions (using compact style).
705
720
  # text_align is intentionally omitted: mdsvg measure() only computes
706
721
  # line-wrapped height, which is independent of horizontal alignment.
707
- font_path = str(get_inter_font_path())
722
+ _rs = resolved_style or resolve_style(get_config().style)
723
+ if prose:
724
+ font_family = _rs.title.font.family
725
+ assert font_family is not None, "style.title.font.family must be configured"
726
+ else:
727
+ font_family = body_text_font_family(_rs)
728
+
729
+ font_path = get_font_path(font_family)
708
730
  size = measure_markdown(
709
731
  markdown_title,
710
732
  width=width,
711
733
  padding=0.0,
712
734
  style=get_compact_style(
713
- resolved_style,
735
+ _rs,
714
736
  h1_size=h1_size,
715
737
  heading_font_weight=heading_weight,
738
+ font_family=font_family if prose else None,
716
739
  ),
717
740
  font_path=font_path,
718
741
  mono_font_path=str(get_mono_font_path()),
@@ -1008,12 +1031,14 @@ def compute_title_variables_inline_band_height(
1008
1031
  )
1009
1032
  if not face.title:
1010
1033
  return 0.0
1034
+ prose = face_is_prose(face.text)
1011
1035
  title_h = max(
1012
1036
  get_title_height(
1013
1037
  face.title,
1014
1038
  title_w,
1015
1039
  variable_defaults,
1016
1040
  resolved_style=face.resolved_style,
1041
+ prose=prose,
1017
1042
  ),
1018
1043
  float(face.resolved_style.title.min_height),
1019
1044
  )
@@ -1134,11 +1159,13 @@ def calculate_layout(face: Face) -> Face:
1134
1159
  elif face.title:
1135
1160
  card_pad = float(config.style.board.card_padding)
1136
1161
  title_measure_width = max(content_width - 2 * card_pad, 0.0)
1162
+ prose = face_is_prose(face.text)
1137
1163
  title_height = get_title_height(
1138
1164
  face.title,
1139
1165
  title_measure_width,
1140
1166
  variable_defaults,
1141
1167
  resolved_style=face.resolved_style,
1168
+ prose=prose,
1142
1169
  )
1143
1170
  container_height += title_height + effective_gap
1144
1171
 
@@ -1675,12 +1702,14 @@ def nested_face_sizing_context(
1675
1702
  nested_face, content_width, variable_defaults
1676
1703
  )
1677
1704
  elif nested_face.title:
1705
+ prose = face_is_prose(nested_face.text)
1678
1706
  title_height = max(
1679
1707
  get_title_height(
1680
1708
  nested_face.title,
1681
1709
  inner,
1682
1710
  variable_defaults,
1683
1711
  resolved_style=nrs,
1712
+ prose=prose,
1684
1713
  ),
1685
1714
  float(nested_face.resolved_style.title.min_height),
1686
1715
  )
@@ -29,14 +29,15 @@ Font family rules (theme-driven; values below are per-theme):
29
29
  every width. On the shipped ``default`` (and ``cream``) the title-slot
30
30
  family is serif (Source Serif 4), so medium/wide chart titles render serif
31
31
  while smaller cards stay sans for legibility.
32
- - Prose content and their titles: serif at any width, regardless of theme.
33
- Prose is detected via ``is_prose(text)`` true when word count ≥ 100.
32
+ - Prose content uses the theme's text stack. Prose titles use the theme's
33
+ title stack at any width instead of the chart-title narrow-card fallback.
34
+ Prose is detected via ``is_prose(text)`` - true when word count >= 100.
34
35
 
35
- Page and section titles (face/board headers) always resolve to
36
- ``style.font.family`` (sans on every shipped theme) at every width unless the
37
- face contains prose content. Face titles clamp the width used for offset
38
- computation to the narrow boundary (≥ 360px) a face header should never
39
- shrink below the narrow tier even on a narrow nested container.
36
+ Page and section titles (face/board headers) use ``style.text.font.family`` in
37
+ normal faces and ``style.title.font.family`` in prose faces. Face titles clamp
38
+ the width used for offset computation to the narrow boundary (≥ 360px) — a face
39
+ header should never shrink below the narrow tier even on a narrow nested
40
+ container.
40
41
 
41
42
  Chart labels (axis, legend, tick) are separately themed — not controlled here.
42
43
 
@@ -47,7 +48,7 @@ Usage::
47
48
  font_size, weight, family = chart_title_spec(
48
49
  width, level=face_level + 1, resolved_chart_style=resolved_style.charts
49
50
  )
50
- font_size, weight, family = face_title_spec(width, level=face_level)
51
+ font_size, weight = face_title_spec(width, level=face_level)
51
52
  """
52
53
 
53
54
  from __future__ import annotations
@@ -143,7 +144,8 @@ def is_prose(text: str) -> bool:
143
144
  """Return True if *text* qualifies as prose.
144
145
 
145
146
  Prose is defined as body text with at least ``_PROSE_WORD_THRESHOLD`` words.
146
- Prose content and its surrounding title render in serif at any card width.
147
+ Prose content uses ``style.text.font.family``. Its surrounding title uses
148
+ ``style.title.font.family`` at any card width.
147
149
 
148
150
  Args:
149
151
  text: Raw text content (Markdown or plain).
@@ -154,6 +156,11 @@ def is_prose(text: str) -> bool:
154
156
  return len(text.split()) >= _PROSE_WORD_THRESHOLD
155
157
 
156
158
 
159
+ def face_is_prose(text: str | None) -> bool:
160
+ """Return whether optional face text qualifies for prose title treatment."""
161
+ return is_prose(text) if text else False
162
+
163
+
157
164
  def chart_title_spec(
158
165
  width: float,
159
166
  *,
@@ -198,8 +205,8 @@ def chart_title_spec(
198
205
  ``build_resolved_style(...).charts`` so face-local and
199
206
  chart-local style patches are already merged in.
200
207
  use_title_family: ``True`` forces the title-slot family even on
201
- narrow/tiny cards (so prose-tagged titles use serif at every
202
- width on themes that define a serif title slot). ``False``
208
+ narrow/tiny cards (so prose-tagged titles use the theme's
209
+ title family at every width). ``False``
203
210
  forces the body family at every width. ``None`` uses the
204
211
  width default (title-slot family at medium/wide, body family
205
212
  at narrow/tiny).
@@ -233,19 +240,12 @@ def face_title_spec(
233
240
  *,
234
241
  level: int,
235
242
  resolved_style: ResolvedStyle | None = None,
236
- ) -> tuple[int, int | str, str]:
237
- """Return ``(font_size, font_weight, font_family)`` for a face/page title.
243
+ ) -> tuple[int, int | str]:
244
+ """Return ``(font_size, font_weight)`` for a face/page title.
238
245
 
239
246
  Same logic as ``chart_title_spec`` but:
240
247
  - The width used for offset computation is clamped to ``_TINY_MAX`` — a page
241
248
  header should not shrink below the narrow tier even on a narrow container.
242
- - Always returns sans (Inter) regardless of width.
243
-
244
- Note: callers that render prose faces override the returned family to serif
245
- by passing ``font_family=config.style.title.font.family`` to
246
- ``get_compact_style``. The function itself is family-agnostic; the prose
247
- exception lives in the render layer (see ``render_title`` in
248
- ``render/svg_utils.py``).
249
249
 
250
250
  Args:
251
251
  width: Pixel width of the face/board.
@@ -254,12 +254,9 @@ def face_title_spec(
254
254
  should override global config.
255
255
 
256
256
  Returns:
257
- ``(font_size_px, css_font_weight, css_font_family_string)``
257
+ ``(font_size_px, css_font_weight)``
258
258
  """
259
259
  from dataface.core.compile.config import get_config
260
- from dataface.core.compile.models.style.resolved import (
261
- apply_emoji_to_family,
262
- )
263
260
 
264
261
  cfg = get_config()
265
262
  title_style = (
@@ -279,10 +276,7 @@ def face_title_spec(
279
276
  font_size = int(sizes[effective_level - 1])
280
277
 
281
278
  weight = _coerce_weight(title_style.font.weight or 500)
282
- _root = cfg.style.font.family
283
- assert _root is not None, "style.font.family must be configured"
284
- _family = apply_emoji_to_family(_root, cfg.style.font.emoji)
285
- return font_size, weight, _family
279
+ return font_size, weight
286
280
 
287
281
 
288
282
  def face_title_markdown(
@@ -310,7 +304,7 @@ def face_title_markdown(
310
304
  ``(markdown_string, h1_size, font_weight)`` — pass to
311
305
  ``get_compact_style(h1_size=…, heading_font_weight=…)``.
312
306
  """
313
- font_size, weight, _family = face_title_spec(
307
+ font_size, weight = face_title_spec(
314
308
  width, level=level, resolved_style=resolved_style
315
309
  )
316
310
  return f"# {title}", float(font_size), weight
@@ -74,9 +74,9 @@ style:
74
74
  size: 18
75
75
  weight: 500
76
76
  case: title
77
- # Tight headline leading. Convention: larger type wants tighter line-height
78
- # (Bringhurst, Butterick). At ~24px the body 1.5 multiplier reads too airy
79
- # between wrapped lines; 1.1 sits in the standard 1.05-1.15 display range.
77
+ # Tight headline leading. Convention: larger type wants tighter line-height.
78
+ # At ~24px the body 1.25 multiplier reads loose between wrapped title lines;
79
+ # 1.1 sits in the standard display-headline range.
80
80
  line_height: 1.1
81
81
  # H1–H6 font-size ramp, indexed by face.level - 1.
82
82
  # Paired with width_offsets below — chart_title_spec computes
@@ -704,9 +704,9 @@ style:
704
704
  text:
705
705
  font:
706
706
  size: *size_body
707
- # Body leading. Convention: 1.4-1.55 for screen reading (Bringhurst ~1.5,
708
- # NYT ~1.5, Medium ~1.58). 1.5 sits at the editorial standard.
709
- line_height: 1.5
707
+ # Body leading for dense report prose. Paragraph spacing is derived from
708
+ # this rhythm in the mdsvg mapper, so the vertical system stays proportional.
709
+ line_height: 1.25
710
710
  align: left
711
711
 
712
712
  layout:
@@ -28,6 +28,15 @@ style:
28
28
  text:
29
29
  font:
30
30
  color: dft-creams.ink
31
+ code:
32
+ background: dft-creams.surface-subtle
33
+ border:
34
+ color: dft-creams.border
35
+ blockquote:
36
+ font:
37
+ color: dft-creams.muted
38
+ border:
39
+ color: dft-creams.border
31
40
  charts:
32
41
  # bar.border.color now tracks theme.background via self-reference at the
33
42
  # default-theme level (see stark.yaml), so the cream knockout
@@ -132,20 +132,21 @@ style:
132
132
  font:
133
133
  family: '''ui-monospace'', ''SFMono-Regular'', Menlo, Consolas, monospace'
134
134
  color: *color_ink
135
- background: '#f3f4f6'
135
+ size: 10
136
+ background: dft-grays.surface-subtle
136
137
  border:
137
- color: '#e5e7eb'
138
+ color: dft-grays.border
138
139
  width: 1
139
140
  radius: 4
140
141
 
141
142
  blockquote:
142
143
  font:
143
- color: '#5f6b7a'
144
+ color: dft-grays.muted
144
145
  style: italic
145
- background: '#f9fafb'
146
+ background: ''
146
147
  border:
147
- color: *color_accent
148
- width: 3
148
+ color: dft-grays.border
149
+ width: 2
149
150
  radius: 0
150
151
 
151
152
  variables: