Skip to content

Commit 7ed2a69

Browse files
authored
Merge pull request #2 from treble-app/feat/sync-size-guardrail
feat: sync size guardrail with searchable picker
1 parent 5544d91 commit 7ed2a69

5 files changed

Lines changed: 572 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/init.rs

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,7 @@ pub async fn run(figma_arg: Option<String>, flavor: String) -> Result<()> {
8181
match client.get_file(&file_key).await {
8282
Ok(file) => {
8383
println!("{}", format!("\"{}\"", file.name).green());
84-
85-
// Show pages
86-
for page in &file.document.children {
87-
let frame_count = page.children.len();
88-
println!(" {} {} ({} frames)", "→".dimmed(), page.name, frame_count);
89-
}
84+
print_page_tree(&file.document.children);
9085
}
9186
Err(e) => {
9287
println!("{}", format!("Failed: {e}").red());
@@ -180,6 +175,128 @@ fn check_claude_plugin() {
180175
}
181176
}
182177

178+
// ── Page-tree pretty printer ────────────────────────────────────────────
179+
//
180+
// Figma "pages" are a flat list, but designers fake hierarchy with naming
181+
// conventions: ALL-CAPS / "✨"-prefixed names with 0 frames are section
182+
// headers; "↳"-prefixed names are children; runs of dashes/dots are
183+
// dividers. We detect those, throw away the dividers, and render a real
184+
// tree with proper └── / ├── / │ branch glyphs.
185+
186+
use crate::figma::types::CanvasNode;
187+
188+
enum Item {
189+
Section {
190+
label: String,
191+
leaves: Vec<(String, usize)>,
192+
},
193+
Leaf {
194+
name: String,
195+
count: usize,
196+
},
197+
}
198+
199+
fn print_page_tree(canvas_pages: &[CanvasNode]) {
200+
let items = build_items(canvas_pages);
201+
202+
let total_frames: usize = canvas_pages.iter().map(|p| p.children.len()).sum();
203+
let total_pages: usize = canvas_pages
204+
.iter()
205+
.filter(|p| !p.children.is_empty())
206+
.count();
207+
208+
let last = items.len().saturating_sub(1);
209+
for (i, item) in items.iter().enumerate() {
210+
let is_last = i == last;
211+
let branch = if is_last { "└──" } else { "├──" };
212+
let trunk = if is_last { " " } else { "│ " };
213+
214+
match item {
215+
Item::Section { label, leaves } => {
216+
println!(" {} {}", branch.dimmed(), label.bold());
217+
let llast = leaves.len().saturating_sub(1);
218+
for (li, (name, count)) in leaves.iter().enumerate() {
219+
let lbranch = if li == llast {
220+
"└──"
221+
} else {
222+
"├──"
223+
};
224+
println!(
225+
" {}{} {} {}",
226+
trunk.dimmed(),
227+
lbranch.dimmed(),
228+
name,
229+
format!("({count} frames)").dimmed()
230+
);
231+
}
232+
}
233+
Item::Leaf { name, count } => {
234+
println!(
235+
" {} {} {}",
236+
branch.dimmed(),
237+
name,
238+
format!("({count} frames)").dimmed()
239+
);
240+
}
241+
}
242+
}
243+
244+
println!();
245+
println!(
246+
" {}",
247+
format!("{total_pages} pages, {total_frames} frames").dimmed()
248+
);
249+
}
250+
251+
fn build_items(canvas_pages: &[CanvasNode]) -> Vec<Item> {
252+
let mut items: Vec<Item> = Vec::new();
253+
let mut current_section: Option<(String, Vec<(String, usize)>)> = None;
254+
255+
for page in canvas_pages {
256+
let raw = page.name.trim();
257+
let frame_count = page.children.len();
258+
259+
if frame_count == 0 {
260+
if is_divider(raw) {
261+
continue;
262+
}
263+
// New section header — flush the previous one.
264+
if let Some((label, leaves)) = current_section.take() {
265+
items.push(Item::Section { label, leaves });
266+
}
267+
current_section = Some((strip_page_decoration(raw), Vec::new()));
268+
} else {
269+
let leaf = (strip_page_decoration(raw), frame_count);
270+
match current_section.as_mut() {
271+
Some((_, leaves)) => leaves.push(leaf),
272+
None => items.push(Item::Leaf {
273+
name: leaf.0,
274+
count: leaf.1,
275+
}),
276+
}
277+
}
278+
}
279+
if let Some((label, leaves)) = current_section {
280+
items.push(Item::Section { label, leaves });
281+
}
282+
items
283+
}
284+
285+
/// True when the page name is purely visual punctuation (e.g. "----",
286+
/// "- - - - -", ".....") — Figma users insert these as dividers.
287+
fn is_divider(name: &str) -> bool {
288+
name.chars().filter(|c| c.is_alphabetic()).count() < 3
289+
}
290+
291+
/// Strip the leading hierarchy markers ("↳", "✨") and surrounding
292+
/// whitespace, then collapse internal whitespace runs.
293+
fn strip_page_decoration(name: &str) -> String {
294+
let trimmed = name.trim_start_matches(|c: char| {
295+
c.is_whitespace() || c == '↳' || c == '✨' || c == '→' || c == '*'
296+
});
297+
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
298+
}
299+
183300
/// Extract a Figma file key from a URL or raw key string.
184301
/// Handles:
185302
/// - "abc123DEFghiJKL" (raw key)
@@ -232,4 +349,31 @@ mod tests {
232349
"abc123DEFghiJKL"
233350
);
234351
}
352+
353+
#[test]
354+
fn test_is_divider() {
355+
assert!(is_divider("----"));
356+
assert!(is_divider("- - - - -"));
357+
assert!(is_divider("...."));
358+
assert!(is_divider("--"));
359+
assert!(!is_divider("BRAND SUPPORT"));
360+
assert!(!is_divider("✨ LinkedIn Posts"));
361+
}
362+
363+
#[test]
364+
fn test_strip_page_decoration() {
365+
assert_eq!(
366+
strip_page_decoration("↳ Town Hall Designs"),
367+
"Town Hall Designs"
368+
);
369+
assert_eq!(
370+
strip_page_decoration("✨ Brand Support – Marketing Materials"),
371+
"Brand Support – Marketing Materials"
372+
);
373+
assert_eq!(strip_page_decoration("BRAND SUPPORT"), "BRAND SUPPORT");
374+
// Emoji that aren't markers stay put.
375+
assert_eq!(strip_page_decoration("📌 Thumbnail"), "📌 Thumbnail");
376+
// Internal whitespace collapses.
377+
assert_eq!(strip_page_decoration("↳ AHIMA 2025"), "AHIMA 2025");
378+
}
235379
}

0 commit comments

Comments
 (0)