wabi 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: abcbfe9f2785a854e687d5f992d6ceb32c4e4fb67ba0d081416ff4bd9b383988
4
+ data.tar.gz: 6706563e1af726d59eafc0dbee7020a1fe565ed51cde9846020dcd74239b97e3
5
+ SHA512:
6
+ metadata.gz: a367a2820c7e1276ce580cae7f577fca70d0f2f2c2f211a222f97dffc8920b1d4bbdfd3745c3ac76a921e654b74cf958b465d7fcd3501bbedfbe8aa474b8a387
7
+ data.tar.gz: c1de47b6186629b44e908fb3a364a564605a5b34c3551307057909fec013ae4e71b2c0a12f42e69d714f88a94a1f1ef38be755e4c1206a577edf668e469317e4
data/CHANGELOG.md ADDED
@@ -0,0 +1,157 @@
1
+ # Changelog
2
+
3
+ All notable changes to Wabi land here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [0.3.0] — 2026-05-26
6
+
7
+ Sprint 7 (docs site polish). The docs site stops being a one-page kitchen
8
+ sink and starts behaving like real documentation: marketing landing,
9
+ components index, detailed pages for the 4 exemplar components, and
10
+ prose docs for onboarding / theming / philosophy. Sprint 8 (v0.4) is
11
+ queued to fill in the remaining 16 component detail pages + Pagefind
12
+ search.
13
+
14
+ ### Added
15
+
16
+ - **`Components::Site::CodeBlock`** — Phlex `<pre><code>` wrapper with server-side Rouge syntax highlighting and a clipboard-copy button. Backed by a tiny `site--copy` Stimulus controller that hands the literal source (not the highlighted HTML) to `navigator.clipboard`.
17
+ - **`Components::Site::ComponentPreview`** — Tabbed Preview/Code view around an example. Block-provided live render in the Preview tab; literal source string rendered via CodeBlock in the Code tab. Backed by `site--preview-tabs` Stimulus controller that toggles panels and flips `data-active` on the tab buttons.
18
+ - **`ComponentsController` + `/docs/components` index** — Routes a components landing that lists all 20 v0.2 components with their manifest-derived descriptions. Cards for the 4 exemplars with detailed docs (Button / DropdownMenu / Dialog / Tabs) link out; cards for the remaining 16 show description + a "Source only" affordance until v0.4 fills them in. Nav header gains a "Components" link.
19
+ - **4 detailed component doc pages**: `/docs/components/button` (single-element variants), `/docs/components/dropdown_menu` (compound with submenu), `/docs/components/dialog` (overlay), `/docs/components/tabs` (in-flow navigation). Each ships: breadcrumb back-link, title, description, installation snippet, live `ComponentPreview` example, Source `CodeBlock(s)` reading the actual .rb files at request time, Accessibility bullets.
20
+ - **Marketing landing at `/`** — Hero ("Beautifully imperfect components for Rails") with Get-started + Browse-components CTAs, theming-aware live demo (two Cards that repaint with the theme picker), 30-second install snippet, "Why Wabi" 3-up feature grid, footer.
21
+ - **`/preview`** — The Sprint 1-6 kitchen sink home is preserved verbatim here for dev-time component browsing. 22 component demo sections.
22
+ - **`/docs/getting-started`** — 5-step onboarding (gem add → install generator → wire Tailwind 4 → mount theme controller → add components). Cross-links to /docs/components, /docs/theming, /docs/themes, /docs/philosophy.
23
+ - **`/docs/theming`** — How to switch palettes, available palette list, dark-mode toggle pattern, customizing tokens (HSL var space), Tailwind 4 notes (no preset.js, `@theme inline`, `@source` for autodetection).
24
+ - **`/docs/philosophy`** — 5-section rationale: "you own the code", Phlex-native composition, accessible-by-default, brand-neutral, Hotwire-friendly.
25
+
26
+ ### Changed
27
+
28
+ - **Dependencies**: `docs/Gemfile` gains `rouge ~> 4.5` for server-side syntax highlighting in `CodeBlock`. No new gems beyond that.
29
+ - **`docs/app/views/pages/home.rb` REPLACED** with the marketing landing; the old kitchen-sink home was moved to `docs/app/views/pages/preview.rb` (class renamed `Home` → `Preview`) and routed at `/preview`.
30
+ - **Nav layout**: header gains a "Components" link alongside the existing "Themes" link. ThemePicker dropdown stays.
31
+
32
+ ### Fixed
33
+
34
+ - **Constant resolution shadow under `Views::Pages::Components`.** Introducing the new `Views::Pages::Components::*` namespace (for the components index and 4 detailed pages) shadowed the top-level `Components::*` from anything else under `Views::Pages`. Ruby's constant lookup found `Views::Pages::Components` first and failed to find `Site` or `UI` inside it. Three views needed leading-`::` prefix on their `render` calls: `Home`, `Preview`, `Themes`. The `Themes` view (Sprint 6) had been silently broken since the components namespace was introduced — discovered and fixed in the same task.
35
+
36
+ ### Spec totals
37
+
38
+ - Registry: 125/125 unchanged (Sprint 7 doesn't touch registry).
39
+ - Gem: 66/66 unchanged.
40
+ - Doc-site smoke (Rack::Test): 11/11 routes return 200 — `/`, `/preview`, `/docs/themes`, `/docs/components`, `/docs/components/{button,dropdown_menu,dialog,tabs}`, `/docs/getting-started`, `/docs/theming`, `/docs/philosophy`.
41
+
42
+ ### Known v0.3 deferrals → v0.4
43
+
44
+ - **Detailed doc pages for the remaining 16 components** (input, textarea, label, card, badge, separator, alert, avatar, checkbox, switch, select, drawer, tooltip, popover, toast, accordion). The index lists them with descriptions + "Source only" affordance; v0.4 fills in the per-component pages.
45
+ - **Pagefind static search** — Needs Node/bun + pre-rendered HTML. Not yet worth the infrastructure for ~25 docs pages.
46
+ - **Sidebar nav** with active-section state — Header-only nav is fine for 5 top-level pages; sidebar makes sense once components have their own subsection.
47
+ - Same v0.2 deferrals still standing: `@zag-js/toast` group machine, real portal pattern, `wabi:update` generator, multi-level DropdownMenu nesting, `tailwind-merge`-equivalent class dedup edge cases, Phlex 2.4.1 Ruby 4 warnings.
48
+
49
+ ---
50
+
51
+ ## [0.2.0] — 2026-05-26
52
+
53
+ Sprint 6 (themes). 8 palettes ship, live theme switcher in the docs nav,
54
+ `/docs/themes` gallery page, and a `wabi:theme <slug>` generator to swap
55
+ themes in user apps. Sprint 7 (docs polish — per-component doc pages,
56
+ Pagefind search, marketing landing) is the next planned milestone.
57
+
58
+ ### Added
59
+
60
+ - **7 new theme palettes**: slate, stone, zinc, rose, blue, green, violet. Light + dark per theme, scoped via `[data-theme="<slug>"]` and `[data-theme="<slug>"][data-mode="dark"]` selectors. Default theme stays as the bare `[data-theme="default"]` (plus `:root` for the no-theme-yet case).
61
+ - **`bin/rails g wabi:theme <slug>` generator**: fetches `_shared.css` + `<slug>.css` from the lockfile's registry (HTTP or `file://`), concatenates, and overwrites `app/assets/tailwind/wabi/tokens.css`. Hint printed to re-run `tailwindcss:build`.
62
+ - **`Components::Site::ThemePicker`**: DropdownMenu in the docs nav with the 8 themes as a RadioGroup + a "Toggle dark mode" item. Wired to the existing `wabi--theme` Stimulus controller (`setTheme`/`toggleMode`), so persists in localStorage across reloads.
63
+ - **`/docs/themes` gallery**: side-by-side cards painting with each palette's own colors (each card carries its own `data-theme`). Each has an "Apply this theme" button that propagates the choice globally.
64
+ - **Registry endpoint `/r/themes/:slug.css`**: serves the per-theme CSS file. Slug regex allows the `_shared` underscore-prefixed name plus regular slugs. Realpath check guards against directory traversal.
65
+ - **`registry/themes/` directory + builder integration**: new canonical home for theme files. `Wabi::Registry::Builder#build_themes` runs as part of every `bin/build` and regenerates two derived artifacts:
66
+ - `gem/templates/tokens.css` — `_shared.css` + `default.css` only (what `wabi:install` copies into user apps).
67
+ - `docs/app/assets/tailwind/wabi/tokens.css` — `_shared.css` + all 8 themes concatenated (powers live switching in the docs site).
68
+
69
+ ### Fixed
70
+
71
+ - **DropdownMenuItem / DropdownMenuRadioItem dropped user-supplied `data:` kwargs.** Both components extracted only `:class` from `@attrs`, silently discarding everything else. Surfaced when wiring ThemePicker's `data: { action: "click->wabi--theme#setTheme", ... }` and the action attribute never reached the DOM. Both components now merge user data with component data; component keys win on collision so caller-supplied attrs can't override target/value bindings. Two new regression specs lock this in.
72
+ - **CSS specificity bug: `:root` redefined per theme clobbered light-mode switching.** First pass had every theme file start with `:root, [data-theme="<slug>"]`. When concatenated for the docs site, `:root` appeared 8 times. Since `:root` and `[data-theme="X"]` have equal specificity (0,1,0), the later `:root` block (from violet, last in concat order) overrode every earlier `[data-theme="X"]` rule — so every light-mode theme repainted as violet. Dark mode escaped because `[data-theme="X"][data-mode="dark"]` has specificity (0,2,0). Fix: only `default.css` keeps the `:root, ` prefix; the other 7 themes use `[data-theme="<slug>"]` alone. Regression spec asserts exactly one `:root` block in the docs output.
73
+
74
+ ### Changed
75
+
76
+ - **Default theme location moved** from `gem/templates/tokens.css` (hand-maintained) to `registry/themes/default.css` (canonical source) + `_shared.css` (boilerplate). The gem template is now a build artifact, regenerated by `bin/build`. Users see no functional difference; gem upgrades carry the same default palette as before.
77
+ - **Site::Layout grew a `<header>`** with a "Wabi" home link, a "Themes" nav link to `/docs/themes`, and the theme picker. The `raw safe(yield_content(&block))` + `<Toaster>` body siblings are preserved exactly.
78
+
79
+ ### Spec totals
80
+
81
+ - Registry: 125 examples, 0 failures (was 119 at v0.1; +6 across `#build_themes` describe block + DropdownMenu user-data merge specs).
82
+ - Gem: 66 examples, 0 failures (was 64; +2 from `theme_generator_spec`).
83
+
84
+ ### Known v0.2 deferrals → v0.3
85
+
86
+ - Pagefind static search.
87
+ - Per-component documentation pages (20 routes).
88
+ - Marketing-oriented landing page; kitchen-sink home would move to `/preview`.
89
+ - Getting-started / philosophy / theming concept prose pages.
90
+ - `@zag-js/toast` group machine (max stack, gap/offset, pause-on-group-hover).
91
+ - Real portal pattern for transformed ancestors.
92
+ - `wabi:update` diff-aware generator (`wabi:install --force` is the current minimum).
93
+ - Multi-level DropdownMenu nesting (v0.2 still supports single-level submenus).
94
+ - Phlex 2.4.1 Ruby 4 warnings (wait for upstream fix).
95
+
96
+ ---
97
+
98
+ ## [0.1.0] — 2026-05-26
99
+
100
+ First milestone release. 20 components, accessible, themable, no Zag.js
101
+ 0.x quirks left, no public gem / GitHub repo yet.
102
+
103
+ ### Components (20)
104
+
105
+ - **Static** (9): Button, Input, Textarea, Label, Card (compound), Badge, Separator, Alert (compound), Avatar (compound).
106
+ - **Forms** (3): Checkbox, Switch, Select (compound).
107
+ - **Overlays** (4): Dialog (compound), Drawer (4-side variant), Tooltip, Popover.
108
+ - **Menus + feedback** (2): DropdownMenu (with nested Submenu + checkbox/radio items), Toast (+ Toaster singleton container).
109
+ - **Navigation** (2): Tabs, Accordion.
110
+
111
+ ### Gem
112
+
113
+ - `wabi:install` + `wabi:add` + `wabi:list` + `wabi:registry` Rails generators.
114
+ - `Wabi::Base` Phlex base class with CVA-style `variants` DSL and variant-aware `merge_class` Tailwind dedup (`focus-visible:`, `data-[state=…]:`, axis-aware, width-vs-color for `border`/`ring`/`text`/`outline`/`divide`).
115
+ - `Wabi::RegistryClient` with disk cache, dev-context cache bypass (file://, localhost).
116
+ - `Wabi::Lockfile` tracking installed components.
117
+ - `Wabi::TurboStreamExtensions` adds `turbo_stream.wabi_toast(...)` when Turbo is loaded.
118
+ - Tailwind 4 native — `@theme inline` + `@custom-variant dark`, no preset.js.
119
+
120
+ ### Registry
121
+
122
+ - Build pipeline produces `dist/r/<name>.json` per component + `index.json`.
123
+ - JSON Schema validation (`schema/component.v1.json`).
124
+ - 20 components installable via `bin/rails g wabi:add <name>`.
125
+
126
+ ### Accessibility
127
+
128
+ - All overlays toggle the `inert` attribute via Zag's `onOpenChange` callback (synchronous inside the state transition, lands before `setInitialFocus`). Closed overlays are out of the tab order and the accessibility tree.
129
+ - `data-state` driven CSS transitions (opacity / translate / pointer-events) — no `display:none` cuts transitions short.
130
+ - WCAG-AA targeted per component manifest.
131
+
132
+ ### Theming
133
+
134
+ - Default theme ships in `app/assets/tailwind/wabi/tokens.css` via `wabi:install`.
135
+ - `data-mode="dark"` toggles dark palette; `data-theme="…"` ready for multi-palette in v0.2.
136
+
137
+ ### Tooling
138
+
139
+ - `bin/dev` monorepo entrypoint clears stale `docs/tmp/pids/server.pid` on launch.
140
+ - CI verifies all 20 dist artifacts on push (`.github/workflows/registry-build.yml`).
141
+ - Registry spec suite: 119 examples, 0 failures.
142
+ - Gem spec suite: 64 examples, 0 failures.
143
+
144
+ ### Documentation
145
+
146
+ - `docs/SMOKE-TEST.md` records per-sprint outcomes and Zag.js 1.x trap memos (entries 1–10 in `feedback_zag_js_pattern`).
147
+ - `docs/V01-CARRYOVER.md` tracks v0.1 carryover (now closed) plus v0.2 deferrals.
148
+
149
+ ### Known v0.2 deferrals
150
+
151
+ - `@zag-js/toast` group machine (`max` / `gap` / pause-on-group-hover). Current vanilla controller per toast covers the 99% case.
152
+ - Real portal pattern (Stimulus loses target tracking when subtree moves to `document.body`). Workaround: `position: fixed` + high z-index.
153
+ - Multi-palette theme picker UI + documented theme-extension flow.
154
+ - `wabi:update` diff-aware generator (current `wabi:install --force` is the minimum acceptable for v0.1).
155
+ - Multi-level DropdownMenu nesting (sub-inside-a-sub). v0.1 supports single-level submenus.
156
+ - `tailwind-merge`-equivalent class dedup for edge cases (`bg-cover` collapsing under `bg-{color}` etc.).
157
+ - Phlex 2.4.1 emits Ruby 4.0.5 warnings at load time; wait for upstream fix.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oscar Ortega and Wabi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Wabi
2
+
3
+ > Beautifully imperfect components for Rails.
4
+
5
+ Wabi is an open-source UI component library for **Ruby on Rails 8**, built on **Phlex + Tailwind 4 + Stimulus + Hotwire**. Inspired by shadcn/ui, components are *copied* into your app — you own the code, customize freely, no upstream API to drift away from.
6
+
7
+ 🎉 **Status:** v0.3.0 alpha. 20 components, 8 theme palettes, WCAG-AA targeted. Not yet on RubyGems.
8
+
9
+ ---
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ # 1. Add the gem (path-based for now; RubyGems push pending)
15
+ bundle add wabi --git https://github.com/wabikit/wabi --glob 'gem/*'
16
+
17
+ # 2. Run the installer (copies tokens.css + theme controller + lockfile)
18
+ bin/rails g wabi:install
19
+
20
+ # 3. Add components from the registry
21
+ bin/rails g wabi:add button card dialog
22
+
23
+ # 4. Render
24
+ # In any Phlex view:
25
+ # render Components::UI::Button.new(appearance: :primary) { "Click me" }
26
+ ```
27
+
28
+ Then add `@import "./wabi/tokens.css";` AFTER `@import "tailwindcss";` in your `app/assets/tailwind/application.css`, and mount `data-controller="wabi--theme"` on `<html>` in your layout.
29
+
30
+ ---
31
+
32
+ ## What's in the box
33
+
34
+ ### 20 components
35
+
36
+ | Static (9) | Forms (3) | Overlays (4) | Menus + Feedback (2) | Navigation (2) |
37
+ |---|---|---|---|---|
38
+ | Button | Checkbox | Dialog | DropdownMenu | Tabs |
39
+ | Input | Switch | Drawer (4 sides) | Toast + Toaster | Accordion |
40
+ | Textarea | Select | Tooltip | | |
41
+ | Label | | Popover | | |
42
+ | Card (compound) | | | | |
43
+ | Badge | | | | |
44
+ | Separator | | | | |
45
+ | Alert (compound) | | | | |
46
+ | Avatar (compound) | | | | |
47
+
48
+ All interactive components wire through **Zag.js 1.x** state machines for WAI-ARIA roles, keyboard semantics, and focus management. Toast uses a custom vanilla controller (cross-toast coordination is a v0.4 task).
49
+
50
+ ### 8 theme palettes
51
+
52
+ `default`, `slate`, `stone`, `zinc`, `rose`, `blue`, `green`, `violet`. Light + dark variants per theme. Switch with:
53
+
54
+ ```bash
55
+ bin/rails g wabi:theme rose
56
+ ```
57
+
58
+ Or live-switch via the `wabi--theme` Stimulus controller (sets `data-theme` on `<html>`, persists in `localStorage`).
59
+
60
+ ---
61
+
62
+ ## Why Wabi
63
+
64
+ | Principle | What it means |
65
+ |---|---|
66
+ | **You own the code** | `bin/rails g wabi:add button` COPIES the Phlex source into your app. Edit, refactor, fork — there's no upstream API to drift from, because the upstream is you. |
67
+ | **Phlex-native** | Components are Ruby classes. Composition is method dispatch. Variants are class-method DSLs. Real inheritance, IDE tab-into-source, like the rest of your Rails app. |
68
+ | **Accessible by default** | Zag.js carries WAI-ARIA roles, keyboard nav, focus management, scroll lock for modals. Overlays toggle `inert` when closed so they stay out of tab order + the a11y tree. |
69
+ | **Brand-neutral** | 8 carefully-chosen palettes. None is "the Wabi look". Pick the closest one or edit HSL values directly. |
70
+ | **Hotwire-friendly** | Stimulus controllers wrap each Zag machine. `turbo_stream.wabi_toast(...)` lets the server spawn notifications without round-tripping the page. |
71
+
72
+ ---
73
+
74
+ ## Example: a confirmation dialog
75
+
76
+ ```ruby
77
+ # app/views/users/destroy_confirmation.rb
78
+ class Views::Users::DestroyConfirmation < Views::Base
79
+ def view_template
80
+ render Components::UI::Dialog.new do
81
+ render Components::UI::DialogTrigger.new(
82
+ class: "inline-flex h-10 px-4 rounded-md bg-destructive text-destructive-foreground"
83
+ ) { "Delete account" }
84
+
85
+ render Components::UI::DialogContent.new do
86
+ render Components::UI::DialogHeader.new do
87
+ render Components::UI::DialogTitle.new { "Delete account" }
88
+ render Components::UI::DialogDescription.new { "This action cannot be undone." }
89
+ end
90
+ render Components::UI::DialogFooter.new do
91
+ render Components::UI::DialogCancel.new { "Cancel" }
92
+ render Components::UI::DialogAction.new(appearance: :destructive,
93
+ data: { action: "click->wabi--dialog#close" }) { "Delete" }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ ```
100
+
101
+ That's a fully-accessible modal with focus trap, scroll lock, backdrop click, Escape dismiss, and `inert` on close — out of the box. The Phlex source is in `app/components/ui/dialog*.rb`; modify whatever you want.
102
+
103
+ ---
104
+
105
+ ## CLI reference
106
+
107
+ | Generator | What it does |
108
+ |---|---|
109
+ | `wabi:install [--force]` | Copies `tokens.css`, the `wabi--theme` Stimulus controller, and initializes `config/wabi.lock.json`. `--force` re-copies tokens/controller on gem upgrades (lockfile is preserved). |
110
+ | `wabi:add <name…>` | Copies one or more component source files from the registry into `app/components/ui/` and their controllers into `app/javascript/controllers/wabi/`. Updates the lockfile. |
111
+ | `wabi:list` | Lists all available components in the configured registry. |
112
+ | `wabi:registry <url>` | Switches the active registry origin (default: `https://wabikit.dev/r`). |
113
+ | `wabi:theme <slug>` | Swaps `tokens.css` for the requested palette. Run `bin/rails tailwindcss:build` after. |
114
+
115
+ ---
116
+
117
+ ## Compatibility
118
+
119
+ - **Ruby**: 3.4 or later
120
+ - **Rails**: 8.0 or later
121
+ - **Tailwind**: 4.x (native `@theme inline`, no `tailwind.config.js`/`preset.js`)
122
+ - **Phlex**: 2.4 or later
123
+ - **Stimulus**: 3.x
124
+ - **Browsers**: Chrome 117+, Safari 17.4+, Firefox 119+ (some components use modern CSS like `grid-template-rows` height animation for Accordion)
125
+
126
+ ---
127
+
128
+ ## Documentation
129
+
130
+ The full docs site is at the GitHub repo's `docs/` Rails app (also planned to host at https://wabikit.dev when DNS is wired). Locally:
131
+
132
+ ```bash
133
+ git clone https://github.com/wabikit/wabi
134
+ cd wabi
135
+ bin/dev # starts registry watcher + tailwind watcher + docs server on :3000
136
+ ```
137
+
138
+ Then visit:
139
+ - `/` — marketing landing
140
+ - `/docs/components` — index of all 20 components
141
+ - `/docs/components/{button,dropdown_menu,dialog,tabs}` — detailed pages with live preview + source
142
+ - `/docs/themes` — all 8 palettes side-by-side
143
+ - `/docs/getting-started`, `/docs/theming`, `/docs/philosophy` — prose docs
144
+ - `/preview` — the Sprint 1-6 kitchen sink (every component on one page)
145
+
146
+ ---
147
+
148
+ ## Monorepo layout
149
+
150
+ - `gem/` — the `wabi` Ruby gem: runtime (`Wabi::Base`, `Wabi::Variants`, `Wabi::ClassMerge`, `Wabi::RegistryClient`, `Wabi::Lockfile`) + Rails generators.
151
+ - `registry/` — component source files (`components/<name>/{manifest.yml, *.rb, *.js}`) + theme CSS files + the build pipeline emitting `dist/r/<name>.json`.
152
+ - `docs/` — the `wabikit.dev` Rails app; also serves the registry at `/r/*.json` and `/r/themes/*.css`.
153
+
154
+ ---
155
+
156
+ ## Roadmap
157
+
158
+ | Version | Target | Status |
159
+ |---|---|---|
160
+ | v0.1 | 20 components | ✅ shipped 2026-05-26 |
161
+ | v0.2 | 8 themes + theme picker | ✅ shipped 2026-05-26 |
162
+ | v0.3 | Real docs site (marketing + components index + 4 detailed pages + prose) | ✅ shipped 2026-05-26 |
163
+ | v0.4 | Detailed pages for remaining 16 components; Pagefind search; sidebar nav | planned |
164
+ | v0.5 | `@zag-js/toast` group machine; real portal pattern; `wabi:update` generator | planned |
165
+ | v1.0 | API stability; external a11y audit | 2027-04 target |
166
+
167
+ See [ROADMAP.md](./ROADMAP.md) for the long-term view and [CHANGELOG.md](./CHANGELOG.md) for the per-release detail.
168
+
169
+ ---
170
+
171
+ ## Contributing
172
+
173
+ Wabi is in alpha and the API is still moving. Filing issues with concrete repros, suggestions for components, or theme palette ideas is the most useful kind of contribution right now. See [CONTRIBUTING.md](./CONTRIBUTING.md) (TODO — will land before v0.4) for the per-component anatomy and the Zag.js wiring conventions used throughout.
174
+
175
+ ---
176
+
177
+ ## License
178
+
179
+ MIT — see [LICENSE](./LICENSE). Theme HSL values derive from [shadcn/ui](https://ui.shadcn.com)'s palettes, also MIT-licensed.
data/lib/wabi/base.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+ require_relative "variants"
5
+ require_relative "class_merge"
6
+
7
+ module Wabi
8
+ class Base < Phlex::HTML
9
+ extend Wabi::Variants
10
+
11
+ private
12
+
13
+ def merge_class(*classes)
14
+ Wabi::ClassMerge.call(*classes)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Wabi
6
+ # Minimal Tailwind class deduplication. Later tokens win for the same group key.
7
+ #
8
+ # The group key is variant-aware: any variants stacked in front of a utility
9
+ # (e.g. "focus-visible:", "data-[state=checked]:", "md:dark:") become part of
10
+ # the key, so variant-scoped utilities never collide with their plain
11
+ # counterparts. Inside a given variant scope, the key is the utility's first
12
+ # hyphen-separated segment ("h-4" → "h", "bg-red-500" → "bg").
13
+ #
14
+ # NOTE: Still intentionally minimal for v0.1 — utilities that share a prefix
15
+ # but represent different CSS properties (e.g. `ring-2` width vs `ring-ring`
16
+ # color, or `border` width vs `border-primary` color) collide and the later
17
+ # one wins. Full Tailwind conflict resolution (à la tailwind-merge) is
18
+ # post-v0.1.
19
+ module ClassMerge
20
+ module_function
21
+
22
+ def call(*inputs)
23
+ tokens = inputs.compact.flat_map { |s| s.to_s.split(/\s+/) }.reject(&:empty?)
24
+ seen = {}
25
+ tokens.each do |token|
26
+ seen[group_key(token)] = token
27
+ end
28
+ seen.values.join(" ")
29
+ end
30
+
31
+ def group_key(token)
32
+ idx = last_colon_outside_brackets(token)
33
+ if idx
34
+ prefix = token[0..idx]
35
+ utility = token[(idx + 1)..]
36
+ else
37
+ prefix = ""
38
+ utility = token
39
+ end
40
+ "#{prefix}#{utility_group(utility)}"
41
+ end
42
+
43
+ # Single-word "atom" utilities that name a CSS property whose compound
44
+ # siblings (e.g. `flex` vs `flex-col`, `border` vs `border-input`) target
45
+ # an entirely different property and must NOT share a dedup bucket. Without
46
+ # this distinction, `flex flex-col` collapses to just `flex-col` -- the
47
+ # display:flex rule is lost and the children don't lay out as a flex row.
48
+ ATOM_UTILITIES = %w[
49
+ flex grid block inline hidden visible
50
+ border ring rounded outline
51
+ transition truncate
52
+ absolute relative fixed static sticky
53
+ ].to_set.freeze
54
+
55
+ # Directional / axial suffix segments that distinguish utilities in the
56
+ # same family (e.g. `-translate-x-1/2` vs `-translate-y-1/2` are different
57
+ # axes, `border-l` vs `border-r` are different sides). When the first
58
+ # segment after the family root matches one of these, keep the family root
59
+ # PLUS the direction segment as the group key.
60
+ AXIS_FAMILIES = %w[translate -translate scale skew rotate space border].to_set.freeze
61
+ AXIS_SUFFIXES = %w[x y z t b l r s e].to_set.freeze
62
+
63
+ # Families whose first non-family segment can be EITHER a width/size value
64
+ # OR a color name. Tailwind treats these as different CSS properties so
65
+ # they must dedup independently. Without this, `border-2 border-input`
66
+ # collapses to just `border-input` (loses the width), or `ring-2 ring-ring
67
+ # ring-offset-2` collapses to one of them (the focus ring vanishes).
68
+ WIDTH_COLOR_FAMILIES = %w[border ring text outline divide].to_set.freeze
69
+
70
+ # Tail tokens that unambiguously identify a size/length utility. Anything
71
+ # that doesn't match these AND doesn't look numeric is assumed to be a
72
+ # color (or other named theme token).
73
+ SIZE_TOKENS = %w[
74
+ xs sm base md lg xl 2xl 3xl 4xl 5xl 6xl 7xl 8xl 9xl
75
+ auto full screen min max fit none px
76
+ ].to_set.freeze
77
+
78
+ def utility_group(utility)
79
+ return "atom:#{utility}" if ATOM_UTILITIES.include?(utility)
80
+
81
+ segments = utility.split("-").reject(&:empty?)
82
+ head = utility.start_with?("-") ? "-#{segments.first}" : segments.first
83
+ tail = utility.start_with?("-") ? segments[1] : segments[1]
84
+
85
+ # `ring-offset-*` is its own family: `ring-offset-2` (width) and
86
+ # `ring-offset-input` (color) must NOT collide with bare `ring-*`.
87
+ if head == "ring" && tail == "offset"
88
+ sub_tail = utility.start_with?("-") ? segments[2] : segments[2]
89
+ return "ring-offset:size" if sub_tail && size_or_numeric?(sub_tail)
90
+ return "ring-offset:color" if sub_tail
91
+ return "ring-offset"
92
+ end
93
+
94
+ return "#{head}-#{tail}" if AXIS_FAMILIES.include?(head) && AXIS_SUFFIXES.include?(tail)
95
+
96
+ if WIDTH_COLOR_FAMILIES.include?(head) && tail
97
+ return "#{head}:size" if size_or_numeric?(tail)
98
+ return "#{head}:color"
99
+ end
100
+
101
+ head
102
+ end
103
+
104
+ def size_or_numeric?(tail)
105
+ return true if SIZE_TOKENS.include?(tail)
106
+ return true if tail.match?(/\A-?\d+(\/\d+)?\z/)
107
+ false
108
+ end
109
+
110
+ # The last `:` that lives outside any `[...]` block. Arbitrary-value variants
111
+ # like `data-[state=checked]:` may contain `:` inside the brackets, which
112
+ # must not be treated as the variant→utility separator.
113
+ def last_colon_outside_brackets(token)
114
+ depth = 0
115
+ idx = nil
116
+ token.each_char.with_index do |c, i|
117
+ case c
118
+ when "[" then depth += 1
119
+ when "]" then depth -= 1
120
+ when ":" then idx = i if depth.zero?
121
+ end
122
+ end
123
+ idx
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "digest"
5
+ require "wabi/registry_client"
6
+ require "wabi/lockfile"
7
+
8
+ module Wabi
9
+ module Generators
10
+ class AddGenerator < Rails::Generators::Base
11
+ argument :components, type: :array, banner: "component_name [component_name ...]"
12
+
13
+ desc "Add one or more Wabi components to this app."
14
+
15
+ def add_components
16
+ @js_deps_to_pin = {}
17
+ lockfile # eager-init via accessor
18
+ components.each { |name| install_component(name) }
19
+ lockfile.save
20
+ print_js_pin_instructions
21
+ end
22
+
23
+ private
24
+
25
+ def lockfile
26
+ @lockfile ||= Wabi::Lockfile.load(File.join(destination_root, "config/wabi.lock.json"))
27
+ end
28
+
29
+ def client
30
+ @client ||= Wabi::RegistryClient.new(base_url: lockfile.registry)
31
+ end
32
+
33
+ def install_component(name)
34
+ return if lockfile.components.key?(name)
35
+ say " fetching #{name}", :cyan
36
+ data = client.fetch(name)
37
+
38
+ Array(data["registry_dependencies"]).each { |dep| install_component(dep) }
39
+
40
+ data["files"].each do |file|
41
+ target = File.join(destination_root, file["path"])
42
+ FileUtils.mkdir_p(File.dirname(target))
43
+ File.write(target, file["content"])
44
+ say " create #{file["path"]}", :green
45
+ end
46
+
47
+ (data["js_dependencies"] || {}).each { |pkg, ver| @js_deps_to_pin[pkg] = ver }
48
+
49
+ hash = Digest::SHA256.hexdigest(JSON.generate(data["files"]))
50
+ lockfile.record(name, version: data["version"], hash: hash)
51
+ end
52
+
53
+ def print_js_pin_instructions
54
+ return if @js_deps_to_pin.empty?
55
+ say "\n This component requires JS packages. Add these pins to config/importmap.rb:", :yellow
56
+ @js_deps_to_pin.each do |pkg, version|
57
+ v = version.to_s.sub(/\A[~^]/, "")
58
+ v = "1.0.0" if v.empty?
59
+ say %( pin "#{pkg}", to: "https://cdn.jsdelivr.net/npm/#{pkg}@#{v}/+esm")
60
+ end
61
+ say "\n NOTE: `bin/importmap pin <pkg>` is NOT recommended for packages with submodule"
62
+ say " imports (like @zag-js/*) — it only downloads the entry file, leaving relative"
63
+ say " imports unresolved. The `+esm` endpoint above ships a single bundle with all"
64
+ say " transitive deps resolved to absolute URLs.", :yellow
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "wabi/lockfile"
5
+
6
+ module Wabi
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("../../../templates", __dir__)
10
+
11
+ desc "Set up Wabi in this Rails app: copy tokens, theme controller, init lockfile."
12
+
13
+ class_option :force, type: :boolean, default: false,
14
+ desc: "Overwrite existing tokens.css and theme_controller.js (the lockfile is preserved)."
15
+
16
+ def copy_tokens
17
+ copy_file "tokens.css", "app/assets/tailwind/wabi/tokens.css",
18
+ force: options[:force], skip: !options[:force]
19
+ end
20
+
21
+ def copy_theme_controller
22
+ copy_file "controllers/wabi/theme_controller.js",
23
+ "app/javascript/controllers/wabi/theme_controller.js",
24
+ force: options[:force], skip: !options[:force]
25
+ end
26
+
27
+ def init_lockfile
28
+ path = File.join(destination_root, "config/wabi.lock.json")
29
+ FileUtils.mkdir_p(File.dirname(path))
30
+ return if File.exist?(path)
31
+ lockfile = Wabi::Lockfile.load(path)
32
+ lockfile.save
33
+ end
34
+
35
+ def print_next_steps
36
+ say "\n Wabi installed. Next steps:", :green
37
+ say ""
38
+ say " 1. Import tokens AFTER the Tailwind import in app/assets/tailwind/application.css:"
39
+ say " @import \"tailwindcss\";"
40
+ say " @import \"./wabi/tokens.css\";"
41
+ say ""
42
+ say " 2. Use 'tailwind' (not 'application') in your stylesheet link tag:"
43
+ say " stylesheet_link_tag \"tailwind\", \"data-turbo-track\": \"reload\""
44
+ say " (Rails 8.1's :app symbol also works if you keep the default ERB layout.)"
45
+ say ""
46
+ say " 3. Mount the theme controller on <html> in your layout:"
47
+ say " <html data-controller=\"wabi--theme\">"
48
+ say ""
49
+ say " 4. Add components from the registry:"
50
+ say " bin/rails g wabi:add button input card"
51
+ say " Components autoload under Components::UI::* (Phlex 2.x convention)."
52
+ say " Render them as: render Components::UI::Button.new"
53
+ say ""
54
+ say " 5. If Tailwind doesn't pick up component classes, add this near the top of"
55
+ say " application.css (Tailwind 4 normally auto-detects .rb files, but in"
56
+ say " unusual asset paths an explicit @source helps):"
57
+ say " @source \"../../components/**/*.rb\";"
58
+ say ""
59
+ say " Run wabi:install --force later to refresh tokens.css and theme_controller.js"
60
+ say " after a gem upgrade (your wabi.lock.json is preserved)."
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "wabi/lockfile"
5
+ require "wabi/registry_client"
6
+
7
+ module Wabi
8
+ module Generators
9
+ class ListGenerator < Rails::Generators::Base
10
+ desc "List installed Wabi components and available components in the registry."
11
+
12
+ def list
13
+ lockfile = Wabi::Lockfile.load(File.join(destination_root, "config/wabi.lock.json"))
14
+ installed_names = lockfile.components.keys
15
+
16
+ client = Wabi::RegistryClient.new(base_url: lockfile.registry)
17
+ catalog = client.fetch("index")["components"] || []
18
+
19
+ say "\nRegistry: #{lockfile.registry}\n", :cyan
20
+ catalog.each do |entry|
21
+ name = entry["name"]
22
+ marker = installed_names.include?(name) ? "[installed]" : " "
23
+ say format(" %s %-20s v%s", marker, name, entry["version"])
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "wabi/lockfile"
5
+
6
+ module Wabi
7
+ module Generators
8
+ class RegistryGenerator < Rails::Generators::Base
9
+ argument :url, type: :string, banner: "registry_url"
10
+
11
+ desc "Set the registry URL for this project."
12
+
13
+ def update_registry
14
+ path = File.join(destination_root, "config/wabi.lock.json")
15
+ lockfile = Wabi::Lockfile.load(path)
16
+ # Lockfile doesn't currently expose a registry setter; bypass via instance_var.
17
+ lockfile.instance_variable_set(:@registry, url)
18
+ lockfile.save
19
+ say " registry → #{url}", :green
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "wabi/lockfile"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Wabi
9
+ module Generators
10
+ class ThemeGenerator < Rails::Generators::Base
11
+ argument :slug, type: :string, banner: "theme_slug"
12
+
13
+ desc "Switch to a different Wabi theme palette (default, slate, stone, zinc, rose, blue, green, violet)."
14
+
15
+ def fetch_and_write
16
+ lockfile = Wabi::Lockfile.load(File.join(destination_root, "config/wabi.lock.json"))
17
+ base = lockfile.registry # e.g. https://wabikit.dev/r OR file:///abs/path
18
+
19
+ shared = fetch("#{base}/themes/_shared.css", label: "_shared")
20
+ body = fetch("#{base}/themes/#{slug}.css", label: slug)
21
+
22
+ target = File.join(destination_root, "app/assets/tailwind/wabi/tokens.css")
23
+ FileUtils.mkdir_p(File.dirname(target))
24
+ File.write(target, [shared, body].join("\n"))
25
+
26
+ say " theme → #{slug}", :green
27
+ say " wrote app/assets/tailwind/wabi/tokens.css", :green
28
+ say " Run `bin/rails tailwindcss:build` to compile the new palette.", :cyan
29
+ end
30
+
31
+ private
32
+
33
+ def fetch(url_or_path, label:)
34
+ if url_or_path.start_with?("file://")
35
+ path = url_or_path.sub("file://", "")
36
+ raise Wabi::Error, "Theme #{label} not found at #{path}" unless File.exist?(path)
37
+ File.read(path)
38
+ else
39
+ uri = URI.parse(url_or_path)
40
+ response = Net::HTTP.get_response(uri)
41
+ raise Wabi::Error, "Theme #{label} not found at #{url_or_path}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
42
+ response.body
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Wabi
7
+ # Manages config/wabi.lock.json in a user's Rails app.
8
+ # Tracks installed components, versions, hashes, and registry origin.
9
+ class Lockfile
10
+ DEFAULT_REGISTRY = "https://wabikit.dev/r"
11
+
12
+ attr_reader :path, :registry, :components
13
+
14
+ def self.load(path)
15
+ data =
16
+ if File.exist?(path)
17
+ JSON.parse(File.read(path))
18
+ else
19
+ {}
20
+ end
21
+ new(path: path, data: data)
22
+ end
23
+
24
+ def initialize(path:, data: {})
25
+ @path = path
26
+ @registry = data["registry"] || DEFAULT_REGISTRY
27
+ @components = data["components"] || {}
28
+ end
29
+
30
+ def record(name, version:, hash:)
31
+ @components[name] = { "version" => version, "hash" => hash }
32
+ end
33
+
34
+ def save
35
+ FileUtils.mkdir_p(File.dirname(@path))
36
+ File.write(@path, JSON.pretty_generate({
37
+ "registry" => @registry,
38
+ "components" => @components,
39
+ }))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "fileutils"
6
+ require "uri"
7
+ require_relative "version"
8
+
9
+ module Wabi
10
+ class RegistryClient
11
+ DEFAULT_BASE_URL = "https://wabikit.dev/r"
12
+ DEFAULT_TTL = 24 * 60 * 60
13
+
14
+ attr_reader :base_url
15
+
16
+ def initialize(base_url: DEFAULT_BASE_URL)
17
+ @base_url = base_url
18
+ @ttl = Integer(ENV.fetch("WABI_CACHE_TTL", DEFAULT_TTL))
19
+ end
20
+
21
+ def cache_dir
22
+ File.expand_path("~/.cache/wabi/#{Wabi::VERSION}")
23
+ end
24
+
25
+ def fetch(component_name)
26
+ unless dev_context?
27
+ cached = read_cache(component_name)
28
+ return JSON.parse(cached) if cached
29
+ end
30
+
31
+ body =
32
+ if @base_url.start_with?("file://")
33
+ fetch_local(component_name)
34
+ else
35
+ fetch_http(component_name)
36
+ end
37
+
38
+ write_cache(component_name, body) unless dev_context?
39
+ JSON.parse(body)
40
+ end
41
+
42
+ private
43
+
44
+ def dev_context?
45
+ @base_url.start_with?("file://", "http://localhost", "http://127.")
46
+ end
47
+
48
+ def fetch_local(name)
49
+ path = File.join(@base_url.sub("file://", ""), "#{name}.json")
50
+ raise Wabi::Error, "Component #{name} not found at #{path}" unless File.exist?(path)
51
+ File.read(path)
52
+ end
53
+
54
+ def fetch_http(name)
55
+ uri = URI.parse("#{@base_url}/#{name}.json")
56
+ response = Net::HTTP.get_response(uri)
57
+ unless response.is_a?(Net::HTTPSuccess)
58
+ raise Wabi::Error, "Failed to fetch #{name}: HTTP #{response.code}"
59
+ end
60
+ response.body
61
+ end
62
+
63
+ def cache_path(name)
64
+ File.join(cache_dir, "#{name}.json")
65
+ end
66
+
67
+ def read_cache(name)
68
+ path = cache_path(name)
69
+ return nil unless File.exist?(path)
70
+ return nil if (Time.now - File.mtime(path)) > @ttl
71
+ File.read(path)
72
+ end
73
+
74
+ def write_cache(name, body)
75
+ FileUtils.mkdir_p(cache_dir)
76
+ File.write(cache_path(name), body)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wabi
4
+ # Extends `Turbo::Streams::TagBuilder` with a `wabi_toast` shorthand that
5
+ # appends a server-rendered Toast component into the singleton Toaster
6
+ # container.
7
+ #
8
+ # In a Rails controller / Turbo Stream view:
9
+ #
10
+ # render turbo_stream: turbo_stream.wabi_toast(
11
+ # title: "Saved",
12
+ # description: "Profile updated.",
13
+ # appearance: :success,
14
+ # )
15
+ #
16
+ # Equivalent to:
17
+ #
18
+ # render turbo_stream: turbo_stream.append(
19
+ # "wabi-toaster",
20
+ # Components::UI::Toast.new(title: "Saved", description: "...", appearance: :success),
21
+ # )
22
+ #
23
+ # The user app must have run `bin/rails g wabi:add toast` first so that
24
+ # `Components::UI::Toast` is defined.
25
+ #
26
+ # NOTE: this module is named `Wabi::TurboStreamExtensions` (NOT
27
+ # `Wabi::Rails::TurboStreamExtensions`) because nesting a `Rails` module
28
+ # inside `Wabi` shadows the top-level `Rails` constant from generators that
29
+ # reference `Rails::Generators::Base`, breaking gem load order.
30
+ module TurboStreamExtensions
31
+ def wabi_toast(toaster_id: "wabi-toaster", **toast_options)
32
+ toast_class = wabi_resolve_toast_class
33
+ append(toaster_id, toast_class.new(**toast_options))
34
+ end
35
+
36
+ private
37
+
38
+ def wabi_resolve_toast_class
39
+ Object.const_get("Components::UI::Toast")
40
+ rescue NameError
41
+ raise NameError,
42
+ "Components::UI::Toast is not defined. Run `bin/rails g wabi:add toast` " \
43
+ "to install the component before using `turbo_stream.wabi_toast(...)`."
44
+ end
45
+ end
46
+ end
47
+
48
+ # Inject into Turbo's tag builder when Turbo is loaded. Gated so the gem stays
49
+ # usable in non-Turbo Rails apps and in pure-Ruby specs.
50
+ if defined?(::Turbo::Streams::TagBuilder)
51
+ ::Turbo::Streams::TagBuilder.include(Wabi::TurboStreamExtensions)
52
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wabi
4
+ # CVA-style variant DSL. Use by extending into a class:
5
+ #
6
+ # class Button
7
+ # extend Wabi::Variants
8
+ # variants do
9
+ # base "btn"
10
+ # variant :size, { sm: "h-8", md: "h-10" }, default: :md
11
+ # end
12
+ # end
13
+ #
14
+ # Button.new.tokens(size: :sm) # => "btn h-8"
15
+ module Variants
16
+ def variants(&block)
17
+ definition = Definition.new
18
+ definition.instance_eval(&block)
19
+ @_wabi_variants = definition
20
+ define_method(:tokens) do |**opts|
21
+ self.class.instance_variable_get(:@_wabi_variants).resolve(**opts)
22
+ end
23
+ end
24
+
25
+ class Definition
26
+ def initialize
27
+ @base = ""
28
+ @variants = {}
29
+ @defaults = {}
30
+ end
31
+
32
+ def base(str)
33
+ @base = str
34
+ end
35
+
36
+ def variant(key, options, default: nil)
37
+ @variants[key] = options
38
+ @defaults[key] = default if default
39
+ end
40
+
41
+ def resolve(**opts)
42
+ parts = [@base]
43
+ @variants.each do |key, options|
44
+ selected = opts[key] || @defaults[key]
45
+ parts << options[selected] if selected && options[selected]
46
+ end
47
+ parts.compact.reject(&:empty?).join(" ")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wabi
4
+ VERSION = "0.3.0"
5
+ end
data/lib/wabi.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "wabi/version"
4
+ require_relative "wabi/base"
5
+ require_relative "wabi/class_merge"
6
+ require_relative "wabi/variants"
7
+ require_relative "wabi/registry_client"
8
+ require_relative "wabi/lockfile"
9
+ require_relative "wabi/turbo_stream_extensions"
10
+
11
+ module Wabi
12
+ class Error < StandardError; end
13
+ end
14
+
15
+ # Generators are only loaded under Rails (which triggers them via convention)
16
+ if defined?(Rails::Generators)
17
+ require_relative "wabi/generators/install_generator"
18
+ require_relative "wabi/generators/add_generator"
19
+ require_relative "wabi/generators/list_generator"
20
+ require_relative "wabi/generators/registry_generator"
21
+ require_relative "wabi/generators/theme_generator"
22
+ end
@@ -0,0 +1,37 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Wabi theme controller — toggles data-mode on <html>, persists to localStorage,
4
+ // respects prefers-color-scheme on first load.
5
+ export default class extends Controller {
6
+ static values = {
7
+ themeKey: { type: String, default: "wabi-theme" },
8
+ modeKey: { type: String, default: "wabi-mode" },
9
+ }
10
+
11
+ connect() {
12
+ const html = document.documentElement
13
+ const storedTheme = localStorage.getItem(this.themeKeyValue) || "default"
14
+ const storedMode = localStorage.getItem(this.modeKeyValue) || this.systemMode()
15
+ html.dataset.theme = storedTheme
16
+ html.dataset.mode = storedMode
17
+ }
18
+
19
+ toggleMode() {
20
+ const html = document.documentElement
21
+ const next = html.dataset.mode === "dark" ? "light" : "dark"
22
+ html.dataset.mode = next
23
+ localStorage.setItem(this.modeKeyValue, next)
24
+ this.dispatch("change", { detail: { mode: next } })
25
+ }
26
+
27
+ setTheme(event) {
28
+ const theme = event.params.theme
29
+ document.documentElement.dataset.theme = theme
30
+ localStorage.setItem(this.themeKeyValue, theme)
31
+ this.dispatch("change", { detail: { theme } })
32
+ }
33
+
34
+ systemMode() {
35
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"
36
+ }
37
+ }
@@ -0,0 +1,87 @@
1
+ /* Wabi theme system — Tailwind 4 native, shared infrastructure.
2
+ *
3
+ * Every Wabi theme CSS file is the concatenation of this `_shared.css` and a
4
+ * per-theme `<slug>.css` file containing only the raw HSL variable blocks.
5
+ * The build step in `registry/lib/builder.rb` emits the merged result to
6
+ * `gem/templates/tokens.css` (for the gem's wabi:install) and
7
+ * `docs/app/assets/tailwind/wabi/tokens.css` (for live switching in docs).
8
+ */
9
+
10
+ @custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));
11
+
12
+ @theme inline {
13
+ --color-background: hsl(var(--background));
14
+ --color-foreground: hsl(var(--foreground));
15
+ --color-card: hsl(var(--card));
16
+ --color-card-foreground: hsl(var(--card-foreground));
17
+ --color-popover: hsl(var(--popover));
18
+ --color-popover-foreground: hsl(var(--popover-foreground));
19
+ --color-primary: hsl(var(--primary));
20
+ --color-primary-foreground: hsl(var(--primary-foreground));
21
+ --color-secondary: hsl(var(--secondary));
22
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
23
+ --color-muted: hsl(var(--muted));
24
+ --color-muted-foreground: hsl(var(--muted-foreground));
25
+ --color-accent: hsl(var(--accent));
26
+ --color-accent-foreground: hsl(var(--accent-foreground));
27
+ --color-destructive: hsl(var(--destructive));
28
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
29
+ --color-border: hsl(var(--border));
30
+ --color-input: hsl(var(--input));
31
+ --color-ring: hsl(var(--ring));
32
+ --radius-lg: var(--radius);
33
+ --radius-md: calc(var(--radius) - 2px);
34
+ --radius-sm: calc(var(--radius) - 4px);
35
+ }
36
+
37
+ /* Wabi default theme — neutral grayscale.
38
+ *
39
+ * Raw HSL values only. The `_shared.css` companion ships the @custom-variant
40
+ * + @theme inline mapping; both files must be concatenated to produce a
41
+ * working tokens.css.
42
+ */
43
+
44
+ :root, [data-theme="default"] {
45
+ --background: 0 0% 100%;
46
+ --foreground: 240 10% 3.9%;
47
+ --primary: 240 5.9% 10%;
48
+ --primary-foreground: 0 0% 98%;
49
+ --secondary: 240 4.8% 95.9%;
50
+ --secondary-foreground: 240 5.9% 10%;
51
+ --muted: 240 4.8% 95.9%;
52
+ --muted-foreground: 240 3.8% 46.1%;
53
+ --accent: 240 4.8% 95.9%;
54
+ --accent-foreground: 240 5.9% 10%;
55
+ --destructive: 0 84.2% 60.2%;
56
+ --destructive-foreground: 0 0% 98%;
57
+ --border: 240 5.9% 90%;
58
+ --input: 240 5.9% 90%;
59
+ --ring: 240 5.9% 10%;
60
+ --radius: 0.5rem;
61
+ --card: 0 0% 100%;
62
+ --card-foreground: 240 10% 3.9%;
63
+ --popover: 0 0% 100%;
64
+ --popover-foreground: 240 10% 3.9%;
65
+ }
66
+
67
+ [data-theme="default"][data-mode="dark"] {
68
+ --background: 240 10% 3.9%;
69
+ --foreground: 0 0% 98%;
70
+ --primary: 0 0% 98%;
71
+ --primary-foreground: 240 5.9% 10%;
72
+ --secondary: 240 3.7% 15.9%;
73
+ --secondary-foreground: 0 0% 98%;
74
+ --muted: 240 3.7% 15.9%;
75
+ --muted-foreground: 240 5% 64.9%;
76
+ --accent: 240 3.7% 15.9%;
77
+ --accent-foreground: 0 0% 98%;
78
+ --destructive: 0 62.8% 30.6%;
79
+ --destructive-foreground: 0 0% 98%;
80
+ --border: 240 3.7% 15.9%;
81
+ --input: 240 3.7% 15.9%;
82
+ --ring: 240 4.9% 83.9%;
83
+ --card: 240 10% 3.9%;
84
+ --card-foreground: 0 0% 98%;
85
+ --popover: 240 10% 3.9%;
86
+ --popover-foreground: 0 0% 98%;
87
+ }
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wabi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Oscar Ortega
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: phlex-rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.8'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.8'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: fakefs
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.2'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: webmock
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.26'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.26'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13.0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13.0'
110
+ description: An OSS UI component library for Rails 8 — Phlex-native, Tailwind-themed,
111
+ accessible, with 'you own the code' philosophy.
112
+ email:
113
+ - dev@cosmaneura.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - CHANGELOG.md
119
+ - LICENSE
120
+ - README.md
121
+ - lib/wabi.rb
122
+ - lib/wabi/base.rb
123
+ - lib/wabi/class_merge.rb
124
+ - lib/wabi/generators/add_generator.rb
125
+ - lib/wabi/generators/install_generator.rb
126
+ - lib/wabi/generators/list_generator.rb
127
+ - lib/wabi/generators/registry_generator.rb
128
+ - lib/wabi/generators/theme_generator.rb
129
+ - lib/wabi/lockfile.rb
130
+ - lib/wabi/registry_client.rb
131
+ - lib/wabi/turbo_stream_extensions.rb
132
+ - lib/wabi/variants.rb
133
+ - lib/wabi/version.rb
134
+ - templates/controllers/wabi/theme_controller.js
135
+ - templates/tokens.css
136
+ homepage: https://wabikit.dev
137
+ licenses:
138
+ - MIT
139
+ metadata:
140
+ homepage_uri: https://wabikit.dev
141
+ source_code_uri: https://github.com/wabikit/wabi
142
+ bug_tracker_uri: https://github.com/wabikit/wabi/issues
143
+ changelog_uri: https://github.com/wabikit/wabi/blob/main/CHANGELOG.md
144
+ rdoc_options: []
145
+ require_paths:
146
+ - lib
147
+ required_ruby_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '3.4'
152
+ required_rubygems_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '0'
157
+ requirements: []
158
+ rubygems_version: 4.0.10
159
+ specification_version: 4
160
+ summary: Beautifully imperfect components for Rails.
161
+ test_files: []