fluidattacks-designs 0.5.1

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
Documentation

designs / gpui

The desktop (GPUI) variant of the Fluid Attacks design system. The web variant in designs/src is the source of truth; this workspace projects its tokens and components onto gpui for Rust desktop apps (common/mrq today, signals when it migrates).

Layout

Follows the federated Rust standard (common/utils/rust):

  • crates/domain — fluidattacks-designs-domain: the design tokens and per-component styling decisions, pure and no_std, usable from any toolkit on any platform. Grows one component at a time; the web files each module mirrors are named in its docs.
  • crates/shell — fluidattacks-designs: the GPUI widgets over those tokens. macOS-gated with their toolkit for now, mirroring mrq (gpui's Linux build needs a native windowing stack the Linux CI runners don't carry).

Catalog

cargo run --example gallery opens the native component catalog (macOS). A browser gallery in the spirit of gpui-component's story-web (gpui compiled to wasm) is the intended long-term storybook equivalent; it stays out of scope until gpui's wasm backend ships in a crates.io release, because the federated cargo-deny policy pins all dependencies to crates.io (allow-git = []).

Boxes and scrolling

Container is the web's generic box. Its style surface there is TModifiable, which is CSS by another name, and gpui's Styled is the same vocabulary, so the port delegates to it rather than re-listing seventy properties: every gpui style method works on a Container. Reach for it instead of a bare div() so an app gets the design system's behaviour rather than reimplementing it.

That behaviour is the scroll prop. On the web overflow: auto is the whole story, since the browser tracks the offset and paints the bar. gpui does neither, so the port also takes the handle to track and composes Scrollbar:

Container::new()
    .scroll("rows", Scroll::Y, &self.rows_scroll)
    .flex()
    .flex_col()
    .child(row)

It is also a block box, as a div is on the web, so children stack at their content size and a scrolling one overflows. gpui defaults to flex instead, and that default is a trap: flexbox drops the automatic minimum size of any child hiding its own overflow, so a scrolling flex column squeezes those children and never scrolls. Stacking is both the faithful default and the safe one; .flex() opts into flex layout and its rules, exactly as on the web. Scrollbar stays public for the rare bar that is not a Container's own.

Table

The web's table wraps TanStack Table, which supplies sorting, filtering, column state and selection. No such library exists for GPUI, so the port wrote that model itself: it lives in fluidattacks-designs-domain::table, pure and tested, and TableState is the shell that holds it.

That split is the thing to know before reading the rest. TableState is an entity you own — you build it once, keep it, and call methods on it; Table is the element, rebuilt every frame, and carries only what the frame needs.

let table = cx.new(|cx| {
    TableState::new(
        vec![
            Column::new("path", "Location").width(280.0),
            Column::new("line", "Line").kind(ColumnKind::Number).width(80.0),
            Column::new("severity", "Severity").width(120.0),
        ],
        window,
        cx,
    )
});
Table::new(&self.table)
    .height(320.0)
    .search(true)
    .control(self.filters(cx))
    .pagination(self.fetch_into())
    .empty("No findings", "Nothing matched this filter.")

height is the framed part — header, rows and footer — so the rows get what the other two leave. The search-and-controls row sits above it and adds its own. Rows virtualize against that remainder, which is why the number is the caller's rather than something the content decides.

Rows

Rows are Vec<Vec<SharedString>>, cells in column order, and set_rows replaces the lot. Cells are strings even when they hold numbers — ColumnKind is what makes 10 sort after 9 instead of before it:

state.set_rows(
    vec![
        vec!["src/api.rs".into(), "42".into(), "high".into()],
        vec!["src/db.rs".into(), "7".into(), "low".into()],
    ],
    cx,
);

set_rows drops the colors with the rows it replaces, because a color is keyed by row index and those indices now mean something else. Repaint after serving:

state.set_rows(rows, cx);
for (row, severity) in severities.iter().enumerate() {
    state.set_row_color(row, tint_of(severity), cx);
}

set_cell_color tints one cell instead, and clear_colors drops both.

Reading back what is on screen — after the sort, the filters and the search have had their say — is visible_cells, Signals' get_visible_rows, with visible_rows for just the count.

