{"id":"hurreman/roblox-ui-kit","name":"roblox-ui-kit","scope":"hurreman","platform":"roblox","description":"Shared React UI kit for hurreman's Roblox games: themeable components (flat or 9-slice surfaces), motion, panel host, gamepad selection, toasts and store hooks","version":"0.2.0","latest":"0.2.0","versions":["0.1.0","0.2.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{"jsdotlua/react":{"version":"^17.2.1","alias":"React"},"jsdotlua/react-roblox":{"version":"^17.2.1","alias":"ReactRoblox"}},"integrity":"438b0567d3a341c582849eb96a0895a9ac99975703c916657cbe51eb8e54a67e","likes":0,"downloads":0,"install":"forest install hurreman/roblox-ui-kit","url":"https://forest.dev/p/roblox/hurreman/roblox-ui-kit","files":"https://api.forest.dev/ai/package/roblox/hurreman/roblox-ui-kit/files","readme":"# roblox-ui-kit\n\n`hurreman/roblox-ui-kit` is the shared React UI kit for hurreman's Roblox games (Magical Neighborhood, Slime Nebula). It's built on jsdotlua React 17.2.1.\n\nIt gives you:\n- **Themeable components.** Each game keeps its own look, and a surface can be a flat colour or 9-slice image art.\n- **Motion:** UIAnimator, staggers and the panel pop.\n- **A panel host** with focus mode and gamepad selection.\n- **Toasts**, driven by a push API.\n- **Store hooks** that work with any change-signalling client store.\n\nMost of it comes from Magical Neighborhood's `src/client/UI`. The rest is new: Tabs, ProgressBar, virtualised List/Grid, Tooltip, selection groups, Viewport, PanelHost and the state helpers.\n\n## Install\n\n`wally.toml`:\n\n```toml\n[place]\nshared-packages = \"game.ReplicatedStorage.Packages\"\n\n[dependencies]\nUIKit = \"hurreman/roblox-ui-kit@0.1.0\"\nReact = \"jsdotlua/react@17.2.1\"\nReactRoblox = \"jsdotlua/react-roblox@17.2.1\"\n```\n\nRun `wally install`, then map `Packages` into `ReplicatedStorage` in the Rojo project. The kit finds React next to itself (Wally links it), or else in `ReplicatedStorage.Packages`.\n\n## Quick start\n\n```lua\nlocal Kit = require(ReplicatedStorage.Packages.UIKit)\nlocal React, ReactRoblox = Kit.React, Kit.ReactRoblox\nlocal e = React.createElement\n\nKit.configure({ extends = Kit.Presets.Base, colors = { accent = Color3.fromRGB(80, 220, 200) } })\n\nlocal panels = Kit.PanelRegistry.new()\npanels:register(\"Rewards\", { component = RewardsPanel, animatesClose = true })\n\nlocal function App()\n\tlocal scale = Kit.useViewportScale()\n\treturn e(\"Frame\", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1 }, {\n\t\tScale = e(\"UIScale\", { Scale = scale }),\n\t\tTips = e(Kit.Tooltip.Provider, {}, {\n\t\t\tPanels = e(Kit.PanelHost.Host, { registry = panels }),\n\t\t\tToasts = e(Kit.Toasts.View, { feed = toasts }),\n\t\t}),\n\t})\nend\n```\n\nUse one root: a single `ReactRoblox.createRoot` in a `ScreenGui` with `ResetOnSpawn = false` and `ZIndexBehavior = Sibling`.\n\nEverything on `Kit` loads the first time you touch it. So `require` is cheap, and a game only pays for the parts it uses.\n\n## Theming\n\nThere's one current theme per client. `Kit.configure(theme)` merges a partial theme over the theme it `extends`. When `extends` is left out, that's `Kit.Presets.Base`.\n\n`configure` checks the result straight away. A missing required token, an unknown colour key, or a surface or text role that doesn't resolve raises an error then, not halfway through a render.\n\n```lua\nKit.configure({ extends = Kit.Presets.MagicalNeighborhood, rarity = HouseTiers.RarityColors }) -- Magical Neighborhood\nKit.configure(SlimeNebulaTheme)                                                               -- a game's own table\n```\n\nMerge rules:\n- Sections merge key by key.\n- A **surface token replaces** the token of the same name. A 9-slice panel doesn't inherit the flat panel's stroke and corner.\n- `false` counts as a value, so `stroke = false` means no outline.\n\nComponents that call `Kit.useTheme()` re-render when `configure` runs again. Plain-function primitives (`Kit.Text`, `Kit.Panel`, `Block.Surface`) pick up the new theme on their parent's next render.\n\nTo follow a runtime theme, as Slime Nebula's `Theme.lua` does with its `Theme.Changed` signal, call `configure` from that signal:\n\n```lua\nlocal function apply() Kit.configure(SlimeNebulaTheme.toKit()) end\napply()\nSlimeNebulaTheme.Changed:Connect(apply)\n```\n\n### Sections\n\n| Section | What it drives |\n|---|---|\n| `colors` | Named colours. The kit reads these semantic keys: `text`, `textDim`, `stroke`, `panel`, `panelLight`, `accent`, `positive`, `negative`, plus `warning`, `close` and `disabled` from Base. Any other keys are the game's own palette. A string value is an alias: `accent = \"gold\"`. |\n| `fonts` | `Font` values by key. `display` and `body` are required. |\n| `text` | Text roles for `Kit.Text { role = ... }`. Each role is `{ font = key, color? = key, strokeColor?, strokeSize?, maxSize? }`. Base has `display`, `title`, `body`, `stat`, `label` and `dim`. An unknown role falls back to `display`. |\n| `space`, `radius` | Scales: `xs` to `xxl`, `sm` to `pill`. Wherever a theme or a component takes a size, it takes a number or one of these keys. |\n| `surfaces` | Surface tokens (below). Required: `panel`, `button`, `tab`, `tabActive`, `tooltip`, `track`, `fill`, `toast`. |\n| `block` | `variant` (`\"flat\"`, `\"studded\"` or `\"image\"`), `studTile`, `face` (the image variant's surface), `lip`, and `style` (the Block.Style numbers). |\n| `window` | Window's layout constants: ribbon height, title icon, close button, padding, fit margins, `dim`, `panelColor`. `panelSurface` draws the panel from a surface, such as a 9-slice frame, instead of a block. `closeImage` draws the close button as one image (hover grows it, a press dips it) instead of a block in `closeColor`. |\n| `dialogue` | DialogueBox and GuideBubble colours: `panel`, `nameplate`, `choice`, `highlight`, `disabled`, `close`, `portrait`. Also `charsPerSecond`. |\n| `toast` | `kindColors` (`kind` → colour), `defaultColor`, `band`, `top`, `maxWidth`. |\n| `rarity` | Rarity name → colour, used by ToastItem. Games pass their own ladder. |\n\n`Kit.Theme` also exposes:\n- `color(ref)`, `space(v)`, `radius(v)`, `surface(ref, over?)`, `role(name?)`, `current()`, `onChanged(fn)`.\n- `Colors` and `Fonts`: live mirrors with every colour resolved.\n- `Resolve`: the pure resolver.\n\n### Surfaces\n\nA surface token is either **flat** or **image (9-slice)**. Every surface name and every `surface` prop takes either kind.\n\nFlat:\n\n```lua\npanel = {\n\tcolor = \"panel\",                       -- a colour key or a Color3\n\ttransparency = 0,\n\tcorner = \"md\",                         -- px or a radius key\n\tstroke = { color = \"stroke\", thickness = 2, transparency = 0 },\n\tgradient = { rotation = 90, top = 0.12, bottom = 0.12 }, -- the fill lightened/darkened (or colors = { a, b })\n\tpadding = \"sm\",                        -- adds a UIPadding\n}\n```\n\nImage (9-slice). Setting `image` makes a token an image surface:\n\n```lua\nKit.configure({\n\tsurfaces = {\n\t\tpanel = {\n\t\t\timage = \"rbxassetid://1234567890\",   -- the frame art\n\t\t\tsliceCenter = Rect.new(48, 48, 208, 208),\n\t\t\tsliceScale = 0.5,                    -- shrink the corners for a 2x texture\n\t\t\timageRectOffset = Vector2.new(0, 0), -- optional: one cell of a sprite sheet\n\t\t\timageRectSize = Vector2.new(256, 256),\n\t\t\ttint = \"panel\",                      -- ImageColor3 (colour key or Color3)\n\t\t\tpadding = 14,                        -- keep content off the art's border\n\t\t},\n\t\twindow = { extends = \"panel\", sliceScale = 0.75 }, -- inherit, then tweak\n\t\tbuttonPrimary = { extends = \"panel\", tint = \"accent\" },\n\t},\n\tblock = { variant = \"image\", face = { image = \"rbxassetid://...\", sliceCenter = Rect.new(20, 20, 44, 44), sliceScale = 1 } },\n\twindow = { panelSurface = \"window\" },\n})\n```\n\nHow image surfaces behave:\n- `scaleType` is `\"Slice\"` when `sliceCenter` is set, and `\"Stretch\"` otherwise.\n- An image surface has no fill behind the art unless you give it a `color`.\n- When a component passes a `color`, it **tints** an image surface and **fills** a flat one. So `Block { color = ... }` works with both kinds.\n- A token can inherit from another with `extends = \"<surface name>\"`. A flat child of an image parent drops the image.\n\nTo draw a surface yourself: `Kit.Surface.render { surface, over?, color?, tint?, size, onClick?, sink?, native?, children }`, or `e(Kit.Surface.Surface, ...)`, which forwards `ref`.\n\n### Presets\n\n| Preset | Look |\n|---|---|\n| `Base` | Neutral dark: BuilderSans, flat surfaces, flat blocks (no studs). It's the default. |\n| `MagicalNeighborhood` | Magical Neighborhood's current look: LuckiestGuy + Nunito, the purple panels, the studded blocks, and its Window and dialogue colours. Its old palette keys (`gold`, `orange`, `green`, `red`, `purple`, `white`, `dim`) are kept as aliases of the semantic ones. |\n\nThe stories add a third, story-only theme, **NineSlice**, with every surface made of 9-slice art. Paste your own art into its `SliceImage` control.\n\n## Components\n\nAll views are pure: no remotes, no game modules. Data and callbacks come in as props.\n\n| | |\n|---|---|\n| `Kit.Text { text, role?, size?, color?, strokeSize?, maxSize?, rich?, textSize?, autoHeight?, alignY?, ... }` | Outlined text in a text role. By default it is scaled to fit its box (titles, stats, labels). For multi-line body copy give `textSize`: a fixed size that wraps; add `autoHeight` (with a height-0 `size`) to grow the box with the text. `Kit.Theme.stat(\"+30%\", \"money\")` makes rich text with the figure in a heavier weight. |\n| `Kit.Panel { surface? = \"panel\", color?, radius?, strokeSize?, onClick?, sink?, ... }` | A themed panel. `sink = true` swallows clicks. |\n| `Kit.Block.Surface / Kit.Block.Button { color, icon?, label?, layout?, variant?, onClick?, disabled?, disabledColor?, ... }` | The chunky block (lip, sink on press, icon hover). A disabled button ignores hover, press and clicks and turns the theme's `disabled` colour (`disabledColor` overrides it, `false` keeps the normal colour). `Kit.Block.Style` holds the live `theme.block.style`. |\n| `e(Kit.Window, { title, icon?, color?, size: Vector2, onClose, subtitle?, header?, dim?, closeOnBackdrop?, closing?, closeTo? }, children)` | The modal shell. It pops in, and closing plays a wind-up and shrink before `onClose` runs. It also shrinks itself to fit small screens. `subtitle` adds a dim caption line at the top of the body (children go below it). |\n| `Kit.Backdrop.element { dim?, onActivated?, ref?, zIndex? }` | The modal dim. It overshoots the safe area. |\n| `Kit.IconLabel { image, text, size, ... }` | An icon and text laid out as one group. |\n| `e(Kit.PointArrow, { position, label?, rightEdge?, bob? })`, `e(Kit.Spotlight, { hole, label?, bob? })` | Onboarding pointers. |\n| `Kit.Burst.emit(parent, origin, { glyphs, colors?, count?, distance?, gravity? })` | Fire-and-forget particle bursts. |\n| `e(Kit.GuideBubble, { lineId, speaker, text, portrait?, fallbackIcon?, onDismiss })` | The tutorial guide's speech bubble. |\n| `e(Kit.DialogueBox, { speaker, text, nodeId, choices: { ChoiceView }, onChoose, onClose })` | NPC dialogue. Each `ChoiceView` is `{ Index, Text, Disabled?, DisabledReason?, Highlighted? }`. |\n| `e(Kit.Tabs, { tabs = { { id, label, icon?, badge? } }, selected, onSelect, vertical?, ... })` | A tab strip. L1 and R1 cycle the tabs on a controller. |\n| `e(Kit.ProgressBar, { value, tween?, label?, ticks?, vertical?, track?, fill?, fillColor? })` | `value` is a number or a **binding**. A binding moves the fill every frame with no re-render. |\n| `e(Kit.List, { items, itemHeight, renderItem, keyOf?, overscan? })`, `e(Kit.Grid, { items, cellSize, renderItem, columns?, stretch?, gap? })` | Virtualised scrolling: only the rows in view are mounted. |\n| `e(Kit.Tooltip.Provider, ...)`, `Kit.Tooltip.use(content)`, `Kit.Tooltip.merge(handlers, props)`, `Kit.Tooltip.Bubble` | Tooltips. They follow the mouse, or sit above the selected element on a gamepad. |\n| `e(Kit.ErrorBoundary, { name, onError?, fallback? }, children)` | Catches a render error in its subtree: warns, calls `onError` once (deferred), and shows `fallback` (default nothing). A remount starts fresh. PanelHost wraps every panel in one. |\n| `e(Kit.Viewport.View, { model, yaw?, pitch?, zoom?, fov?, spin?, surface? })`, `Kit.Viewport.pool`, `Kit.Viewport.Pool.new` | Model previews framed to fit. Clones are pooled per template, and spin runs through a ref. |\n| `Kit.Toasts.new(config?)`, `toasts:push / notify`, `e(Kit.Toasts.View, { feed, paused?, focus?, top?, rarityColors?, renderAnnouncement? })`, `e(Kit.ToastItem, ...)` | Notifications (below). |\n\n### Toasts\n\nThe queue (`Kit.Toasts.Queue`) has no store wiring. The game pushes into it:\n\n```lua\nlocal toasts = Kit.Toasts.new({\n\tkindTimes = { hatch = 4.2 },              -- fixed durations per kind\n\tfocusKinds = { tutorial = true },         -- what may interrupt focus mode\n\tuncollapsedKinds = { hatch = true },      -- every one gets its own turn\n\tstill = function(about) return ToastSubjects.still(about) end, -- reminders that go stale\n})\nNet.event(\"Notify\").OnClientEvent:Connect(function(kind, text, rarity, about)\n\ttoasts:notify(kind, text, { rarity = rarity, about = about })\nend)\n```\n\nThese are Magical Neighborhood's rules:\n- Announcements show one at a time, sized to how long the text takes to read. A repeat collapses into the one before it as ×N.\n- Feedback kinds (`error`, `collect`) show immediately, beside any announcement.\n- `paused` holds announcements back. `focus` lets only `focusKinds` through, and when focus ends, only the fresh ones are played.\n- A reminder (a toast with `about`) is dropped, or leaves early, once `still(about)` returns false.\n\n`renderAnnouncement(item, leaving)` draws special kinds yourself, such as Magical Neighborhood's hatch card.\n\n## Panels, focus and gamepad\n\n```lua\nlocal panels = Kit.PanelRegistry.new()\npanels:register(\"Shop\", { component = ShopPanel, animatesClose = true })    -- focus = true by default\npanels:register(\"Map\", { component = MapPanel, focus = false, selection = false })\npanels:open(\"Shop\", { tab = \"gems\" }, \"dock\")   -- args, source\n```\n\n`e(Kit.PanelHost.Host, { registry = panels, focusMode? = true, blur? = true, panelProps? })` renders the one open panel. Each panel gets `{ name, args, closing, onClose, close, requestClose }`. Pass `closing` and `onClose` through to `Kit.Window`. To close from inside the panel (e.g. after a successful action), call `requestClose()`: it plays the close animation. `close()` skips it.\n\nWhen a panel with `animatesClose = true` is replaced or closed, it stays mounted with `closing = true` until its close animation calls `onClose`.\n\n**Errors stay contained.** Each panel renders inside a `Kit.ErrorBoundary`. A panel that throws while rendering is warned about (`[roblox-ui-kit] panel <name> crashed while rendering`), closed in the registry with source `\"error\"` and unmounted. Other panels and the root keep running. Wrap other independent regions (HUD widgets, world boards) yourself: `e(Kit.ErrorBoundary, { name, onError?, fallback? }, children)`.\n\nWhile a panel with `focus` is open, `Kit.FocusMode` blurs the world and hides the touch controls. Each panel sits in a `Kit.SelectionGroup`.\n\nHooks: `Kit.PanelHost.usePanel(registry) -> name, args` and `Kit.PanelHost.useIsOpen(registry, name)`.\n\n**Bridging to a game's modal bus.** Every change carries a `source`, so a bridge can ignore the changes it made itself. Slime Nebula's `UnifiedModal` / `ActionEvents` would bridge like this:\n\n```lua\npanels:onPanelChanged(function(name, previous, change)\n\tif change.source == \"bus\" then return end           -- we did that: don't echo it back\n\tif previous then ActionEvents.ModalClosed:Fire(previous) end\n\tif name then ActionEvents.ModalOpened:Fire(name) end\nend)\nActionEvents.ModalOpened.Event:Connect(function(legacyName)\n\tif not panels:get(legacyName) then panels:close(nil, \"bus\") end -- a legacy panel opened: close ours\nend)\n```\n\n`e(Kit.SelectionGroup, { active?, default?, restore?, gamepadOnly?, confine? }, children)`:\n- **Confines** D-pad navigation to the group, using `SelectionGroup` and `SelectionBehavior = Stop`.\n- **Selects a default** when the group opens: a ref, a descendant's name, or the top-left selectable.\n- **Restores** the earlier selection when it closes. Nested groups restore in order (`Kit.Selection.Stack`).\n\nBy default it only takes the selection when the player is on a controller. In Studio, set the workspace attribute `ForceGamepad = true` to test without a pad.\n\n`Kit.Input` has:\n- `prefersGamepad()`, `prefersKeyboard()`, `touchControlsShown()`, `mode()`\n- `padGlyph(keyCode?)`\n- `onModeChanged(fn)`, `useInputMode()`\n- `useViewportScale({ design?, min?, max? })`\n\n`Kit.TouchButtons.place(action, \"left\" | \"above\")` puts ContextActionService buttons next to the jump button.\n\n## State hooks\n\n| | |\n|---|---|\n| `Kit.useSignal(store","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/hurreman/roblox/roblox-ui-kit/0.2.0/readme"}