Skip to content
Back to skills

Ratatui Kit

ASecurity

Build polished, component-driven terminal UIs (TUIs) in Rust with the ratatui-kit framework — React-style components, hooks, props, the element! macro, routing, and global state, on top of Ratatui + Tokio. Use this skill whenever the user wants to build, scaffold, redesign, or extend a terminal / console / CLI user interface, dashboard, wizard, picker, or interactive command-line app in Rust, OR mentions ratatui-kit, the element! macro, #[component], use_state / use_atom / use_event_handler, ...

  • 57 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
toolsrustgoshellbashreactnodeexpressawsgitapi

Works with

  • cursor
  • terminal
  • cli
  • api

Security analysis

A100/100

Pro scans all 8 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add yexiyue/ratatui-kit --skill ratatui-kit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Ratatui Kit?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Ratatui Kit
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yexiyue-ratatui-kit/badge)](https://www.skillsdirectory.com/skills/yexiyue-ratatui-kit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: ratatui-kit
description: >-
  Build polished, component-driven terminal UIs (TUIs) in Rust with the
  ratatui-kit framework — React-style components, hooks, props, the element!
  macro, routing, and global state, on top of Ratatui + Tokio. Use this skill
  whenever the user wants to build, scaffold, redesign, or extend a terminal /
  console / CLI user interface, dashboard, wizard, picker, or interactive
  command-line app in Rust, OR mentions ratatui-kit, the element! macro,
  #[component], use_state / use_atom / use_event_handler, RouterProvider, or asks
  to "make a TUI", "build a terminal app", "add a screen/panel/widget", or "wire
  up keyboard or mouse input" in a ratatui-kit project — even if they don't name the
  framework explicitly. Reach for it before writing ratatui-kit code by hand, so
  the output uses correct APIs and idioms instead of guessed ones.
license: MIT
metadata:
  author: yexiyue
  framework: ratatui-kit
  version: "1.1.0"
---

# ratatui-kit — Building Terminal UIs in Rust

[ratatui-kit](https://github.com/yexiyue/ratatui-kit) is a component framework for
terminal UIs, built on [Ratatui](https://ratatui.rs) and [Tokio](https://tokio.rs).
If you know React, the mental model transfers almost 1:1: `element!` is JSX,
`#[component]` is a function component, `use_state` is `useState`, `use_future` is
an async effect, props and context work the same way, and there is a built-in
router and global store.

**This framework is niche — do not generate its code from memory.** The macro
syntax, component props, hook signatures, and the input system are specific and
easy to get subtly wrong. Use the minimal template below plus the reference files
in `references/` as your source of truth. A wrong prop name or a hook in the wrong
place is a hard compile error, not a warning.

**Companion skills.** This skill covers the *framework* and assumes general Rust
competence — it does not re-teach ownership, `mut` bindings, borrow rules, or async.
Install **`rust-best-practices`** and **`rust-async-patterns`** alongside it; they
catch the general-Rust mistakes (e.g. a missing `mut` on a mutated binding) that
aren't specific to ratatui-kit. And whatever else you do, **compile before you call
it done** (see *Verifying*) — `cargo check` names every such trivial error precisely.

---

## The build workflow

When asked to build, scaffold, or extend a ratatui-kit UI, work in this order:

1. **Establish the project.**
   - New project: `cargo new <name>`, then add deps (see *Project setup* below).
     Decide which **features** you need — components are feature-gated and won't
     compile if the feature is off. When in doubt, start with `features = ["full"]`.
   - Existing project: check `Cargo.toml` for `ratatui-kit` and which features are
     enabled before using a gated component.

2. **Plan the component tree.** Sketch the screens and panels as `#[component]`
   functions. Decide **state ownership**: ephemeral, per-component state →
   `use_state`; state shared across distant components or pages → an `Atom`
   (`atom` feature). Multi-screen app → `RouterProvider` + `routes!`.

3. **Compose with `element!`.** Build each component's tree. Remember function
   components use **transparent layout**: put layout props (`width`, `height`,
   `flex_direction`, `gap`, …) on the **root element you return**, not on the call
   site of a function component.

4. **Wire input and lifecycle.** Register keyboard handlers with
   `use_event_handler`; use semantic pointer hooks for mouse interactions. Get the quit callback
   from `use_exit`. Ctrl+C exits through the framework by default; applications
   that need cancellation or double-press exit can explicitly route it through
   the central event dispatcher.

5. **Verify by compiling — this is the definition of done.** Run `cargo check` with
   the features you used, read the errors, and fix them; iterate check → fix → check
   until clean. Most first-compile failures are trivial and the compiler names them
   precisely (a missing `mut` binding, a wrong prop name, an un-enabled feature).
   See *Verifying* at the end. Examples in the repo (`examples/`) are runnable
   references for any pattern.

For anything beyond the essentials on this page, open the matching reference:

| You're working on… | Read |
| --- | --- |
| Which component to use, its exact props/types | `references/components.md` |
| Hook signatures, `State`/`AtomState` methods & operators | `references/hooks.md` |
| `element!` grammar, `#[component]`, `#[derive(Props)]`, `routes!`, adapters | `references/syntax-and-macros.md` |
| Click / hover / drag, pointer capture, Button, mouse setup | `references/pointer-events.md` |
| Keyboard events, input layers, state, routing | `references/events-state-routing.md` |
| App skeleton, layout & visual polish, interactive patterns, pitfalls | `references/building-polished-uis.md` |
| Every `Palette`/`*Theme` field, custom `ComponentTheme`, preset themes | `references/theming.md` |

---

## Project setup

`ratatui-kit` ships **zero features by default**; enable what you use. An async
runtime is required — the docs and examples use Tokio.

```toml
[dependencies]
ratatui-kit = { version = "0.12", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
```

> **New crate:** set `edition = "2024"` in `[package]` (the crate is edition 2024,
> so an older edition is rejected). If the crate sits inside or beside another Cargo
> workspace, add an empty `[workspace]` table to its `Cargo.toml` so Cargo doesn't
> attach it to the enclosing workspace.

| Feature | Unlocks |
| --- | --- |
| `router` | `RouterProvider` / `Outlet`, `routes!`, `use_navigate` / `use_route` / `use_params` |
| `atom` | `Atom` / `AtomState` / `use_atom` (process-global state) |
| `input` | `Input` / `SearchInput` (single-line text entry) |
| `tree` | `TreeSelect` |
| `table` | `Table` (data grid: cell-grid borders, wrapping, row/column highlight, footer) |
| `virtual-list` | `VirtualList` (windowed list for huge data) |
| `textarea` | `TextArea` (multi-line text entry with soft wrap) |
| `full` | all of the above |

Core components need **no feature**: `View`, `Border`, `Center`, `Fragment`,
`Text`, `WrappedText`, `Positioned`, `Modal`, `ConfirmModal`, `AlertModal`,
`ShortcutInfoModal`, `Button`, `Select`, `MultiSelect`, `ScrollView`, `ContextProvider`.

> `TextArea` is a full redesign on `tui-input` (not tui-textarea): soft-wrapped
> display rows, display-row Up/Down/Home/End, click-to-position (cursor stops
> before the clicked cell), `on_submit` fires on Enter in single-line mode.
> Value + cursor are caller-owned: feed `value` back through `on_change`.

Standard imports — `prelude::*` for framework items, `ratatui::` for styles/layout
types, `crossterm::event::` for key types:

```rust
use ratatui_kit::{
    crossterm::event::{Event, KeyCode, KeyEventKind},
    prelude::*,
    ratatui::{
        layout::{Constraint, Direction, Flex},
        style::{Color, Style, Stylize},
        text::Line,
    },
};
```

When you build multi-span styled lines, import `Span` too (`text::{Line, Span}`)
rather than writing the fully-qualified path: `Line::from(vec![Span::styled("label ",
Style::new().dark_gray()), Span::styled(value, Style::new().yellow())])`.

---

## Minimal app (copy this as your starting point)

This compiles and runs as-is. It shows the five things every app needs: an entry
point, a component, local reactive state, an async side effect, and a keyboard
handler with a quit key.

```rust
use ratatui_kit::{
    crossterm::event::{Event, KeyCode, KeyEventKind},
    prelude::*,
    ratatui::{
        layout::{Constraint, Direction, Flex},
        style::{Style, Stylize},
        text::Line,
    },
};

#[tokio::main]
async fn main() {
    element!(App)
        .fullscreen()                 // take over the whole terminal (alt screen)
        .await
        .expect("Failed to run the application");
}

#[component]
fn App(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let mut count = hooks.use_state(|| 0_u64);
    let mut exit = hooks.use_exit();

    // Async side effect: writing state wakes the render loop — no manual redraw.
    hooks.use_future(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
            count += 1;               // State<T> overloads += and notifies on write
        }
    });

    // Standard keyboard-handler shape: destructure Key → check Press → match code.
    hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
        let Event::Key(key) = event else { return EventResult::Ignored };
        if key.kind != KeyEventKind::Press { return EventResult::Ignored }
        match key.code {
            KeyCode::Char('q') | KeyCode::Char('Q') => { exit(); EventResult::Consumed }
            _ => EventResult::Ignored,
        }
    });

    element!(
        Center(width: Constraint::Length(48), height: Constraint::Length(9)) {
            Border(
                flex_direction: Direction::Vertical,
                justify_content: Flex::Center,
                border_style: Style::new().cyan(),
                top_title: Line::from(" ratatui-kit ").cyan().bold().centered(),
                bottom_title: Line::from(" q quit · Ctrl+C exit ").dark_gray().centered(),
            ) {
                Text(text: Line::styled(
                    format!("Counter: {:02}", count.get()),
                    Style::new().green().bold(),
                ).centered())
            }
        }
    )
}
```

There is also a non-fullscreen mode if you want the UI to share the scrollback,
but `.fullscreen().await` is the default for an app.

---

## Mental model (the parts that change the code you write)

- **Declarative & re-created each frame.** `element!` produces a lightweight tree
  description; the framework reconciles it against the live component tree. You
  describe *what the UI should be*, never imperatively draw.

- **Reactive, not imperative.** The UI only re-renders when **reactive state**
  changes: `use_state`'s `State<T>`, a global `AtomState<T>`, async writes, or a
  terminal event. A plain local variable will **not** trigger a redraw — anything
  the user should see must live in `use_state` or an `Atom`.

- **`#[component]` + transparent layout.** A `#[component] fn` becomes a component
  whose body runs each frame and returns one root element. Function components do
  **not** occupy their own layout node — they inherit the `LayoutStyle` of the
  root element they return. So layout props belong on that returned root, not on
  the tag where you *use* the component.

- **Reconciliation preserves state by identity.** A node is reused across frames
  when its `key` **and** component type match the previous frame — and reuse means
  its hooks/state survive. Give every element in a `for` loop a **stable `key:`**.

- **Hook order must be stable.** Hooks are indexed by call order. Never put a hook
  (`use_state`, `use_future`, `use_event_handler`, `use_atom`, …) inside an `if`,
  `for`, `match`, or early `return` — call them unconditionally at the top of the
  component body, or you get a "Hook type mismatch" panic.

---

## `element!` essentials

`element!` is JSX-like. A node is `Component(prop: value, ...) { children }`. Prop
values are auto-wrapped with `.into()`, so `text: "hi"`, `top_title: Line::from(..)`
(an `Option` field — auto-`Some`), and `wrap: true` all work. Omitted props fall
back to `Default`, so **every Props type must be `Default`**.

```rust
element!(
    View(flex_direction: Direction::Vertical, gap: 1) {
        Text(text: "first line")
        Text(text: "second line", style: Style::new().dark_gray())
    }
)
```

**Control flow is first-class in the children block** — `if` / `if let` / `for` /
`match` work directly, and different branches may return different element types:

```rust
element!(
    Border(flex_direction: Direction::Vertical) {
        if items.is_empty() {
            Text(text: "Nothing here", style: Style::new().yellow())
        } else {
            for (i, item) in items.iter().enumerate() {
                View(key: i, height: Constraint::Length(1)) {   // stable key in loops
                    Text(text: item.clone())
                }
            }
        }
    }
)
```

`{ expr }` embeds any Rust expression returning an `Element`, `Option<Element>`,
`Vec<AnyElement>`, or `impl Iterator<Item = Element>`. Bridge any native Ratatui
widget with `widget(expr)` (stateless) or `stateful(widget, state)` — your escape
hatch when no built-in component fits. Full grammar: `references/syntax-and-macros.md`.

---

## Handling input

The standard handler shape (used by every example) — destructure the key, gate on
`Press`, `match` the code, return `Consumed` when you handled the key and `Ignored`
otherwise so other handlers/layers still get a chance:

```rust
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
    let Event::Key(key) = event else { return EventResult::Ignored };
    if key.kind != KeyEventKind::Press { return EventResult::Ignored }
    match key.code {
        KeyCode::Char('j') | KeyCode::Down => { /* move down */ EventResult::Consumed }
        KeyCode::Char('k') | KeyCode::Up   => { /* move up   */ EventResult::Consumed }
        KeyCode::Char('q')                 => { exit(); EventResult::Consumed }
        _ => EventResult::Ignored,
    }
});
```

Modals and search boxes that must **capture** input (so the background stops
reacting) use an **input layer**. Prefer the built-in `ConfirmModal` / `AlertModal`
/ `ShortcutInfoModal`, which manage their own layer — you only pass `open` + a
callback. Roll your own layer only for custom modal interactions; see
`references/events-state-routing.md` for `use_input_layer` and the layering rules.

**Mouse integration.** Read [references/pointer-events.md](references/pointer-events.md)
before adding pointer behavior. Enable `RunOptions { mouse: true }` at the entry;
these APIs are core and require no feature flag. Choose the API by ownership:

- Built-in action → `Button` (left Down); release-to-activate → `use_click`.
- Region feedback/resizing → `use_hover` / `use_drag`; custom dispatch → `use_pointer`.
- Third-party/custom list → `use_hover_row(hit)`; custom scrollable content →
  `use_scrollbar(ScrollbarOptions, on_seek)` with one content unit throughout.
- ScrollView and TreeSelect scrollbars already support drag, track jump and arrow
  steps. Configure `ScrollbarStyle` once, including hover and drag feedback.

Keep hooks stable when props change; guard callbacks instead. A hand-written
`Component::update` must attach the updater's context stack before hooks.
`PointerEvent.position` and `area` use drawing coordinates; `screen_position`,
raw events, `Click.position`, and drag positions use terminal coordinates. Let
nested ScrollViews map and clip hits. Pass an explicit same-frame
`PointerOptions.layer` for editing/modal handlers. Handle drag cancellation by
clearing transient feedback. For custom lists, reject loading/empty/out-of-range
hits, release read guards before writing, and write hover state only on change.
Button does not provide keyboard focus or activation; register shortcuts for the
same business action separately.

**Custom Ctrl+C behavior.** The default remains immediate framework exit. To
implement agent cancellation, confirmation, or double-press exit, call
`SystemContext::set_auto_quit_on_ctrl_c(false)` from the root component and
register a `Global` handler for Ctrl+C. Return `Consumed` after handling it so the
event does not continue into the active layer. The exact borrow-safe pattern is in
`references/events-state-routing.md` §1.5.

**Text field that coexists with command keys.** When the user types into a field
*and* single-letter keys (`j`/`k`/Space/`q`) are commands, reach for **SearchInput**
— its `activate_key` opens an exclusive input layer while editing, so background
command keys stay live and you avoid a global Editing/Navigating mode. Build a manual
two-mode state machine, or forward every key to a raw `Input`, only when the whole
screen is a form with no competing single-letter commands.

**Shared-layer ordering.** A persistent shell/parent that shares an input layer with
child pages (or a `Select`) must return `EventResult::Ignored` for every key it
doesn't own — parents are dispatched before children in the same layer, so a blanket
`_ => Consumed` in the shell swallows the children's `j`/`k`/`Enter`. Give a child
that must pre-empt the shell `EventPriority::High`.

---

## State

- **Local:** `let mut count = hooks.use_state(|| 0);` → a `Copy` handle. Read with
  `count.get()` (for `Copy` types) or `count.read()`; write with `count.set(v)`,
  `count.write()`, or operators (`count += 1` notifies on write). Non-`Copy` data:
  `let list = hooks.use_state(Vec::new); list.write().push(x); let snap = list.read().clone();`

- **Global** (`atom` feature): declare a module-level `static`, subscribe in any
  component. Writes wake **only** the components subscribed to that atom.

  ```rust
  static SCORE: Atom<i32> = Atom::new(|| 0);

  #[component]
  fn ScoreCard(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
      let mut score = hooks.use_atom(&SCORE);   // subscribe
      // score.get(), score += 1, score.set(0) — all notify subscribers
      element!(Text(text: Line::from(format!("score: {}", score.get()))))
  }
  ```

**Binding mutability (framework API detail).** `State`/`AtomState` are `Copy` handles
whose `.set()` / `.set_no_update()` and operators (`+=` `-=` `*=` `/=`) take
`&mut self`, while `.get()` / `.read()` / `.write()` take `&self`. So a handle you
mutate must be `let mut`; defaulting every handle to `let mut` sidesteps the most
common trivial slip (an unused `mut` is only a warning). The general borrow rule
behind this is `rust-best-practices` territory — lean on that skill and on `cargo check`.

**Formatting a handle.** The handle itself is `Display`/`Debug` (`format!("{count}")`
works), but the guard from `.read()`/`.write()` is **not** — interpolating
`x.read()` into a `format!`/`Line` fails (`ReactiveRef doesn't implement Display`).
Format the handle directly, or deref the guard: `&*x.read()`, `x.read().clone()`,
or `x.read().as_str()`.

`State` and `AtomState` share the same methods/operators; full list in
`references/hooks.md`.

---

## Visual polish (principles, not a fixed style)

"Polished" here means **clear, consistent, and legible** — not a specific color
scheme. The framework gives you the tools; choose a palette and density that fit
the app's identity and the user's request. The repo's own examples gravitate
toward blue/cyan frames with `dark_gray` hint lines — treat that as *one coherent
option*, not a rule.

**Colors come from a theme, not scattered literals.** Every component derives its
styles from a shared `Palette`, and the default palette already gives a coherent
scheme — you get sensible colors without setting any style prop. Recolor the whole
app by tuning a `Palette` inside `PaletteProvider`; override one spot with a bare
`Style` prop (component theme overrides are `Option<Style>`, patched over the theme; configuration structs such as `ScrollbarStyle` use plain `Style` fields);
re-style one component type with `ThemeOverride::<FooTheme>(theme: ...)` (turbofish
required); or drive the `Palette` from an `Atom<Palette>` to switch themes at
runtime. If the user wants a named color scheme (Dracula, Nord, Catppuccin, ...)
rather than a hand-picked one, reach for the official `ratatui-kit-themes`
extension crate instead of hand-tuning `Palette` fields — see
`references/theming.md` §7. For every `Palette` field, every built-in
component's `*Theme` fields, and how to make your own component theme-aware,
see `references/theming.md`.

Principles, and the tools that achieve them:

- **Hierarchy** — separate primary from secondary with color + weight + alignment.
  Tools: `Stylize` (`.bold()`, `.green()`, `Style::new().fg(..)`), reverse-video
  highlights (`Style::new().black().on_cyan()`) for the selected row.
- **Whitespace** — let the UI breathe. Tools: `gap` between flex children,
  `margin` / `Padding`, sizing panels with `Constraint::Length`/`Fill`.
- **Alignment & framing** — fixed-width centered panels read as intentional. Tools:
  `Center`, `Border` with a `.centered()` `top_title`, `justify_content: Flex::Center`.
- **Orientation** — show users where they are and what they can press. Tools: a
  `bottom_title` hint line listing key → action (e.g. `" j/k move | Enter select | q quit "`).
- **State feedback** — distinguish loading / success / warning / error and the
  focused/selected element so the UI feels responsive. Tools: status-colored
  `Line`s, `active`/`highlight_style` props on `Select`/`MultiSelect`, `.dim()`
  backgrounds behind modals.

Concrete idioms, the standard app skeleton, and interactive-component patterns are
in `references/building-polished-uis.md`.

---

## Common pitfalls (each one is a real compile error or silent bug)

- **Hook inside `if`/`for`/`match`/early-return** → "Hook type mismatch" panic.
  Call hooks unconditionally at the top of the component.
- **A non-`mut` handle you later `.set()`/`+=`** → "cannot borrow as mutable" — the
  single most common trivial failure. Default to `let mut` on *every* `use_state` /
  `use_atom` / `use_exit` binding; an unused `mut` is just a warning.
- **`Select`/`MultiSelect` with no `default_index`** → nothing is highlighted on the
  first frame and `Enter` is a no-op until the user presses `j`/`k`. Set
  `default_index: Some(0)` whenever the first interaction is Enter-to-confirm.
- **Using a gated component without its feature** → "cannot find" errors. `Input`/
  `SearchInput` need `input`; `TreeSelect` → `tree`; `Table` → `table`; `VirtualList`
  → `virtual-list`; router items → `router`; `Atom` → `atom`. Develop with
  `--all-features` or `full`.
- **Layout props on a function component's call site** instead of its returned root
  → no effect (transparent layout). Put them on the root element the component returns.
- **`for` element without `key:`** → reconciliation reuses the wrong node; state
  leaks between rows. Use the loop index or a stable id.
- **Expecting a redraw from a plain variable** → nothing happens. Only `use_state`/
  `Atom`/async writes/events wake the render loop.
- **Storing an `InputLayer` across frames** → it's re-minted each frame; a stored
  handle goes deaf. Pass it within the same frame only.
- **`Block` is not `Send + Sync`** since Ratatui 0.30 — hold `Option<Block<'static>>`
  directly in props; don't reintroduce any `SendBlock` wrapper.
- **A `{ … }` right after a self-closing component is eaten as its children**, not a
  sibling embed. `SearchInput(props) { if x { Foo } }` makes the brace `SearchInput`'s
  *children block* — which parses `if`/`for`/`match` as first-class control flow over
  **element children**, so an `element!(…)` or arbitrary Rust expr inside yields a cryptic
  `expected identifier`. For a conditional **sibling**, use first-class control flow with
  **native** element children (no wrapping `{}`, no inner `element!(…)`/`.into_any()`):
  `SearchInput(props)` then `if x { Border(…){…} } else { TreeSelect<T>(…) }`.
- **`widget(w)` needs `for<'a> &'a w: Widget`** (renders by reference). Widgets that only
  impl `Widget` *by value* (e.g. `tui-big-text`'s `BigText`) fail with `&T: Widget is not
  satisfied`. Use a version that impls `WidgetRef` (→ `&T: Widget`), or wrap it:
  `struct W(BigText); impl Widget for &W { fn render(self, a, b){ self.0.clone().render(a,b) } }`
  then `widget(W(bt))`.

---

## Verifying

**Always** compile before declaring the task done — a clean `cargo check` is the
definition of done. Build with the features you actually used (gated modules don't
compile otherwise), read any errors, fix them, and re-check until clean:

```bash
cargo check --features full           # or your specific feature set
cargo clippy --all-targets --features full -- -D warnings
cargo run                             # run it; or `cargo run --example <name>` in the repo
```

Most first-compile failures are trivial and the compiler names them precisely — a
missing `mut` on a mutated `State` handle, a wrong prop name, or an un-enabled
feature. The repo has runtime, component, pointer-interaction, and macro UI tests.
For framework changes, run the repository's all-feature test matrix in addition
to compiling the application. If you scaffolded a binary, a successful
`cargo run` that draws the expected first frame confirms the wiring.

Files in this skill

  • SKILL.md24.3 KB
  • references/building-polished-uis.md27.6 KB
  • references/components.md36.4 KB
  • references/events-state-routing.md20.1 KB
  • references/hooks.md25.7 KB
  • references/pointer-events.md15.9 KB
  • references/syntax-and-macros.md16.2 KB
  • references/theming.md13.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.