Sorting, search and filters — and where they run

Three ways to narrow, and the distinction that matters is what data they see. All three act on the rows in hand, which under pagination is one page, not the set behind it. Clicking a header cycles asc → desc → none, and the search box — off unless search(true) mounts it — matches every visible cell.

An app that needs them over the whole set sends them to its source instead — that is what on_search and on_sort are for. Both fire after the table has recorded the change, so you read the new standing off the state and re-fetch:

// `query` reads the whole standing back off the table, so both handlers ask
// the source the same way
fn query(table: &TableState, page: Page) -> Query {
    Query {
        page,
        text: table.query().to_owned(),
        sorting: table.sorting().map(|(id, sort)| (id.to_owned(), sort)),
    }
}

state.on_search(|table, cx| serve(table, &query(table, Page::resized(25)), cx));

// ordering a page is not ordering the catalog, so the source does it; like the
// web, a new standing returns to the first page
state.on_sort(|table, cx| serve(table, &query(table, Page::resized(25)), cx));

Install these once, when you build the state. Both are deferred, so a handler may call back into the table freely.

Column filters are predicates, Signals' filter / negative_match:

state.set_filter("severity", |cell| cell == "critical", false, cx);
state.set_filter("status", |cell| cell == "closed", true, cx); // everything but
state.clear_filter("severity", cx);

sort_by sets the standing without a click, and set_search_placeholder relabels the box.

Selection

Single by default. multi_select consumes the state, so it goes on the way in:

let table = cx.new(|cx| TableState::new(columns, window, cx).multi_select(true));

which opens the rest:

