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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ node_modules/

# testing
.coverage
_test_*.svg
.coverage.*
.fastest.coverage
htmlcov/
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ reflects what actually animates, and deploys only ship from a green build.
- **JSON validation errors:** `validate --json` returns `{valid, error}` for setup failures such as
bad JSON, unknown presets, and missing files.
- **Title in compose output:** the artifact's name now ships alongside the text roles.
- **POST body reaches every field:** `font_mode`, `pair`, `state_glyph_shape`, `data`, `format`,
`telemetry_data`, `receipt_display_name`, `connector_data`, `stats_username`, `chart_owner`,
`chart_repo`, `edge_motion`, and `motion_register` are `POST /v1/compose` body fields on every
response shape. An unknown key is reported in `warnings` (json, envelope, report) or the
`X-HW-Warning` header (svg) instead of being dropped.

### Security
- **Markup injection via `genome_override`:** a hostile genome could inject `<script>` and CSS
Expand Down Expand Up @@ -57,6 +62,20 @@ reflects what actually animates, and deploys only ship from a green build.
to truncate.
- **Matrix pills keep their value at every state:** `CRITICAL` with state `none` renders as a
pill in the none tint instead of a dash.
- **Matrix headline chip refuses by name:** a chip wider than the masthead used to escape the
frame; it now refuses with the need and the room in px and the value and label widths.
- **Matrix declared widths grow to their content floor:** a `width` below the widest chip, mark,
or header word is raised with a `declared-width` diagnostic; only at the 900px ceiling does the
table refuse. Column floors are whole pixels, so a column solved at its floor never ellipsizes
the run the floor was measured from.
- **Matrix wrapped runs enter the font subset:** wrapped cell, note, and header runs reach the
subsetter, so embedded faces carry every glyph the table paints.
- **Matrix axis ticks share the compact formatter:** `1k · 2k · 3k` and `2.5M`, never `1000k`;
values below a thousand keep their digits (`0.25`).
- **Fitter below the ellipsis width renders nothing:** a budget narrower than `…` returns an empty
run and reports overflow instead of an ellipsis wider than its cell.
- **Specimen fixtures:** 13 diagram fixture sources repointed to the moved hand specimens; the
suite skips only when the `v04/` corpus is absent and fails when a specimen is missing.
- **Matrix bar axis grades the bar track:** gridlines and ticks land where the bars end; heat
tiles in compressed columns never emit a negative-width rect; multi-paragraph notes stay inside
their row.
Expand All @@ -80,6 +99,8 @@ reflects what actually animates, and deploys only ship from a green build.
### Removed
- **`custom_glyph_svg`:** it silently discarded your SVG while marking the output
`data-hw-glyph="custom"`. Now rejected as an unknown field.
- **`metric_text_x` / `text_inset`:** the strip metric inset knob was read by nothing since the
symmetric-air law; the paradigm field and the `compute_cell_layout` parameter are gone.

### Notes
- Metadata changes touch every artifact, but nothing moves a pixel.
Expand Down
262 changes: 131 additions & 131 deletions assets/examples/matrices/hw-data-connectors-matrix-inlay.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 4 additions & 2 deletions src/hyperweave/compose/geometry/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ def measure_voice(text: str, voice: MatrixVoice) -> float:


def truncate_to_width(text: str, max_w: float, voice: MatrixVoice) -> str:
"""Longest prefix of ``text`` that fits ``max_w``, ellipsized.
"""Longest prefix of ``text`` that fits ``max_w``, ellipsized; the bare
ellipsis when no character fits beside it, nothing when even the
ellipsis does not.

Measurement-based (per-font LUTs), so the ellipsis lands where the ink
actually runs out. The untruncated string stays in the payload by
Expand All @@ -92,7 +94,7 @@ def truncate_to_width(text: str, max_w: float, voice: MatrixVoice) -> str:
else:
hi = mid - 1
if lo == 0:
return ELLIPSIS
return ELLIPSIS if measure_voice(ELLIPSIS, voice) <= max_w else ""
return text[:lo].rstrip() + ELLIPSIS


