Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -9,161 +9,418 @@ use ratatui::style::{Modifier, Style};
|
|||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
|
||||
/// Legal line copy — used for both render spans and mouse hit width.
|
||||
const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy.";
|
||||
/// Shares its row with the buttons.
|
||||
const PRIVACY_BANNER_TITLE: &str = "Help improve Grok";
|
||||
|
||||
/// Click target for the legal line links.
|
||||
pub(crate) const PRIVACY_BANNER_LEGAL_URL: &str = "https://x.ai/legal";
|
||||
const PRIVACY_BANNER_DESC: &str = "Off by default. Opt-in to allow SpaceXAI to retain coding \
|
||||
data, e.g., prompts, traces, & metrics, for training and debugging purposes. Change \
|
||||
anytime via settings.";
|
||||
|
||||
pub(crate) const PRIVACY_BANNER_TERMS_URL: &str = "https://x.ai/legal/terms-of-service";
|
||||
pub(crate) const PRIVACY_BANNER_POLICY_URL: &str = "https://x.ai/legal/privacy-policy";
|
||||
|
||||
/// `(text, url_when_link)`.
|
||||
type LegalSegment = (&'static str, Option<&'static str>);
|
||||
|
||||
/// Widest first; the first that fits *whole* wins. A clipped line would
|
||||
/// leave hit rects over unreadable link text, and every variant keeps both
|
||||
/// links so neither document becomes unreachable.
|
||||
const PRIVACY_BANNER_LEGAL_VARIANTS: [&[LegalSegment]; 3] = [
|
||||
&[
|
||||
("Read ", None),
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" and ", None),
|
||||
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
(".", None),
|
||||
],
|
||||
&[
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" and ", None),
|
||||
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
],
|
||||
&[
|
||||
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
|
||||
(" & ", None),
|
||||
("Privacy", Some(PRIVACY_BANNER_POLICY_URL)),
|
||||
],
|
||||
];
|
||||
|
||||
const OPT_OUT_LABEL: &str = "[Opt out]";
|
||||
const OPT_IN_LABEL: &str = "[Opt in]";
|
||||
|
||||
/// Title + legal.
|
||||
const CHROME_ROWS: u16 = 2;
|
||||
|
||||
pub(crate) const MIN_HEIGHT: u16 = CHROME_ROWS + 1;
|
||||
|
||||
/// Caps banner growth on narrow terminals; overflow is elided with `…` so
|
||||
/// the disclosure never looks complete when it isn't.
|
||||
const MAX_BODY_ROWS: usize = 4;
|
||||
|
||||
/// Past this, the body abandons the button column for the full slot width:
|
||||
/// a shorter banner beats a tidy right edge.
|
||||
const PREFERRED_BODY_ROWS: usize = 3;
|
||||
|
||||
/// Hit rects returned by [`render`] for mouse handling.
|
||||
pub(crate) struct PrivacyBannerRects {
|
||||
pub accept: Rect,
|
||||
pub customize: Rect,
|
||||
pub legal: Rect,
|
||||
pub opt_in: Rect,
|
||||
pub opt_out: Rect,
|
||||
pub terms: Rect,
|
||||
pub policy: Rect,
|
||||
}
|
||||
|
||||
/// Render the banner: copy left, `[Customize in settings]` / `[Accept]`
|
||||
/// right, legal links on the second row. Needs `area.height >= 2`.
|
||||
/// Hover styling mirrors the plugin CTA buttons.
|
||||
impl PrivacyBannerRects {
|
||||
fn none() -> Self {
|
||||
Self {
|
||||
opt_in: Rect::default(),
|
||||
opt_out: Rect::default(),
|
||||
terms: Rect::default(),
|
||||
policy: Rect::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn button_block_width() -> u16 {
|
||||
(OPT_OUT_LABEL.len() + 1 + OPT_IN_LABEL.len()) as u16
|
||||
}
|
||||
|
||||
fn legal_width(variant: &[LegalSegment]) -> u16 {
|
||||
variant.iter().map(|(text, _)| text.len() as u16).sum()
|
||||
}
|
||||
|
||||
/// Buttons render whole or not at all, and never at the cost of the title:
|
||||
/// a clipped/overflowing `[Opt in]` must not leave a click target in the
|
||||
/// blank margin (a stray click there would silently opt the user in).
|
||||
fn buttons_fit(area_width: u16) -> bool {
|
||||
area_width >= PRIVACY_BANNER_TITLE.len() as u16 + 1 + button_block_width()
|
||||
}
|
||||
|
||||
fn title_width(area_width: u16) -> u16 {
|
||||
if buttons_fit(area_width) {
|
||||
area_width - button_block_width() - 1
|
||||
} else {
|
||||
area_width
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_to(width: usize) -> Vec<std::borrow::Cow<'static, str>> {
|
||||
if width == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let opts = textwrap::Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
|
||||
textwrap::wrap(PRIVACY_BANNER_DESC, opts)
|
||||
}
|
||||
|
||||
fn body_lines(area_width: u16) -> Vec<std::borrow::Cow<'static, str>> {
|
||||
let column = wrap_to(title_width(area_width) as usize);
|
||||
let mut lines = if column.len() <= PREFERRED_BODY_ROWS {
|
||||
column
|
||||
} else {
|
||||
let full = wrap_to(area_width as usize);
|
||||
if full.len() < column.len() {
|
||||
full
|
||||
} else {
|
||||
column
|
||||
}
|
||||
};
|
||||
if lines.len() > MAX_BODY_ROWS {
|
||||
lines.truncate(MAX_BODY_ROWS);
|
||||
if let Some(last) = lines.last_mut() {
|
||||
let mut s = last.trim_end().to_string();
|
||||
while s.chars().count() + 1 > area_width as usize {
|
||||
s.pop();
|
||||
}
|
||||
s.push('\u{2026}');
|
||||
*last = std::borrow::Cow::Owned(s);
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Rows needed at `width` — the body wraps, so both slot owners must size
|
||||
/// from this rather than a constant.
|
||||
pub(crate) fn height(width: u16) -> u16 {
|
||||
CHROME_ROWS + (body_lines(width).len() as u16).max(1)
|
||||
}
|
||||
|
||||
/// Needs `area.height >= MIN_HEIGHT`; give it [`height`] rows for the full
|
||||
/// body.
|
||||
pub(crate) fn render(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
theme: &Theme,
|
||||
mouse_pos: Option<(u16, u16)>,
|
||||
) -> PrivacyBannerRects {
|
||||
let customize_label = "[Customize in settings]";
|
||||
let accept_label = "[Accept]";
|
||||
let right_w = (customize_label.len() + 1 + accept_label.len()) as u16;
|
||||
// Buttons render whole or not at all: a clipped/overflowing [Accept]
|
||||
// must never leave a click target in the blank margin (a stray click
|
||||
// there would silently opt the user in).
|
||||
let buttons_fit = area.width > right_w;
|
||||
let left_w = if buttons_fit {
|
||||
area.width - right_w - 1
|
||||
} else {
|
||||
area.width
|
||||
};
|
||||
|
||||
let left = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: left_w,
|
||||
height: area.height.min(2),
|
||||
};
|
||||
let right = Rect {
|
||||
x: area.x + left_w + 1,
|
||||
y: area.y,
|
||||
width: right_w,
|
||||
height: 1,
|
||||
};
|
||||
if area.height < MIN_HEIGHT || area.width == 0 {
|
||||
return PrivacyBannerRects::none();
|
||||
}
|
||||
|
||||
let hovered = |r: Rect| {
|
||||
mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my)))
|
||||
};
|
||||
|
||||
let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
|
||||
PRIVACY_BANNER_LEGAL.len()
|
||||
} else {
|
||||
"Learn more".len().min(left.width as usize)
|
||||
};
|
||||
// The legal line only exists when the slot really has a second row —
|
||||
// otherwise its rect would make the blank row below clickable.
|
||||
let legal_rect = if area.height >= 2 {
|
||||
// Figma node 8698:3806.
|
||||
buf.set_stringn(
|
||||
area.x,
|
||||
area.y,
|
||||
PRIVACY_BANNER_TITLE,
|
||||
title_width(area.width) as usize,
|
||||
Style::default().fg(theme.text_primary),
|
||||
);
|
||||
|
||||
let body_style = Style::default().fg(theme.gray_bright);
|
||||
let body_rows = area.height - CHROME_ROWS;
|
||||
let body: Vec<Line> = body_lines(area.width)
|
||||
.into_iter()
|
||||
.take(body_rows as usize)
|
||||
.map(|l| Line::styled(l.into_owned(), body_style))
|
||||
.collect();
|
||||
Paragraph::new(body).render(
|
||||
Rect {
|
||||
x: left.x,
|
||||
y: left.y.saturating_add(1),
|
||||
width: legal_w as u16,
|
||||
height: 1,
|
||||
}
|
||||
} else {
|
||||
Rect::default()
|
||||
};
|
||||
x: area.x,
|
||||
y: area.y + 1,
|
||||
width: area.width,
|
||||
height: body_rows,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
|
||||
// Figma node 8698:3806: title fg/primary, description fg/secondary,
|
||||
// legal line fg/tertiary with underlined links in the same color.
|
||||
// The whole legal line is one click target, so its links brighten together.
|
||||
let link_fg = if hovered(legal_rect) {
|
||||
theme.gray_bright
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
let link = Style::default()
|
||||
.fg(link_fg)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
// Last row, so it gets the full width — no buttons to dodge.
|
||||
let gray = Style::default().fg(theme.gray);
|
||||
let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary));
|
||||
let desc = "Allow your sessions to improve SpaceXAI's models.";
|
||||
// Drop trailing spans whole rather than clipping mid-word when narrow.
|
||||
let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() {
|
||||
Line::from(vec![
|
||||
title,
|
||||
Span::raw(" "),
|
||||
Span::styled(desc, Style::default().fg(theme.gray_bright)),
|
||||
])
|
||||
} else {
|
||||
Line::from(title)
|
||||
};
|
||||
// Span pieces must reassemble to PRIVACY_BANNER_LEGAL.
|
||||
let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
|
||||
Line::from(vec![
|
||||
Span::styled("Learn more", link),
|
||||
Span::styled(" and read ", gray),
|
||||
Span::styled("Terms", link),
|
||||
Span::styled(" and ", gray),
|
||||
Span::styled("Privacy Policy", link),
|
||||
Span::styled(".", gray),
|
||||
])
|
||||
} else {
|
||||
Line::from(Span::styled("Learn more", link))
|
||||
};
|
||||
Paragraph::new(vec![line1, line2]).render(left, buf);
|
||||
let legal_y = area.y + area.height - 1;
|
||||
let mut terms_rect = Rect::default();
|
||||
let mut policy_rect = Rect::default();
|
||||
if let Some(variant) = PRIVACY_BANNER_LEGAL_VARIANTS
|
||||
.into_iter()
|
||||
.find(|v| legal_width(v) <= area.width)
|
||||
{
|
||||
let mut x = area.x;
|
||||
let mut spans = Vec::with_capacity(variant.len());
|
||||
for (text, url) in variant {
|
||||
let w = text.len() as u16;
|
||||
let style = match url {
|
||||
None => gray,
|
||||
Some(url) => {
|
||||
let rect = Rect {
|
||||
x,
|
||||
y: legal_y,
|
||||
width: w,
|
||||
height: 1,
|
||||
};
|
||||
if *url == PRIVACY_BANNER_TERMS_URL {
|
||||
terms_rect = rect;
|
||||
} else {
|
||||
policy_rect = rect;
|
||||
}
|
||||
let fg = if hovered(rect) {
|
||||
theme.gray_bright
|
||||
} else {
|
||||
theme.gray
|
||||
};
|
||||
Style::default().fg(fg).add_modifier(Modifier::UNDERLINED)
|
||||
}
|
||||
};
|
||||
spans.push(Span::styled(*text, style));
|
||||
x += w;
|
||||
}
|
||||
Paragraph::new(Line::from(spans)).render(
|
||||
Rect {
|
||||
x: area.x,
|
||||
y: legal_y,
|
||||
width: x - area.x,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
|
||||
if !buttons_fit {
|
||||
if !buttons_fit(area.width) {
|
||||
return PrivacyBannerRects {
|
||||
accept: Rect::default(),
|
||||
customize: Rect::default(),
|
||||
legal: legal_rect,
|
||||
opt_in: Rect::default(),
|
||||
opt_out: Rect::default(),
|
||||
terms: terms_rect,
|
||||
policy: policy_rect,
|
||||
};
|
||||
}
|
||||
let customize_rect = Rect {
|
||||
x: right.x,
|
||||
y: right.y,
|
||||
width: customize_label.len() as u16,
|
||||
let opt_out_rect = Rect {
|
||||
x: area.x + area.width - button_block_width(),
|
||||
y: area.y,
|
||||
width: OPT_OUT_LABEL.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
let accept_rect = Rect {
|
||||
x: right.x + customize_label.len() as u16 + 1,
|
||||
y: right.y,
|
||||
width: accept_label.len() as u16,
|
||||
let opt_in_rect = Rect {
|
||||
x: opt_out_rect.x + opt_out_rect.width + 1,
|
||||
y: area.y,
|
||||
width: OPT_IN_LABEL.len() as u16,
|
||||
height: 1,
|
||||
};
|
||||
let customize_style = if hovered(customize_rect) {
|
||||
let opt_out_style = if hovered(opt_out_rect) {
|
||||
Style::default().fg(theme.text_primary).bg(theme.bg_hover)
|
||||
} else {
|
||||
Style::default().fg(theme.gray_bright)
|
||||
};
|
||||
let accept_style = if hovered(accept_rect) {
|
||||
let opt_in_style = if hovered(opt_in_rect) {
|
||||
Style::default().fg(theme.link_fg).bg(theme.bg_hover)
|
||||
} else {
|
||||
Style::default().fg(theme.text_primary)
|
||||
};
|
||||
buf.set_stringn(
|
||||
customize_rect.x,
|
||||
customize_rect.y,
|
||||
customize_label,
|
||||
customize_rect.width as usize,
|
||||
customize_style,
|
||||
opt_out_rect.x,
|
||||
opt_out_rect.y,
|
||||
OPT_OUT_LABEL,
|
||||
opt_out_rect.width as usize,
|
||||
opt_out_style,
|
||||
);
|
||||
buf.set_stringn(
|
||||
accept_rect.x,
|
||||
accept_rect.y,
|
||||
accept_label,
|
||||
accept_rect.width as usize,
|
||||
accept_style,
|
||||
opt_in_rect.x,
|
||||
opt_in_rect.y,
|
||||
OPT_IN_LABEL,
|
||||
opt_in_rect.width as usize,
|
||||
opt_in_style,
|
||||
);
|
||||
PrivacyBannerRects {
|
||||
accept: accept_rect,
|
||||
customize: customize_rect,
|
||||
legal: legal_rect,
|
||||
opt_in: opt_in_rect,
|
||||
opt_out: opt_out_rect,
|
||||
terms: terms_rect,
|
||||
policy: policy_rect,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Render at `width` into a buffer sized by [`height`], returning the
|
||||
/// rows (trailing blanks trimmed) and the hit rects.
|
||||
fn draw(width: u16) -> (Vec<String>, PrivacyBannerRects) {
|
||||
let h = height(width);
|
||||
let area = Rect::new(0, 0, width, h);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let rects = render(area, &mut buf, &Theme::current(), None);
|
||||
let rows = (0..h)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
(rows, rects)
|
||||
}
|
||||
|
||||
fn rows(width: u16) -> Vec<String> {
|
||||
draw(width).0
|
||||
}
|
||||
|
||||
/// The text a legal variant reassembles to.
|
||||
fn legal_text(variant: &[LegalSegment]) -> String {
|
||||
variant.iter().map(|(text, _)| *text).collect()
|
||||
}
|
||||
|
||||
/// The buffer text under `rect` on its row.
|
||||
fn text_at(rows: &[String], rect: Rect) -> String {
|
||||
let row = &rows[rect.y as usize];
|
||||
row.chars()
|
||||
.skip(rect.x as usize)
|
||||
.take(rect.width as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Slot owners reserve [`height`] rows, so the last one it promises must
|
||||
/// be the legal line — not a body row pushed off the end.
|
||||
#[test]
|
||||
fn height_reserves_every_row_the_banner_paints() {
|
||||
for width in [200, 117, 110, 100, 80, 72, 60, 45, 40, 36, 30, 24, 18] {
|
||||
let rows = rows(width);
|
||||
assert_eq!(rows.len(), height(width) as usize);
|
||||
assert!(
|
||||
rows[0].starts_with(PRIVACY_BANNER_TITLE),
|
||||
"width {width}: title must never be clipped, got {:?}",
|
||||
rows[0]
|
||||
);
|
||||
let legal = rows.last().expect("legal row");
|
||||
assert!(
|
||||
PRIVACY_BANNER_LEGAL_VARIANTS
|
||||
.iter()
|
||||
.any(|v| legal_text(v) == *legal),
|
||||
"width {width}: legal line must survive whole, got {legal:?}"
|
||||
);
|
||||
assert!(
|
||||
rows[1..rows.len() - 1].iter().all(|r| !r.is_empty()),
|
||||
"width {width}: body rows must not be blank: {rows:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The row cap's elision is a narrow-terminal fallback, not the norm.
|
||||
#[test]
|
||||
fn body_copy_is_complete_at_common_widths() {
|
||||
for width in [200, 117, 100, 80, 60] {
|
||||
let body = rows(width)[1..].join(" ");
|
||||
let flattened: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
flattened.contains(PRIVACY_BANNER_DESC),
|
||||
"width {width}: body copy was truncated: {flattened:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_drop_whole_when_the_row_is_too_narrow() {
|
||||
let width = PRIVACY_BANNER_TITLE.len() as u16 + button_block_width(); // one short
|
||||
let h = height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
|
||||
let rects = render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None);
|
||||
assert_eq!(rects.opt_in, Rect::default());
|
||||
assert_eq!(rects.opt_out, Rect::default());
|
||||
assert_ne!(rects.terms, Rect::default(), "terms link still clickable");
|
||||
assert_ne!(rects.policy, Rect::default(), "policy link still clickable");
|
||||
|
||||
let rects = {
|
||||
let width = width + 1;
|
||||
let h = height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
|
||||
render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None)
|
||||
};
|
||||
assert_eq!(rects.opt_out.width, OPT_OUT_LABEL.len() as u16);
|
||||
assert_eq!(rects.opt_in.width, OPT_IN_LABEL.len() as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_below_min_height_arms_no_hit_rects() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, MIN_HEIGHT));
|
||||
let rects = render(
|
||||
Rect::new(0, 0, 100, MIN_HEIGHT - 1),
|
||||
&mut buf,
|
||||
&Theme::current(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(rects.opt_in, Rect::default());
|
||||
assert_eq!(rects.opt_out, Rect::default());
|
||||
assert_eq!(rects.terms, Rect::default());
|
||||
assert_eq!(rects.policy, Rect::default());
|
||||
}
|
||||
|
||||
/// The two links open different documents, so an off-by-one rect sends
|
||||
/// the user to the wrong page.
|
||||
#[test]
|
||||
fn each_legal_link_hits_its_own_words() {
|
||||
for width in [200, 117, 80, 60, 40, 30, 24, 18] {
|
||||
let (rows, rects) = draw(width);
|
||||
assert_eq!(
|
||||
text_at(&rows, rects.terms),
|
||||
"Terms",
|
||||
"width {width}: terms rect is off its word: {rows:?}"
|
||||
);
|
||||
let policy = text_at(&rows, rects.policy);
|
||||
assert!(
|
||||
policy == "Privacy Policy" || policy == "Privacy",
|
||||
"width {width}: policy rect is off its word, got {policy:?}"
|
||||
);
|
||||
assert!(
|
||||
rects.terms.right() <= rects.policy.x,
|
||||
"width {width}: link rects must not overlap"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue