# 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](https://crates.io/crates/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`:
```rust
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][tanstack], 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.
```rust
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,
)
});
```
```rust
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:
```rust
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:
```rust
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:
```rust
// `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`:
```rust
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:
```rust
which opens the rest:
| 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:
```rust
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:
```rust
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.
```rust
// the handler outlives the frame that built it, so it owns a clone
let table = self.table.clone();
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.
[tanstack]: https://tanstack.com/table
## 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.
```rust
.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:
```rust
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.
```rust
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.
```rust
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:
```rust
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:
```rust
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.
```rust
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`:
```rust
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:
```rust
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:
```rust
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:
```rust
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:
```nix
# 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";
```
```bash
# 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
```
`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:
```rust
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