gesture
click select that row alone
shift-click extend the run from the anchor, which stays put across a series
cmd-click toggle one row, leaving the others (gpui's secondary modifier)
press and drag lay a run down the rows
cmd/ctrl-C copy the selection as TSV

selected() returns row indices, clear_selection empties it, and copy_selection is the same clipboard write the keybinding does. The keybinding arrives with Theme::init — an app that installs the theme needs no keymap of its own.

Indices are into the rows you supplied, not into what is on screen; sorting and filtering move rows around them. position_of converts one to a screen position, which is what scroll_to_row and focus_row take:

if let Some(position) = state.position_of(row) {
    state.scroll_to_row(position);
    state.focus_row(position, window, cx);
}

Columns

Readers resize by dragging a header's right edge and reorder by dragging the header itself; both are gestures, with no API to call. Visibility is an app decision, so it has one:

let shown = state.is_column_visible("status");
state.set_column_visible("status", !shown, cx);

Total column width can exceed the frame — that is what makes the horizontal bar appear, and the header scrolls with the rows. The bar answers the pointer dragging it, not the wheel: a container that scrolls only one axis otherwise claims the wheel's other axis and the page behind it stops moving.

Pagination

Server-side, the web's manualPagination. The table never slices anything: a control moves the page it holds, hands it back, and fetching those rows is yours.

// the handler outlives the frame that built it, so it owns a clone
let table = self.table.clone();
Table::new(&self.table).pagination(move |page, app| {
    table.update(app, |state, cx| {
        let (rows, total) = fetch(page);
        // whoever serves a page says which one it is, or the label keeps
        // describing the page the reader left
        state.set_page(page, cx);
        state.set_rows(rows, cx);
        state.set_page_counts(PageCounts::of(total), cx);
    });
})

Without the counts the next arrow has nothing to go on and stays spent. A source that knows its total says PageCounts::of(total); one that only knows whether more exist says PageCounts::open(has_next), and the label reads 100+ the way the web's getCurrentRange does.

The footer's size chooser reports through the same handler, returning to the first page and asking for it. It offers steps up to the first one that covers what the source holds, plus whatever size is in use — so a table of 30 rows showing 25 offers 10, 20, 25 and 50, and never 100. A source that will not name its total is assumed to reach the largest step.

Two consequences worth knowing before wiring it up:

  • The label counts the source; the rows count the filter. Searching a page down to nothing leaves the empty state under a label still reading 1-25 of 320. Both are true — the source does hold 320 — but they answer different questions.
  • set_page resets the scroll, because the rows underneath it changed. Calling it is the signal, including for a re-fetch of the page already shown.

What the web has and this does not

CSV export, and the filter dropdowns beside the search — those are the app's to assemble out of Select and hand to control, which is the same shape the web takes. Editable cells exist in neither.

Text fields

TextInput is one line, TextArea is a block, and everything that is not the text itself is the same on both — the label with its asterisk and tooltip, the border that walks the state machine, the length counter, the error line, the gray help line. That shared frame is the web's OutlineContainer, and the two widgets differ in what sits inside it, which is exactly how the web differs.

Both use the split Select uses: the state is an entity the app owns, the widget renders it.

let comment = cx.new(|cx| TextAreaState::new(window, cx).rows(3).max_length(400));
TextArea::new(&comment)
    .label("Comment")
    .required(true)
    .help_text("markdown is not rendered")

rows

rows is the height of the box, in lines, and text past it scrolls. That is what a <textarea> is — a window onto the text, not a box that grows with it. The height: auto in the web's stylesheet sits on the container, which is only how the container comes to wrap the height the control already had; miss that and you build a field that pushes the page down as it is typed into.

The caret is kept inside that window as it moves, since nothing else will bring it back, and the vertical Scrollbar shows when there is more to read.

The count is honored as written, which the web does not do. Its min-height: 6rem outranks any rows under five, so a rows={1} and a rows={4} come out the same height there — the rows={3} on the comment editor and the rows={1} on the chat box are both dead props. Here three rows is three rows. An unset rows falls back to inputs::DEFAULT_ROWS, five, which is the nearest whole count to what that floor actually shows.

What the extra dimension brings

Enter writes a newline instead of reaching the form, so a textarea that should submit on a chord wants the app to bind one — the web's comment editor uses ctrl+enter. Up and down walk the caret between visual rows, holding the column they started from. Home and End answer to the visual row rather than to the whole value, so a wrapped line behaves like the separate line it looks like. Pasting keeps its newlines, where the single-line field has to flatten them.

Wrapping is the box's, not the caller's: the text shapes at whatever width the layout hands down, and re-shapes when that width changes. The field never shrinks to make room for its neighbours — a form field is not a page's slack, and one squeezed below its own text would paint straight over what follows.

What the web has and this does not

resize, which the web turns off anyway, and maskValue. That second one is worth being precise about: on the web's textarea maskValue only adds the sr-block class, which keeps the value out of session replay and has no effect on screen. It is not TextInput's bullet mask, so there is nothing to port and no reveal toggle to show.

Selects

The web combobox is react-aria plus react-stately plus a virtualizer, so almost nothing there was ours to copy: the menu's stylesheet was, and the behaviour those three libraries supply had to be written. SelectState is the entity the app owns — the options, the choice, and where the keyboard sits — and Select renders it, the same split TextInput uses:

let picker = cx.new(|cx| SelectState::new("Select a group", window, cx));
picker.update(cx, |state, cx| state.set_options(options, cx));
Select::new(&picker).on_change(|value, _| load_group(value))

Filling the menu is not choosing from it, and only the second reports. Neither set_options nor set_value can reach on_change, by construction: the handler is held by the frame that renders the rows, not by the state. This is the one behaviour to know before wiring a picker to a loader — the Signals group picker used to select the first item as its list arrived and load a group nobody had asked for.

The rest follows from gpui rather than from the web. The menu matches the width the field measured last frame, because nothing else can tell a floating box how wide the control it hangs off turned out to be. It dismisses on a mouse-down outside rather than on blur, or choosing a row would blur the field and take the row away before the click reached it. Grouped menus draw their rows straight and flat ones virtualize, since uniform_list needs every row the same height and a header is taller than an option. empty_message is what the menu says when the query rules everything out.

The keymap — down, up, enter, escape — is bound by Theme::init, so an app that installs the theme needs nothing of its own.

What the web has and this does not: multi-selection and its "All" row, the clear button, per-option tags and links, and the label, help and error rows a form field wears. Those last ones are not missing so much as elsewhere: they belong to the outline container that TextInput also wraps, not to a select.

Lists

Table is for rows of cells and Select is for a menu that floats; List is the plain column a screen stacks when each row carries one value. The split is Select's: ListState is the entity the app owns, List renders it.

let topics = cx.new(|cx| ListState::new(cx));
topics.update(cx, |state, cx| state.set_items(items, cx));
List::new(&topics).on_change(|value, _| open_topic(value))

Filling the list is not choosing from it, exactly as with Select: neither set_items nor set_value can reach on_change. Both also drop a value no row carries, so a list repopulated from a slow query never stands on a row that is gone.

The arrows move the choice rather than a separate highlight. A list is not a floating menu — there is nothing to dismiss and nothing to commit — so the row the walk lands on is the choice, and it reports as a click on that row would. enter re-reports the row already standing, which is what answers a reader who tabbed in. The keymap is bound by Theme::init.

SelectionMode::None is a list that answers no choice: rows stack, nothing highlights, and on_change never fires — set_value is refused too, so the mode cannot be talked into painting a row as chosen. That is the shape a progress column needs: Signals' reattack assign window stacks findings only to show how each one went, and a row that lit up under the pointer would invite a click it will not answer.

The far glyph is one slot, as on the web: a row that names a right_icon shows it, and one that does not shows a tick only while it is the choice. Its handler is separate from the row's and stops the press, so a glyph can remove a row without also selecting it. empty is the EmptyState the card shows when it holds no rows.

max_height caps the card and scrolls its rows past that. It is the one deviation from the web, and it is gpui's: there the page scrolls and the card just grows, which is what an uncapped card here still does.

What this does not have: the href row — the tokens for a link's color are in tokens::list, but no row renders one yet — and a header, which the web documents but never declares or renders. Multi-selection, drag to reorder and nested lists are absent from the web component too.

Checkboxes

Controlled, like the web: Checkbox paints the checked it is handed and reports the value a click would produce. Nothing in the widget remembers anything, so a checkbox cannot drift out of step with the filter or setting it stands for.

Checkbox::new("only-tests", self.only_tests)
    .label("Only in tests")
    .on_change(cx.listener(|this, checked, _, cx| {
        this.only_tests = *checked;
        cx.notify();
    }))

Two differences from the web worth knowing. It is pointer-driven and takes no focus: the web leans on a hidden native <input> for the keyboard, and there is no native input here to stand on. And its 16px square is the one metric in the design system not copied from the component it ports — the web hides that input and paints the box on the check glyph itself, declaring an 8px svg with 4px of padding and a 1px border, which Tailwind's preflight puts in border-box, where the padding and border do not fit inside the 8px they are declared within. The sibling radio-button has no glyph to paint on and writes width: 16px outright, so this takes that square and the two controls line up. Worth confirming against the published component the next time the gallery runs on a Mac.

Dialogs

Modal and ConfirmDialog render while the app draws them, the way the web returns null when its modal is closed, and report through one handler:

Modal::new("delete-root")
    .title("Delete root")
    .confirm_text("Delete")
    .cancel_text("Cancel")
    .on_outcome(cx.listener(|this, outcome, _, cx| this.answered(*outcome, cx)))
    .child(form)

Two things the web does not have to say. Host the dialog as the last child of the view that owns the window, beside the scroll box rather than in it: the scrim is absolute over its parent, so a parent that scrolls or clips takes the dialog with it, and gpui paints siblings in order, so an earlier one is painted over. A browser portal appends to the end of the body for the same two reasons.

Last child rather than deferred. Deferring would raise the dialog over any sibling, but gpui panics on a deferred inside another, and both Select and Tooltip defer their floating parts — a deferred dialog would abort the moment a form in its body opened a menu.

The body scrolls itself once it outgrows the box's 80% cap, so the caller hands over content and nothing else. gpui paints no bar and tracks no offset, so the dialog composes a Container with Scroll::Y and carries the handle across frames in its element state.

Cancel and dismiss stay apart. The web folds both into one close(), and a confirm dialog resolves its promise false for either; Signals treats someone who said no differently from someone who walked away, so Outcome carries all three and a caller that does not care can match the two together.

Alerts

A message that carries a severity. Alert is the web's banner, inline in the column that raised it; AlertDialog is the same message as a blocking dialog, which is the shape Signals shows it in:

Alert::new("lines-empty", "Nothing has changed since the last scan.")
    .variant(alert::Variant::Info)
    .title("Lines")
    .closable(true)

AlertDialog::new("session-failed", "Session", "The API refused the token.")
    .variant(alert::Variant::Error)
    .on_acknowledge(reporting("acknowledged", cx))

Four variants — error, info, success and warning — each with its glyph. The title is optional and takes the emphasis when it is there: the web drops the message to regular weight under a bold title, and leaves it bold when it stands alone. The dialog is a Modal with one button, so everything the Dialogs section says about hosting one applies here too.

The ink follows the fill, not the mode. The web names shades directly — a 50 fill, a 500 border, 700 ink — and dark cannot flip those shade by shade: 700 ink on a 700 fill is no ink at all. So the fill re-anchors to the scale's deep end and color::readable_on picks the ink for whatever fill it landed on: the web's own 700 over the pale one, near-white over the deep one. Both ends clear 4.5:1, and a test holds them there. The border is the vivid mid, which color::dark leaves alone, so the frame is one color in either mode — a hairline against its own fill, as it is on the web, and 5:1 and better against the page.

The timeout is armed once, on the frame the banner first appears. gpui renders a widget many times over and a timer per frame would be a stampede, so a time or a handler changed after that frame is not re-read: draw it under a new id to re-arm, which is what remounting does to the web's effect. auto_hide decides whether the timeout also hides the banner; on_timeout reports either way, like the web's onTimeOut.

A banner owns one piece of state, whether it is still up, so a dismissal or a timeout needs no flag in the app. An app that wants the banner gone on its own terms stops drawing it, which is why the web's show prop has no counterpart.

What the web has and this does not: the notification and message-banner components built beside it, and toasts stacked in a corner. Each is additive on what is here.

Pictures

CloudImage resolves an illustration by Cloudinary id, out of the kit the Icons section describes. ImageView is the other half: a picture the app already holds, which is what evidence is — bytes the platform API returned, or a file the pentester just chose.

ImageView::new(evidence_id)
    .bytes(ImageFormat::Png, response)
    .alt("The account page, with the session token in the query string")
    .width(120.0)
    .on_click(open_the_viewer)

It fetches nothing, writes no file and logs nothing. The caller hands over bytes it already has and gpui decodes them in memory. Evidence can show a customer's systems, so the widget touching neither disk nor network is a property worth stating rather than a coincidence of the implementation. gpui does keep the decoded frames in its process-wide asset cache, keyed by the bytes themselves; an app's own cache of encoded evidence, keyed by identifier, sits above that. Signals has one, and a second cache inside a widget rebuilt every frame would only fight it.

Sizing is the web's FilePreview: width and height are the box, the picture keeps its aspect ratio inside it, and an axis left unset is computed from the other — which is what height: auto means there.

opacity on the web is not the element's opacity. It is the alpha of a black wash the preview paints over the picture, defaulting to none, so it is called scrim here: a port that read the name at face value would fade the evidence into the page instead of dimming it.

Three things stand in for a picture

No source at all is the caller still fetching, and shows the Loading spinner. Bytes that will not decode show a notice box, because a blank square in a list of evidence reads as evidence that is not there. That box is an Alert's error palette at the size of the slot the picture would have filled — the same object the banner is, so it needs no severity vocabulary of its own.

A video is the third. tokens::image_view::file_type carries the web's extension table, so a caller can tell a .webm from a .png before choosing a constructor, but gpui has no video element and Signals does not play evidence inline either — it hands the file to the desktop. So video() says so on screen rather than painting a square that reads as missing evidence.

The viewer

A 120px thumbnail is for finding the right picture, not for reading one, so a press opens ImageViewer:

let picture = ImageView::new(id).bytes(ImageFormat::Png, held);
ImageViewer::new("viewer", name, picture)
    .on_close(cx.listener(|this, _, _, cx| this.close_viewer(cx)))

The view only reports the press. Everything the Dialogs section says about hosting a modal applies to the viewer, and a thumbnail sits in exactly the place a dialog may not be raised from: inside the scroll box, with siblings painted after it. So the app keeps the flag and draws the viewer where its other dialogs live.

How big the picture gets is the viewer's decision, not the caller's: it takes image_view::VIEWER_WIDTH_FRACTION of the window, which is a little less than the Lg dialog around it, because the dialog spends the difference on its header and the body's insets. A test holds the picture inside that box across the window sizes a desktop gets. Hand the viewer a source, then — a width or a height set on the view it is given is replaced.

The web has no viewer to mirror. Its own image modal is a 185px banner across the top of a dialog, decoration rather than a way to read evidence, so both the component and its sizes are desktop decisions over the modal's tokens.

What the web has and this does not

The <video controls> player and the link around it, for the reason above. alt has no accessibility sink in gpui — there is no tree to write it into — so it becomes a Tooltip on the picture, which is the one place a desktop toolkit can put a description today.

Out of scope until something asks for them: zoom, pan, annotation, and stepping from one picture to the next. Signals uses none of the four.

Theme and modes

Theme::init(mode, cx, faces) installs one of two tables, theme::LIGHT or theme::DARK, as a gpui global. Widgets read it at render time, so switching is a re-init and a repaint — call it again with the other mode and nothing below has to be told:

let next = cx.theme().mode.flipped();
Theme::init(next, cx, &[]);
window.refresh();

The dark table is the light one re-anchored by color::dark, which pairs each shade with the one that holds the same contrast against a dark surface rather than flipping its index — the ramp is not perceptually symmetric, so an index flip turns quiet fills loud. Text lands one step brighter than parity, which is where dark UIs have to sit to read as crisply as a light one.

Two kinds of color reach a widget, and only one of them is resolved on the way to rgb:

  • Roles (cx.theme().colors.text) and anything a function taking a &theme::Colors hands back (button::colors, tabs::active_border, tag::colors) are already in the active mode. Paint them as they come.
  • Component tokens that name a palette shade — a table's header fill, a menu's hover row — are written for the light surface. Pass them through cx.theme().shade(..) first.

Resolving twice is a bug: the second pass walks the mirror again and lands on the wrong end of the ramp. Reach for a role whenever one says what you mean, and for shade only where the role vocabulary has no step that fine.

A color an app hands a widget splits the same way, and the line is what the color lands on:

  • A fill on a surface the design system owns — TableState::set_row_color, set_cell_color — is re-anchored like any other fill. Name the shade the light surface wants and the widget resolves it, so one call is right in both modes. Where the fill then has to carry text, color::readable_on picks the ink from the fill rather than from the surface, because an app may hand over a shade the mirror leaves alone.
  • A content color the app paints on a ground it chose — Link::color, Icon::color, loading::Color::Custom — is left exactly as handed over. The design system does not know what is behind it and must not guess.

cargo run --example gallery carries the switch in its header, so every widget can be reviewed in both appearances.

Fonts

nix build .#designs-gpui-fonts materializes the design system's faces (Roboto, Space Mono) hermetically, and the gpui dev shell exports the directory as FLUIDATTACKS_DESIGNS_FONTS for the gallery to read.

Delivering the faces is the app's job: Theme::init(mode, cx, faces) registers the bytes you hand it and reads no directory of its own. Bundling and signing font files is a packaging concern, and a library that resolved them from a path would force every consumer to reproduce this flake's environment. Pass &[] to accept the platform fallback.

An app bakes them in the way it bakes glyphs, from a build script:

fluidattacks_designs_domain::build::emit_fonts()?;

That reads every .ttf under FLUIDATTACKS_DESIGNS_FONTS and writes a FACES table to include, so Theme::init(mode, cx, &fonts::FACES) needs no directory at runtime. Unlike the glyphs these faces carry no licence keeping them out of the nix store, so a hermetic build can be handed the path too — pass the package through extraAttrs and the store binary gets the type as well.

Icons and illustrations

Glyphs are FontAwesome Pro, so they live neither in git nor in the nix store (whose cachix mirror is public) and cannot ship inside the published crate. Illustrations carry no such licence, but they are fetched rather than versioned, so they are absent from a checkout just the same. Assets resolves either from FLUIDATTACKS_DESIGNS_ICONS / FLUIDATTACKS_DESIGNS_IMAGES when those directories are present — the dev shell fetches the whole kit and the illustration list there — and otherwise from a table the app baked in at compile time.

To embed, a consuming app adds a build script:

fluidattacks_designs_domain::build::emit_assets(
    &[(Style::Solid, &["gear"])],
    &["integrates/empty/analyticsIcon"],
)?;

which fetches nothing itself: it copies bytes out of FLUIDATTACKS_DESIGNS_ICON_SRC and FLUIDATTACKS_DESIGNS_IMAGE_SRC, and fails loudly if an asset is absent. It always includes icon::REQUIRED_ICONS and empty_state::REQUIRED_IMAGES, the assets these components render themselves, so the widgets work without the app enumerating them. The result is a binary that needs no token, no network and no cache at runtime, which is what makes a relocatable signed bundle possible. A miss at runtime is an Err, not a blank square — so both kinds need embedding, not just glyphs.

Both directories come from this flake, so a consuming component does not reimplement the fetch or hard-code a cache path:

# the consumer's flake input, pinned like any other cross-component one
designs.url = "git+ssh://git@gitlab.com/fluidattacks/universe?rev=<sha>&dir=designs";
# the consumer's dev shell: same script for both variables, so a glyph the app
# renders in development is a glyph it can bake in
ICONS="$(designs-gpui-icons-fetch 2>/dev/null || true)"
export FLUIDATTACKS_DESIGNS_ICONS="$ICONS" FLUIDATTACKS_DESIGNS_ICON_SRC="$ICONS"

nix run .#designs-gpui-icons-fetch and .#designs-gpui-images-fetch each print their cache directory only while that cache holds assets, so the empty output a sandbox gets is what makes || true above degrade instead of exporting a path to nothing. A warm cache without network still prints, because the cached copies stay valid.

File picker

Not a component: asking the reader for a file is the host operating system's own dialog. There is no web counterpart to mirror and nothing to style — a design-system dialog would look wrong beside every other macOS app and would throw away the sandbox and permission behaviour the native one carries. What the design system owns is the shape of the request, so an app never reaches into the platform itself:

let request = Request::new(Selection::File)
    .extensions(&["*.csv"])
    .title("Select CSV file");
let picked = file_picker::open(&request, cx).await;

Three selections cover every flow the consuming apps have: File, Files and Directory. Extensions are accepted however a caller writes them (csv, .csv, *.csv), because the calls being ported arrive from Qt filter strings. A Directory request may start_in the path a field already holds, and an empty one falls back to wherever the reader last browsed rather than failing — that field is empty on a first run.

Cancelling is an outcome, not an error: Picked::Cancelled. A Paths always holds at least one path, so a change of mind is one case everywhere rather than an empty list each caller checks for differently.

The filter is a browser affordance, not validation — it decides what the reader can click, not what the path holds. Check type and size on the opened handle; a path can be a symlink, a device node, or swapped out between the panel closing and the file opening. And because paths come back through NSString, a name macOS keeps as bytes but cannot render as UTF-8 is dropped rather than mangled.

The panel is NSOpenPanel reached through objc2, not gpui's prompt_for_paths, whose PathPromptOptions carries neither an extension filter nor a starting directory — the two things these flows ask for. objc2's typed bindings mark those methods safe, so this needs no unsafe, the same trade common/mrq/gpui-mac makes for the AppKit calls gpui does not expose. There is no save-path prompt, no drag-and-drop and no in-app browser; nothing asks for them yet.

A native dialog cannot be driven from a headless test — gpui's test platform leaves prompt_for_paths unimplemented!() — so the request and outcome values carry the unit tests, and the call into AppKit is reviewed by hand through the gallery's picker row on macOS.

Checks

Same jobs as every Rust component, from the designs flake:

  • nix run .#designs-gpui-lint — fmt, clippy, deny, machete
  • nix run .#designs-gpui-conformance — federated config drift check
  • nix run .#designs-gpui-test — nextest behind the coverage ratchet
  • nix develop .#gpui — the Rust dev shell