Expand Down
84 changes: 62 additions & 22 deletions src/hyperweave/compose/matrix/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any

from hyperweave.compose.geometry.text import measure_voice, truncate_to_width, widest_fragment, wrap_natural
from hyperweave.compose.geometry.text import ELLIPSIS, measure_voice, truncate_to_width, widest_fragment, wrap_natural
from hyperweave.compose.matrix.cells import (
bar_track_w,
build_cell,
Expand Down Expand Up @@ -57,6 +57,7 @@
CellState,
ColRole,
GlyphTint,
Headline,
MatrixCapacityError,
MatrixCell,
MatrixColumn,
Expand All @@ -67,6 +68,7 @@
RowHeight,
is_chain,
)
from hyperweave.core.text import format_compact_number

if TYPE_CHECKING:
from collections.abc import Sequence
Expand Down Expand Up @@ -164,6 +166,21 @@ def compute_matrix_layout(
naturals, floors, hard_floors = _natural_widths(
spec, data_cols, cells_by_col, hero_j=hero_j, cfg=cfg, geometry=geometry
)
raised = [
(column.label or column.id, float(column.width), hard_floors[j])
for j, column in enumerate(data_cols)
if column.width is not None and hard_floors[j] > float(column.width) + 0.5
]
if raised:
diagnostics.append(
Diagnostic(
rule="declared-width",
measured="declared width below the content floor: "
+ ", ".join(f"{name!r} {declared:.0f}px to {floor:.0f}px" for name, declared, floor in raised),
band="declared width >= content floor (widest header word, chip, mark, or summary run)",
suggestion="widen or drop the declared width, or shorten the column's content",
)
)

# ── Adaptive frame width ─────────────────────────────────────────────
# The frame fits its content: cfg.width is a CEILING, not a constant.
Expand Down Expand Up @@ -233,6 +250,8 @@ def _masthead_floor(t_w: float) -> float:
masthead_w = _masthead_floor(title_w)
width = min(math.ceil(max(content_w, masthead_w, footer_w, float(cfg.min_width))), ceiling)
avail = width - 2 * margin
if spec.headline is not None:
_refuse_escaping_headline(spec.headline, title=spec.title, avail=avail, ceiling=ceiling, cfg=cfg)

# ── Label column allocation ────────────────────────────────────────
# Relational slack: the label takes its need while every data column
Expand Down Expand Up @@ -272,7 +291,7 @@ def _masthead_floor(t_w: float) -> float:
right_occupant = masthead_right_w + 24.0 if (spec.headline is not None or legend_adjacent) else 0.0
if spec.title:
title_cap = avail - right_occupant
if 0 < title_cap < title_w:
if title_cap < title_w:
title_size = _largest_fitting_title_size(title_text, title_cap, cfg)
floor_voice = cfg.title_voice.model_copy(update={"size": title_size})
if measure_voice(title_text, floor_voice) > title_cap:
Expand Down Expand Up @@ -792,10 +811,13 @@ def _natural_widths(
+ 2 * cfg.cell_pad_x
)
geometry_floor = content_floor(column.kind, cells_by_col[j], column, cfg=cfg, geometry=geometry)
text_floor = max(header_word, summary_w, geometry_floor)
# Whole pixels, as the label column: a floor equal to the ink to the
# last bit lands the cell's own fit check on the wrong side of a float.
text_floor = float(math.ceil(max(header_word, summary_w, geometry_floor)))
if column.width is not None:
# A declared width is the caller's contract on both counts.
w = float(column.width)
# A declared width is the caller's contract down to the content
# floor; below it the column grows and the solve says so.
w = max(float(column.width), text_floor)
hard = w
elif column.kind in _FLEX_KINDS:
# Flexible kinds absorb the remainder; their natural is the
Expand Down Expand Up @@ -842,8 +864,9 @@ def _capacity_message(
widest = sorted(range(len(data_cols)), key=lambda j: hard_floors[j], reverse=True)[:3]
names = ", ".join(repr(data_cols[j].label) for j in widest)
return (
f"the column headers and a minimum label column need {deficit:.0f}px more than the {ceiling}px "
f"frame holds; shorten the widest headers ({names}) or split the table"
f"the column floors (widest header word, chip, mark, or summary run) and a minimum label column "
f"need {deficit:.0f}px more than the {ceiling}px frame holds; narrow the widest columns ({names}) "
"or split the table"
)


Expand Down Expand Up @@ -1129,15 +1152,41 @@ def _last_filled_y(spec: MatrixSpec, j: int, row_y: Sequence[float], row_h: Sequ
return row_y[last] + row_h[last] / 2


def _headline_chip_widths(headline: Headline, cfg: ParadigmMatrixConfig) -> tuple[float, float, float]:
"""Value ink, label ink, and the chip's outer width — the one spacing
math the width decision, the refusal, and the builder all read."""
value_w = measure_voice(headline.value, cfg.headline_voice)
label_w = measure_voice(headline.label, cfg.desc_voice) if headline.label else 0.0
return value_w, label_w, value_w + label_w + (10.0 if headline.label else 0.0) + 24.0


def _refuse_escaping_headline(
headline: Headline, *, title: str, avail: float, ceiling: int, cfg: ParadigmMatrixConfig
) -> None:
"""The chip never leaves the frame. Beside a title it also leaves the
24px gap and room for the title's floor-size ellipsis, so the title
always renders and reports when it truncated."""
value_w, label_w, chip_w = _headline_chip_widths(headline, cfg)
if title:
floor_voice = cfg.title_voice.model_copy(update={"size": cfg.title_size_floor})
room = avail - 24.0 - measure_voice(ELLIPSIS, floor_voice)
where = "beside the title"
else:
room, where = avail, "on the masthead line"
if chip_w > room + 1e-6:
raise MatrixCapacityError(
f"the headline chip needs {chip_w:.0f}px (value {value_w:.0f}px, label {label_w:.0f}px), "
f"{room:.0f}px is available {where} at the {ceiling}px frame; shorten the headline value or label"
)


def _masthead_right_width(
spec: MatrixSpec, data_cols: Sequence[MatrixColumn], *, cfg: ParadigmMatrixConfig, chain: bool
) -> float:
"""Width the descriptor line's right occupant needs (headline chip or
indicator legend), mirroring the builders' own spacing math."""
indicator legend), one spacing math with the builders."""
if spec.headline is not None:
value_w = measure_voice(spec.headline.value, cfg.headline_voice)
label_w = measure_voice(spec.headline.label, cfg.desc_voice) if spec.headline.label else 0.0
return value_w + label_w + (10.0 if spec.headline.label else 0.0) + 24.0
return _headline_chip_widths(spec.headline, cfg)[2]
kinds = {c.kind for c in data_cols}
labels: tuple[str, ...]
if chain:
Expand Down Expand Up @@ -1190,9 +1239,7 @@ def _header_block(
key_rects: list[RectSpec] = []
right = margin + avail
if spec.headline is not None:
value_w = measure_voice(spec.headline.value, cfg.headline_voice)
label_w = measure_voice(spec.headline.label, cfg.desc_voice) if spec.headline.label else 0.0
chip_w = value_w + label_w + (10.0 if spec.headline.label else 0.0) + 24.0
chip_w = _headline_chip_widths(spec.headline, cfg)[2]
chip_h = 18.0
chip_y = 41.0
headline_chip = RectSpec(right - chip_w, chip_y, chip_w, chip_h, chip_h / 2)
Expand Down Expand Up @@ -1399,7 +1446,7 @@ def _axis_block(
while v <= axis_max + 1e-9:
x = x0 + (v / axis_max) * track_w
grid.append(LineSpec(x, rows_top + 2.0, x, axis_top))
ticks.append(TextSpec(x=x, y=tick_y, anchor="middle", text=_fmt_tick(v)))
ticks.append(TextSpec(x=x, y=tick_y, anchor="middle", text=format_compact_number(v, lower_k=True)))
v += step
caption = (
TextSpec(
Expand Down Expand Up @@ -1428,10 +1475,3 @@ def _nice_step(raw: float) -> float:
if mult * mag <= raw:
step = mult * mag
return step


def _fmt_tick(v: float) -> str:
if v >= 1000:
scaled = v / 1000.0
return f"{scaled:g}k"
return f"{v:g}"
12 changes: 3 additions & 9 deletions src/hyperweave/compose/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1388,7 +1388,7 @@ def resolve_strip(
# ── Per-cell adaptive widths via core/cell_layout.py ──
# Single source of truth: every parameter that affects rendered cell
# width (font family, size, weight, letter-spacing, cell_pad,
# min_cell_w, anchor, text_inset) is read from the paradigm YAML and
# min_cell_w, anchor) is read from the paradigm YAML and
# passed once to ``compute_cell_layout``. The legacy split — where
# the resolver measured at weight=700 and ls=0 while the template
# rendered at weight=900 and ls=0.22em via CSS class — is removed.
Expand Down Expand Up @@ -1420,7 +1420,6 @@ def resolve_strip(
cell_pad = 2 * strip_cfg.strip_pad if strip_cfg.owns_strip else strip_cfg.cell_pad
cell_min_w = strip_cfg.cell_min_width
text_anchor = strip_cfg.metric_text_anchor
text_inset = strip_cfg.metric_text_x
else:
value_size = profile.get("strip_metric_value_size", 18)
label_size = profile.get("strip_metric_label_size", 7)
Expand All @@ -1433,7 +1432,6 @@ def resolve_strip(
cell_pad = 20
cell_min_w = min_metric_pitch
text_anchor = "middle"
text_inset = 0

cell_layouts_records: list[dict[str, Any]] = []
for metric in metrics:
Expand All @@ -1456,7 +1454,6 @@ def resolve_strip(
),
cell_pad=cell_pad,
anchor=text_anchor,
text_inset=text_inset,
min_cell_w=cell_min_w,
)
cell_layouts_records.append(asdict(layout))
Expand Down Expand Up @@ -1828,11 +1825,8 @@ def resolve_strip(
else profile.get("strip_metric_value_y", 36)
),
"strip_metric_value_skew": profile.get("strip_metric_value_skew", 0),
# Metric cell alignment — paradigm declares the text-anchor + x-offset
# so the shared metric loop in strip.svg.j2 doesn't need per-paradigm
# branches. Cellular → flush-left via ``start`` + inset 12;
# brutalist/chrome → centered via ``middle`` + fallback x (pitch//2).
"strip_metric_text_x": (strip_cfg.metric_text_x if strip_cfg else 0),
# Metric cell alignment — the paradigm declares the text-anchor so the
# shared metric loop in strip.svg.j2 needs no per-paradigm branch.
"strip_metric_text_anchor": (strip_cfg.metric_text_anchor if strip_cfg else "middle"),
"strip_identity_font": profile.get("strip_identity_font", "var(--dna-font-mono, 'SF Mono', monospace)"),
"strip_metric_label_font": profile.get("strip_metric_label_font", "var(--dna-font-mono, 'SF Mono', monospace)"),
Expand Down
9 changes: 4 additions & 5 deletions src/hyperweave/core/cell_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ def compute_cell_layout(
*,
cell_pad: float,
anchor: str,
text_inset: float,
min_cell_w: int = 0,
) -> CellLayout:
"""Resolve a metric cell's geometry from label + value specs.
Expand All @@ -79,17 +78,17 @@ def compute_cell_layout(
cell, so a short floored cell absorbed the slack as generous extra spacing
while a wide content-driven cell stayed tight — inconsistent rhythm.) The
effective minimum cell is thus ``min_cell_w + cell_pad``. ``label_x`` /
``value_x`` are coordinates inside the cell: at ``text_inset`` for
``anchor='start'``, at ``cell_w / 2`` for ``'middle'``, at
``cell_w - text_inset`` for ``'end'``.
``value_x`` are coordinates inside the cell: the near edge plus the
cell's side air for ``anchor='start'``, ``cell_w / 2`` for ``'middle'``,
the far edge minus that air for ``'end'``.
"""
label_w = label.rendered_width
value_w = value.rendered_width
content_w = max(label_w, value_w)
cell_w = math.ceil(max(content_w, float(min_cell_w)) + cell_pad)
# Left and right air are equal for every anchor: a start- or end-anchored
# run sits at the same distance from its near edge that the far edge
# keeps from its ink, so ``text_inset`` never buys one side more room.
# keeps from its ink.
side_air = (cell_w - content_w) / 2.0
if anchor == "start":
text_x = side_air
Expand Down
8 changes: 1 addition & 7 deletions src/hyperweave/core/paradigm.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,17 +405,11 @@ class ParadigmStripConfig(FrozenModel):
36px teal/amethyst cell columns at left and right). Zero disables."""
flank_cell_size: int = 12
"""Cell size for bifamily flank grids in pixels."""
metric_text_x: int = 0
"""Pixel inset from the cell edge for metric label+value text. Read
by :func:`compute_cell_layout` only when ``metric_text_anchor`` is
``start`` (inset from the left edge) or ``end`` (inset from the
right edge). For the default ``middle`` anchor the text centers at
``cell_w / 2`` and this field is unused."""
metric_text_anchor: Literal["start", "middle", "end"] = "middle"
"""SVG ``text-anchor`` for metric label+value. ``middle`` is the
canonical strip layout shared across all production paradigms
(brutalist, chrome, cellular). ``start`` / ``end`` flush text to
the cell edge plus ``metric_text_x`` inset. One knob drives both
the cell edge with equal air on both sides. One knob drives both
label and value so they share the same anchor grid."""
label_font_weight: int = 400
"""CSS-rendered weight for metric labels. The resolver measures with
Expand Down
16 changes: 10 additions & 6 deletions src/hyperweave/core/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,22 +272,26 @@ def measure_text_trailing_bearing(
).trailing_bearing


def format_compact_number(value: float, *, integer_k: bool = False) -> str:
def format_compact_number(value: float, *, integer_k: bool = False, lower_k: bool = False) -> str:
"""Collapse a magnitude to its largest unit: ``1500 → 1.5K``, ``1500000 →
1.5M``, ``2e9 → 2B``; below 1000 the rounded integer. One decimal, with a
1.5M``, ``2e9 → 2B``; below 1000 the value as written with no trailing
zeros (``500``, ``2.5``, ``0.25``). One decimal above a thousand, with a
trailing ``.0`` dropped, so axis ticks, hero callouts, and context-window
labels all speak one register. ``integer_k`` rounds the K band to whole
thousands (``196400 → 196K``) for the occupancy labels whose specimen
reads ``200K`` / ``196K``. Negative inputs format by magnitude.
reads ``200K`` / ``196K``. ``lower_k`` writes thousands as ``k`` (the
matrix axis register); ``M`` and ``B`` stay capitals because ``m`` reads
as milli. Negative inputs format by magnitude.
"""
n = abs(value)
for unit, divisor in (("B", 1_000_000_000), ("M", 1_000_000), ("K", 1_000)):
if n >= divisor:
scaled = n / divisor
suffix = "k" if unit == "K" and lower_k else unit
if unit == "K" and integer_k:
return f"{round(scaled)}K"
return f"{scaled:.1f}".rstrip("0").rstrip(".") + unit
return f"{round(n)}"
return f"{round(scaled)}{suffix}"
return f"{scaled:.1f}".rstrip("0").rstrip(".") + suffix
return f"{n:g}"


def format_duration(minutes: float) -> str:
Expand Down
2 changes: 1 addition & 1 deletion src/hyperweave/data/paradigms/cellular.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ strip:
flank_width: 36
flank_cell_size: 12
# Cell layout inherits the default centered alignment from
# ParadigmStripConfig (metric_text_anchor=middle, metric_text_x=0) —
# ParadigmStripConfig (metric_text_anchor=middle) —
# cellular has no reason to diverge from brutalist/chrome on
# alignment, since centered metrics read as the canonical strip
# vocabulary across all three paradigms.
Expand Down
Loading