studio-engine 0.141.3 → 0.142.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +103 -1
  4. data/app/assets/tailwind/studio_engine/engine.css +90 -18
  5. data/app/components/previews/studio/button_component_preview/disabled.html.erb +12 -0
  6. data/app/components/previews/studio/button_component_preview/icon_and_link.html.erb +7 -0
  7. data/app/components/previews/studio/button_component_preview/loading.html.erb +12 -0
  8. data/app/components/previews/studio/button_component_preview/sizes.html.erb +9 -0
  9. data/app/components/previews/studio/button_component_preview/tones.html.erb +5 -0
  10. data/app/components/previews/studio/button_component_preview.rb +34 -0
  11. data/app/components/previews/studio/empty_state_component_preview/with_icon_and_action.html.erb +5 -0
  12. data/app/components/previews/studio/empty_state_component_preview.rb +29 -0
  13. data/app/components/previews/studio/table_component_preview/rows.html.erb +14 -0
  14. data/app/components/previews/studio/table_component_preview.rb +33 -0
  15. data/app/components/studio/button_component.html.erb +1 -0
  16. data/app/components/studio/button_component.rb +151 -0
  17. data/app/components/studio/empty_state_component.html.erb +12 -0
  18. data/app/components/studio/empty_state_component.rb +64 -0
  19. data/app/components/studio/table_component.html.erb +32 -0
  20. data/app/components/studio/table_component.rb +152 -0
  21. data/app/controllers/studio/modals_controller.rb +48 -0
  22. data/app/javascript/studio/carousel.js +52 -0
  23. data/app/javascript/studio/controllers/carousel_controller.js +7 -13
  24. data/app/javascript/studio/in_page_links.js +85 -11
  25. data/app/javascript/studio/modal_host.js +219 -0
  26. data/app/models/error_log.rb +5 -5
  27. data/app/views/components/_empty_state.html.erb +2 -6
  28. data/app/views/studio/modals/_host.html.erb +34 -2
  29. data/app/views/studio/modals/remote.html.erb +5 -0
  30. data/app/views/style/_components.html.erb +5 -2
  31. data/app/views/style/_glass.html.erb +9 -1
  32. data/app/views/style/_primitives.html.erb +106 -0
  33. data/config/studio_tricks.yml +6 -2
  34. data/db/migrate/20261109180000_add_engine_not_null_checks.rb +148 -0
  35. data/lib/studio/modals.rb +101 -0
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +11 -0
  38. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca078eb9a5dbe6acfd9979b40081b446fc4a74455e21b9b8417a595225a19b42
4
- data.tar.gz: 15c40bf152b5e6a6cd0d8459a22e2a32543a86c2c0821573b10e669afc5da63f
3
+ metadata.gz: 9778730aefa3dc031248a8ff2535362cfb258443e2e10d8ded26997417720ef1
4
+ data.tar.gz: db87fa943c1ca9c97fa0323144dc7d395478e69788ad6bc248d669fb8c68323b
5
5
  SHA512:
6
- metadata.gz: 35b1aeb4b523008613f191d4782482464ecf8048b75a7175177c944e4e7f2e170da64ab0435470d442aa6d8be08f83a4cca5bfd0f4e30ee804820cec73e65d12
7
- data.tar.gz: de8d944eac95a2f399bd328e2513b95145eb208ea8f38600d188ec6b1ad7069240d4fb4c12324b25b650e66d40d9c45225c1d7374b6f0e66266a523b567233fe
6
+ metadata.gz: 31af4a95d22ab3a24a72e97cccda1670d061e1287bf9c8146fadba9b1c399720729f364a13188669827fa7623bbd61bf558f1e79c8aa235bac73e1e8a3454109
7
+ data.tar.gz: 66fb861fb188b09e76f5bfb068947d46a28bfc2ab1add0f4605b8315edfc4faa8cd2426356d23ab764324a51e811f9055bc6d4a61a41a8f850845aecfde5c5bd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.142.0 — 2026-11-09
8
+
9
+ ### Added
10
+
11
+ - **Modals load on demand (`Studio.modals`).** Register a modal in an initializer (`Studio.modals.register :contest_rules, partial: "modals/contest_rules"`, with `auth: :user`, `prefetch: true` and `locals:` as options) and delete its template from the layout. The shared host fetches it from `GET /_studio/modals/:id` on its first open, shows the processing card when the fetch is slow and the error card when it fails, and keeps it for the page's lifetime, Turbo visits included. Call sites do not change. An app that registers nothing renders the host as before. See the README, "Modals on demand".
12
+ - **`Studio::ButtonComponent`, `Studio::EmptyStateComponent` and `Studio::TableComponent`.** The engine's first UI primitives after the badge, so apps stop hand-building buttons, empty states and tables (UI/UX audit 2026-11-07, §4D). The button maps four tones (`primary`, `secondary`, `quiet`, `danger`) and three sizes onto the shipped `btn-*` utilities, renders a `<button>` or an `<a>`, and its `loading:` state is the aria-busy contract with the pending spinner. The empty state is the `.empty-state` card with an icon, a message, a detail line and one Button action. The table takes columns and rows, sorts through plain `?sort=&direction=` links with `aria-sort`, renders an empty row, and scrolls sideways on a phone. `/admin/style` draws every tone, size and state live, and `e2e/ui_primitives_screenshots.spec.js` compares each specimen to a Linux baseline at 390 and 1280 px in light and dark. Consumers adopt them in later releases; nothing changes until a view renders one.
13
+ - **`.lit-tile` and `.lit-tile-soft`.** Under `surface_style = :glass`, an app's own call-to-action tile that is not a `.btn` takes the buttons' light: a top light, a bottom shade and a contact shadow (`.lit-tile`), or the outline button's softer light (`.lit-tile-soft`), with a pressed state. Shadows only, so the box, the corners and the fill stay the app's. Without glass on the root, both classes do nothing.
14
+ - **The glass surfaces take the app's tint: `--glass-surface-tint` and `--glass-surface-shadow-tint`.** Under `surface_style = :glass`, the light theme's card hairline (`--surface-glass-line`), the soft lights' shade (`--lit-soft-bottom`, `--lit-soft-pressed`, `--lit-badge`, `--lit-input`) and the card shadow (`--surface-glass-shadow`) were the hub's violet for every app. They now read two root tokens, as `R G B`. The defaults are the old values, so an app that sets none looks as it did. An app sets them in its own stylesheet, unlayered, never in `@layer components` (the engine's default wins there and nothing reports it). The dark theme reads neither token. `docs/NEW_APP_SETUP.md` § 9 has the example. An app that already overrides the `--surface-glass-*` or `--lit-*` variables directly keeps working.
15
+
16
+ ### Changed
17
+
18
+ - **The engine tables refuse a NULL slug, and users refuse a NULL role.** A new migration, `AddEngineNotNullChecks` (run `bin/rails studio_engine:install:migrations`), backfills any NULL slug on `error_logs`, `theme_settings`, `studio_geo_settings` and `studio_site_identities` by its model's rule and any NULL `users.role` with `viewer`, then adds a validated CHECK `<column> IS NOT NULL` named `<table>_<column>_present`. It is the NOT NULL step that 0.141.0 deferred. It is a CHECK, not a column-level NOT NULL, so `db/schema.rb` only gains lines and the release's schema-dump guard accepts it; the dump after a migrate equals the dump after a schema load. A role column that is already NOT NULL (the hub, Turf) gets no CHECK. Adopt it only on a host whose production has run 0.141.0 or later for one release: up to 0.137.0, `ErrorLog.capture!` inserts a NULL slug first. To roll a host back to a release older than 0.141.0, run `bin/rails db:migrate:down VERSION=<this migration's installed version>` first; a rollback to 0.141.0 or later needs nothing.
19
+ - **`components/empty_state` renders `Studio::EmptyStateComponent`.** The partial keeps its locals (`message:`, `detail:`) and the `.empty-state` class for one release. Its message moves from `text-lg` to the type scale's `type-body` and its detail from `text-sm` to `type-fine`, so an existing empty state reads a step smaller. New code renders the component.
20
+ - **Glass is the standard for a new app.** Alex McRitchie ruled on 2026-11-09 that glass surfaces and the glass sidebar are the standard. The new-app initializer in `docs/NEW_APP_SETUP.md` now sets `config.surface_style = :glass` and `config.sidebar_style = :glass`, and its layout writes `data-surface-style` on `<html>`. The engine default stays `:default`, so an existing app does not change when it bumps the gem. Nothing to do unless you start a new app.
21
+ - **The glass sidebar takes the app's colours from root tokens.** It hard-coded the hub's violet, so every app that adopted it restated the shell's `--glass-*` variables. The shell now reads `--glass-sidebar-fill` and `--glass-sidebar-tint` (`R G B`) and `--glass-sidebar-ink` and `--glass-sidebar-soft-ink` from `:root` and `.dark`, and their defaults are the old violet values, so the hub and every app that sets none paint exactly as before. An app that overrides the `--glass-*` variables on the shell keeps working; it can move those values to the four tokens (example in `docs/NEW_APP_SETUP.md` § 9).
22
+
23
+ ### Fixed
24
+
25
+ - **A looping carousel that gets no `scrollend` still comes home.** Chrome can send a scroll event and no `scrollend` (for example, when the page shortens and the browser pulls the scroll back). A looping row (`loop: true`, plain or spotlight) that glided across the seam then rested on a clone: it did not jump to the real slide and did not fire `studio:carousel:change`. `studio/carousel` now has `watchRest`: `scrollend` rests the row, and 450 ms after the last scroll event (`REST_CAP_MS`, three of the 150 ms quiet spells, `SETTLE_MS`) is the backstop. After the controller's own scroll, a `scrollend` counts only once a scroll event has come since that scroll, so the end of a replaced glide does not cut the new one short. The change event still fires once per move. A cascade row does not scroll, so this does not change it. Nothing for a consumer to do.
26
+ - **A tab's jump stays on its section when a lazy frame above it loads after the jump.** The body has scroll anchoring off (`studio/_head`), so a lazy frame that the smooth scroll passed and that landed after the scroll ended (the style guide's Loaders feed, 88px above Modals) pushed the section down under the pinned row, and the tab above it took the highlight. For 3 seconds after the end (`HOLD_MS`), `studio/in_page_links` now moves the page with the target, as scroll anchoring would, until the reader's first wheel, touch, key or press, or any scroll it did not make. A `scrollend` that comes after the jump aims again but before that aim's first scroll (Chrome sends the replaced scroll's end) no longer ends the jump short. Where the browser fires `scrollend`, a scroll that `scrollend` never follows (a page that shortens, and the browser pulls the scroll back) now ends the jump 400 ms after its last scroll event; before, the jump stayed open and the tab stayed highlighted. Nothing for a consumer to do.
27
+
7
28
  ## 0.141.3 — 2026-11-09
8
29
 
9
30
  ### Fixed
@@ -16,7 +37,7 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
16
37
  ### Fixed
17
38
 
18
39
  - **The link-preview card shows text typed before its controller loads.** The `link-preview-card` controller is registered lazily, so an operator could type in /admin/link_preview before it listened, and the card kept the saved title. `connect()` now paints the card from the fields as they stand (`paintAll` in `studio/link_preview_card`). No host change.
19
- - **An in-page jump ends at the browser's scrollend, not at a quiet spell.** `studio/in_page_links` ended a smooth jump after 150 ms with no scroll event, even in a browser that fires `scrollend`. On a slow main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than that, so the jump ended at its first frame: the tab highlight followed the passing sections, and a target that moved on the way (a lazy feed above it) was not aimed at again. Now only `scrollend` ends a scroll that moved, where the browser has it; the quiet spell stays for a browser without it, and the 400 ms cap still ends a scroll that never starts. Nothing for a consumer to do.
40
+ - **An in-page jump ends at the browser's scrollend, not at a quiet spell.** `studio/in_page_links` ended a smooth jump after 150 ms with no scroll event, even in a browser that fires `scrollend`. On a slow main thread (a phone, or CI at 8x CPU) a smooth scroll's frames come further apart than that, so the jump ended at its first frame: the tab highlight followed the passing sections, and a target that moved on the way (a lazy feed above it) was not aimed at again. Now only `scrollend` ends a scroll that moved, where the browser has it; the quiet spell stays for a browser without it, and the 400 ms cap still ends a scroll that never starts. A moved target is re-aimed only for growth before `scrollend`: growth after the end, and a scroll that `scrollend` never follows, are fixed in the next release (engine-anchor-late-growth). Nothing for a consumer to do.
20
41
 
21
42
  ## 0.141.1 — 2026-11-09
22
43
 
data/README.md CHANGED
@@ -35,6 +35,7 @@ resolved.
35
35
  - **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
36
36
  - **Device sessions**: a logout ends that device's session on the server, so a copy of the pre-logout cookie no longer signs in; other devices stay signed in. One indexed lookup per signed-in request, legacy cookies accepted until a grace date, rows pruned after 90 idle days, no host code. See [`docs/DEVICE_SESSIONS.md`](docs/DEVICE_SESSIONS.md).
37
37
  - **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
38
+ - **Glass is the standard** (Alex McRitchie, 2026-11-09): a new app sets `config.surface_style = :glass` and `config.sidebar_style = :glass`, and writes `data-surface-style` on its layout's `<html>` ([`docs/NEW_APP_SETUP.md`](docs/NEW_APP_SETUP.md) § 4 and § 9). The engine default stays `:default`, so an existing app moves only when it asks. The glass sidebar takes the app's colours from four root tokens, `--glass-sidebar-fill`, `--glass-sidebar-ink`, `--glass-sidebar-soft-ink` and `--glass-sidebar-tint`, whose defaults are the hub's violet. `.lit-tile` and `.lit-tile-soft` light an app's own call-to-action tile.
38
39
  - **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
39
40
  - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
40
41
  - **Sluggable concern**: a human-readable slug written once at create (`to_param` returns it); `rename_slug!` changes it and cascades to every child column in one transaction, any other write that changes a persisted slug is invalid, and a refused rename answers 422
@@ -298,6 +299,50 @@ actions; the engine supplies only the banner.
298
299
 
299
300
  ## UI Primitives
300
301
 
302
+ ### Button, empty state and table — `Studio::ButtonComponent`, `Studio::EmptyStateComponent`, `Studio::TableComponent`
303
+
304
+ Three ViewComponents every app renders instead of hand-building the part. Each
305
+ one's class comment lists its public surface; `/admin/style` draws every tone,
306
+ size and state live (Components section), and the gallery has a preview per state.
307
+
308
+ ```erb
309
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit") %>
310
+ <%= render Studio::ButtonComponent.new(label: "Delete", tone: :danger, size: :sm) %>
311
+ <%= render Studio::ButtonComponent.new(label: "Docs", tone: :quiet, href: docs_path) %>
312
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
313
+
314
+ <%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start.")) do |empty| %>
315
+ <% empty.with_action(label: "Invite", href: new_invite_path) %>
316
+ No members yet.
317
+ <% end %>
318
+
319
+ <%= render Studio::TableComponent.new(
320
+ rows: @users, sort: params[:sort], direction: params[:direction], empty: "No users yet.",
321
+ columns: [
322
+ { key: :name, label: "User", sortable: true },
323
+ { key: :created_at, label: "Joined", align: :right, cell: ->(u) { l(u.created_at.to_date) } }
324
+ ]) %>
325
+ ```
326
+
327
+ - **Button.** Tones map onto the shipped utilities: `primary` is `btn-primary`,
328
+ `secondary` is `btn-secondary` (the success role, or the app's
329
+ `--btn-secondary-*` retune), `quiet` is `btn-neutral`, `danger` is `btn-danger`.
330
+ Sizes are `btn-sm`, bare `btn` and `btn-lg`. `loading:` uses the aria-busy
331
+ contract (docs/LOADER.md): the button is `aria-busy` and disabled and shows the
332
+ pending spinner. A submit button always carries the hidden spinner, so a Turbo
333
+ form that marks itself busy shows it with no script. A link cannot be loading.
334
+ - **Empty state.** The `.empty-state` card, the message at `type-body`, the
335
+ detail at `type-fine`, an optional icon, and at most one action, which is a
336
+ `Studio::ButtonComponent`. `framed: false` drops the card. The
337
+ `components/empty_state` partial renders it for one release.
338
+ - **Table.** Columns and rows. Sorting is plain links: a sortable header links to
339
+ the current URL with `?sort=<key>&direction=<asc|desc>` (page dropped), the
340
+ sorted header carries `aria-sort` and an arrow, and the controller sorts.
341
+ `sort_url:` takes a callable for another URL shape. No rows renders one row with
342
+ a bare empty state. On a phone the table scrolls sideways, as every engine table
343
+ does; `min_width:` sets where. `sticky_header: true` adds
344
+ `data-sticky-table-header`.
345
+
301
346
  ### The page header — `studio/_page_header`
302
347
 
303
348
  An admin page's title: a crumb trail from the app's one parent map (`Studio.crumbs`,
@@ -671,6 +716,63 @@ the block:
671
716
  <% end %>
672
717
  ```
673
718
 
719
+ #### Modals on demand — `Studio.modals`
720
+
721
+ A layout that renders every modal template into every page ships them all on
722
+ every request, opened or not. Register a modal instead, and the shared host
723
+ fetches it the first time it opens:
724
+
725
+ ```ruby
726
+ # config/initializers/studio.rb
727
+ Studio.modals.register :contest_rules, partial: "modals/contest_rules"
728
+ Studio.modals.register :wallet_setup, partial: "modals/wallet_setup",
729
+ auth: :user, locals: :wallet_setup_modal_locals
730
+ Studio.modals.register :auth, partial: "modals/auth", prefetch: true
731
+ ```
732
+
733
+ Then delete that modal's `<template x-if>` from the layout. Call sites do not
734
+ change: `$store.modals.open("contest_rules", props)` works as before.
735
+
736
+ | Option | Meaning |
737
+ |--------|---------|
738
+ | `partial:` | The content partial, as the layout rendered it. The two rules below still apply (one root, no double quote in `x-data`). |
739
+ | `auth:` | `:any` (default) or `:user`. A `:user` modal answers `401` to a signed-out request and is never prefetched for one. |
740
+ | `prefetch:` | `true` fetches the modal when the browser idles after load (`requestIdleCallback`, else a timeout), so it opens with no wait. Use it for a modal opened on most visits. |
741
+ | `locals:` | A Hash, a Symbol naming a helper the view answers (an existing `*_modal_locals` helper), or a callable taking the view. Resolved per request, in the view. |
742
+
743
+ Ids are lower-case letters, digits, `-` and `_`.
744
+
745
+ **What happens.** The host advertises the registered ids on its template
746
+ (`data-modal-host-remote-value`). `open(id)` for one that is not in the page
747
+ yet fetches `GET /_studio/modals/<id>` (`Studio::ModalsController`), wraps the
748
+ fragment in the same `<template x-if>` a layout would render, appends it to the
749
+ card, and opens. A fetch that answers within 150 ms opens with no loading state.
750
+ A slower one opens the card with the engine's processing card, held at least
751
+ `StudioModals.MIN_LOAD_MS`, then the content replaces it. A failed fetch (a
752
+ network error, a `500`, a `401`, or a host filter's redirect) shows the
753
+ engine's error card, whose button closes; the next open tries again.
754
+
755
+ The fragment is held for the page's lifetime, across Turbo visits: a later open
756
+ inserts it from memory into the new page's host, once, with no second request.
757
+ A fetch that lands after a Turbo visit opens nothing on the new page.
758
+
759
+ **What does not change.** An id the registry does not name takes the old
760
+ synchronous `open()` path, and an app that registers nothing renders the host
761
+ exactly as before (no advertisement, no card marker, no loading or error card).
762
+ One difference for a registered id: during the first open's fetch,
763
+ `isOpen(id)` is false until the card appears.
764
+
765
+ **The endpoint.** It renders only registered ids (any other answers `404`),
766
+ with no layout. It skips the host's `require_authentication` so an `:any`
767
+ modal serves a signed-out visitor, and answers a `:user` modal's signed-out
768
+ request with a bare `401`, never the `/login` redirect. Each answer is
769
+ `private` with `Vary: Cookie` and an ETag. A fragment carries no script, so
770
+ the page's CSP is unchanged. Only the shared host (`$store.modals`) loads on
771
+ demand; a scoped host renders its modals as before.
772
+
773
+ **Do not do both.** An id that the registry names and the layout still
774
+ renders mounts twice. Move each modal in one change.
775
+
674
776
  #### Writing a modal's content partial — two rules the host imposes
675
777
 
676
778
  Both rules let the card render and then do less than it looks like it does.
@@ -1029,7 +1131,7 @@ Two seams, both empty by default, so the navbar is unchanged until an app opts i
1029
1131
  - `sidebar_sections` fills the slide-out link sidebar (the cog trigger in the icon
1030
1132
  rail). Rules: `lib/studio/sidebar_sections.rb`. `Studio.sidebar_style = :glass` floats it
1031
1133
  as an inset translucent panel with line icons (`studio_icon`, `lib/studio/icons/README.md`);
1032
- see `docs/NAVBAR_SETUP.md`.
1134
+ see `docs/NAVBAR_SETUP.md`. Glass is the standard for a new app.
1033
1135
  - `navbar_links` fills the navbar's own link slots: the desktop bar beside the
1034
1136
  logo and the phone row under it. Rules: `lib/studio/navbar_links.rb`.
1035
1137
 
@@ -1709,6 +1709,19 @@ body.modal-open .studio-app-banner {
1709
1709
  @apply px-8 py-3 text-base rounded-xl shadow-sm;
1710
1710
  }
1711
1711
 
1712
+ /* A busy button (Studio::ButtonComponent loading:, or a submit Turbo marks busy) is
1713
+ disabled so it takes no second click, but it is working, not unavailable: it keeps
1714
+ its full colour and shows the progress cursor. The busy words sit in the button's
1715
+ own gap-2, so the Pending rule's margin (engine-motion.css) is dropped here. */
1716
+ .btn[aria-busy="true"]:disabled {
1717
+ opacity: 1;
1718
+ cursor: progress;
1719
+ }
1720
+
1721
+ .btn[aria-busy="true"] [data-loader-busy] {
1722
+ margin-inline-start: 0;
1723
+ }
1724
+
1712
1725
  /* -- Link sidebar (out-of-the-box navigation) ------------------------------
1713
1726
  Paired with components/_link_sidebar + _link_sidebar_trigger and the
1714
1727
  Studio.sidebar_sections config. A full-cover drawer, so it takes the shared
@@ -2337,7 +2350,30 @@ html[data-theme-flip]::view-transition-new(*) {
2337
2350
  and below them.
2338
2351
 
2339
2352
  THE PHONE gets a narrower inset, the full width and a lighter blur, which costs less
2340
- through the slide. */
2353
+ through the slide.
2354
+
2355
+ THE COLOURS ARE THE APP'S, from four tokens on the root. The values below are the hub's
2356
+ violet, so an app that sets none looks like the hub. An app sets them in its own
2357
+ stylesheet, unlayered, on :root and again on .dark (an unlayered :root also wins in the
2358
+ dark theme, so a token set only on :root carries into it):
2359
+ --glass-sidebar-fill the panel, as "R G B" (the shell adds the alpha)
2360
+ --glass-sidebar-ink the label colour
2361
+ --glass-sidebar-soft-ink headers and descriptions; keep 4.5:1 on the fill
2362
+ --glass-sidebar-tint the light theme's hover, hairline and shadow, as "R G B"
2363
+ The light theme's thin fill stays white, and the dark theme's hover, hairline and shadow
2364
+ stay white and black, so the dark block sets no tint. The --glass-* variables on the shell
2365
+ still take a direct override, as the apps that adopted glass before these tokens do. */
2366
+ :root {
2367
+ --glass-sidebar-fill: 250 249 253;
2368
+ --glass-sidebar-ink: #1d1832;
2369
+ --glass-sidebar-soft-ink: #474260;
2370
+ --glass-sidebar-tint: 29 24 50;
2371
+ }
2372
+ .dark {
2373
+ --glass-sidebar-fill: 36 30 62;
2374
+ --glass-sidebar-ink: #f1effa;
2375
+ --glass-sidebar-soft-ink: #dedaed; /* 4.5:1 on the thin fill alone over white, if a blur never paints */
2376
+ }
2341
2377
  [data-sidebar-glass] {
2342
2378
  --sidebar-glass-inset: 0.75rem;
2343
2379
  width: 19.5rem;
@@ -2351,14 +2387,14 @@ html[data-theme-flip]::view-transition-new(*) {
2351
2387
  --glass-icon-gap: 0.625rem;
2352
2388
  --glass-section-gap: 1rem;
2353
2389
  --sidebar-row-lift: 0.4375rem;
2354
- --glass-fill: rgb(250 249 253 / 0.97);
2390
+ --glass-fill: rgb(var(--glass-sidebar-fill) / 0.97);
2355
2391
  --glass-thin-fill: rgb(255 255 255 / 0.72);
2356
2392
  --glass-filter: blur(24px) saturate(150%) brightness(1.15);
2357
- --glass-ink: #1d1832;
2358
- --glass-soft-ink: #474260;
2359
- --glass-hover: rgb(29 24 50 / 0.08);
2360
- --glass-line: rgb(29 24 50 / 0.14);
2361
- --glass-shadow: 0 24px 60px rgb(29 24 50 / 0.24);
2393
+ --glass-ink: var(--glass-sidebar-ink);
2394
+ --glass-soft-ink: var(--glass-sidebar-soft-ink);
2395
+ --glass-hover: rgb(var(--glass-sidebar-tint) / 0.08);
2396
+ --glass-line: rgb(var(--glass-sidebar-tint) / 0.14);
2397
+ --glass-shadow: 0 24px 60px rgb(var(--glass-sidebar-tint) / 0.24);
2362
2398
  pointer-events: auto;
2363
2399
  overflow: hidden;
2364
2400
  min-height: 0;
@@ -2371,11 +2407,11 @@ html[data-theme-flip]::view-transition-new(*) {
2371
2407
  font-size: 0.84375rem;
2372
2408
  }
2373
2409
  .dark [data-sidebar-glass-shell] {
2374
- --glass-fill: rgb(36 30 62 / 0.97);
2375
- --glass-thin-fill: rgb(36 30 62 / 0.72);
2410
+ --glass-fill: rgb(var(--glass-sidebar-fill) / 0.97);
2411
+ --glass-thin-fill: rgb(var(--glass-sidebar-fill) / 0.72);
2376
2412
  --glass-filter: blur(24px) saturate(150%) brightness(0.8);
2377
- --glass-ink: #f1effa;
2378
- --glass-soft-ink: #dedaed; /* 4.5:1 on the thin fill alone over white, if a blur never paints */
2413
+ --glass-ink: var(--glass-sidebar-ink);
2414
+ --glass-soft-ink: var(--glass-sidebar-soft-ink);
2379
2415
  --glass-hover: rgb(255 255 255 / 0.1);
2380
2416
  --glass-line: rgb(255 255 255 / 0.14);
2381
2417
  --glass-shadow: 0 24px 60px rgb(0 0 0 / 0.5);
@@ -2509,13 +2545,27 @@ html[data-theme-flip]::view-transition-new(*) {
2509
2545
  .bg-surface.rounded-xl.border. NOT a menu, popover or modal: anything .absolute, .fixed,
2510
2546
  .sticky or .shadow-2xl, or inside a .fixed scrim or a dialog, stays solid, because it
2511
2547
  floats over text and has no blur to protect it. Under prefers-reduced-transparency
2512
- every surface goes solid. */
2548
+ every surface goes solid.
2549
+
2550
+ THE TINT IS THE APP'S, from two tokens on the root, as "R G B". The values below are the
2551
+ hub's violet, so an app that sets none looks like the hub. An app sets them in its own
2552
+ stylesheet, unlayered (in @layer components the engine's default wins):
2553
+ --glass-surface-tint the light theme's hairline (--surface-glass-line) and the
2554
+ soft lights' shade (--lit-soft-bottom, --lit-soft-pressed,
2555
+ --lit-badge, --lit-input)
2556
+ --glass-surface-shadow-tint the light theme's card shadow (--surface-glass-shadow)
2557
+ The dark theme's hairline, lights and shadows are white and black, so neither token
2558
+ reaches it, and a token set only on :root is safe there. */
2559
+ :root {
2560
+ --glass-surface-tint: 29 24 50;
2561
+ --glass-surface-shadow-tint: 60 50 120;
2562
+ }
2513
2563
  html[data-surface-style="glass"] {
2514
2564
  --surface-glass-fill: color-mix(in srgb, var(--color-surface) 62%, transparent);
2515
2565
  --surface-glass-fill-hover: color-mix(in srgb, var(--color-surface) 80%, transparent);
2516
- --surface-glass-line: rgb(29 24 50 / 0.1);
2566
+ --surface-glass-line: rgb(var(--glass-surface-tint) / 0.1);
2517
2567
  --surface-glass-highlight: inset 0 1px 0 rgb(255 255 255 / 0.7);
2518
- --surface-glass-shadow: 0 6px 20px rgb(60 50 120 / 0.08);
2568
+ --surface-glass-shadow: 0 6px 20px rgb(var(--glass-surface-shadow-tint) / 0.08);
2519
2569
  --surface-glass-filter: blur(16px) saturate(140%);
2520
2570
  --surface-glass-ambient:
2521
2571
  radial-gradient(60vw 55vh at 10% 5%, rgb(var(--color-primary-rgb) / 0.17), transparent 70%),
@@ -2649,11 +2699,11 @@ html[data-theme-flip]::view-transition-new(*) {
2649
2699
  --lit-glow: 0 8px 24px -6px;
2650
2700
  --lit-glow-alpha: 0.6;
2651
2701
  --lit-soft-top: inset 0 1px 0 rgb(255 255 255 / 0.8);
2652
- --lit-soft-bottom: inset 0 -1px 0 rgb(29 24 50 / 0.08);
2702
+ --lit-soft-bottom: inset 0 -1px 0 rgb(var(--glass-surface-tint) / 0.08);
2653
2703
  --lit-pressed: inset 0 2px 4px rgb(0 0 0 / 0.27);
2654
- --lit-soft-pressed: inset 0 2px 3px rgb(29 24 50 / 0.16);
2655
- --lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.5), 0 1px 1px rgb(29 24 50 / 0.12);
2656
- --lit-input: inset 0 1px 2px rgb(29 24 50 / 0.14), inset 0 -1px 0 rgb(255 255 255 / 0.6);
2704
+ --lit-soft-pressed: inset 0 2px 3px rgb(var(--glass-surface-tint) / 0.16);
2705
+ --lit-badge: inset 0 1px 0 rgb(255 255 255 / 0.5), 0 1px 1px rgb(var(--glass-surface-tint) / 0.12);
2706
+ --lit-input: inset 0 1px 2px rgb(var(--glass-surface-tint) / 0.14), inset 0 -1px 0 rgb(255 255 255 / 0.6);
2657
2707
  }
2658
2708
  html.dark[data-surface-style="glass"] {
2659
2709
  --lit-bottom: inset 0 -2px 0 rgb(0 0 0 / 0.3);
@@ -2754,6 +2804,28 @@ html[data-theme-flip]::view-transition-new(*) {
2754
2804
  html[data-surface-style="glass"] .badge { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-badge); }
2755
2805
  html[data-surface-style="glass"] .input-field { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-input); }
2756
2806
 
2807
+ /* A LIT TILE: an app's own call to action that is not a .btn, such as a square tile with a
2808
+ brand fill, takes the same light. .lit-tile is a filled tile: a top light, a bottom shade
2809
+ and a contact shadow. .lit-tile-soft is an outlined or pale one, with the outline button's
2810
+ softer light. Shadows only, so no box moves, the corners stay the app's and the fill stays
2811
+ exact. A press flips the light inset and, with motion allowed, moves the tile 1px. With no
2812
+ glass on the root, both classes do nothing. */
2813
+ html[data-surface-style="glass"] .lit-tile {
2814
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-top), var(--lit-bottom), var(--lit-contact);
2815
+ }
2816
+ html[data-surface-style="glass"] .lit-tile-soft {
2817
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-soft-top), var(--lit-soft-bottom), var(--lit-contact);
2818
+ }
2819
+ html[data-surface-style="glass"] .lit-tile:is(:active, [data-lit-state="pressed"]) {
2820
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-pressed);
2821
+ }
2822
+ html[data-surface-style="glass"] .lit-tile-soft:is(:active, [data-lit-state="pressed"]) {
2823
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--lit-soft-pressed);
2824
+ }
2825
+ @media (prefers-reduced-motion: no-preference) {
2826
+ html[data-surface-style="glass"] :is(.lit-tile, .lit-tile-soft):is(:active, [data-lit-state="pressed"]) { translate: 0 1px; }
2827
+ }
2828
+
2757
2829
  /* BUTTON TRICKS ON A LIT BUTTON (the lit rules above).
2758
2830
  A lit button already uses its box-shadow for its contact shadow and glow, and
2759
2831
  its ::after for its inset light. These rules sit in the utilities layer and the
@@ -0,0 +1,12 @@
1
+ <div class="space-y-3" data-specimen="button-disabled">
2
+ <div class="flex flex-wrap items-center gap-3">
3
+ <% tones.each do |tone| %>
4
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, disabled: true) %>
5
+ <% end %>
6
+ </div>
7
+ <div class="flex flex-wrap items-center gap-3">
8
+ <% tones.each do |tone| %>
9
+ <%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} link", tone: tone, href: "#", disabled: true) %>
10
+ <% end %>
11
+ </div>
12
+ </div>
@@ -0,0 +1,7 @@
1
+ <div class="flex flex-wrap items-center gap-3" data-specimen="button-icon-link">
2
+ <%= render(Studio::ButtonComponent.new(label: "New task")) do |button| %>
3
+ <% button.with_icon do %><svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M10 4a1 1 0 0 1 1 1v4h4a1 1 0 1 1 0 2h-4v4a1 1 0 1 1-2 0v-4H5a1 1 0 1 1 0-2h4V5a1 1 0 0 1 1-1Z"/></svg><% end %>
4
+ <% end %>
5
+ <%= render Studio::ButtonComponent.new(label: "Read the docs", tone: :quiet, href: "#") %>
6
+ <%= render Studio::ButtonComponent.new(label: "Remove", tone: :danger, size: :sm, href: "#") %>
7
+ </div>
@@ -0,0 +1,12 @@
1
+ <div class="space-y-3" data-specimen="button-loading">
2
+ <div class="flex flex-wrap items-center gap-3">
3
+ <% tones.each do |tone| %>
4
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone, loading: true) %>
5
+ <% end %>
6
+ </div>
7
+ <div class="flex flex-wrap items-center gap-3">
8
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", loading: true, busy_label: "Saving…") %>
9
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", size: :sm, loading: true, busy_label: "Saving…") %>
10
+ <%= render Studio::ButtonComponent.new(label: "Save", type: "submit", size: :lg, loading: true, busy_label: "Saving…") %>
11
+ </div>
12
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="space-y-3" data-specimen="button-sizes">
2
+ <% tones.each do |tone| %>
3
+ <div class="flex flex-wrap items-center gap-3">
4
+ <% sizes.each do |size| %>
5
+ <%= render Studio::ButtonComponent.new(label: "#{tone.to_s.capitalize} #{size}", tone: tone, size: size) %>
6
+ <% end %>
7
+ </div>
8
+ <% end %>
9
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="flex flex-wrap items-center gap-3" data-specimen="button-tones">
2
+ <% tones.each do |tone| %>
3
+ <%= render Studio::ButtonComponent.new(label: tone.to_s.capitalize, tone: tone) %>
4
+ <% end %>
5
+ </div>
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Every state Studio::ButtonComponent can show: the four tones, the three
5
+ # sizes, a link, an icon, disabled and loading. No @param tags: the gallery
6
+ # takes no input.
7
+ class ButtonComponentPreview < ViewComponent::Preview
8
+ # The four tones, at the default size.
9
+ def tones
10
+ render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
11
+ end
12
+
13
+ # The three sizes, in each tone.
14
+ def sizes
15
+ render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys, sizes: Studio::ButtonComponent::SIZES.keys })
16
+ end
17
+
18
+ # Disabled, as a button and as a link, in each tone.
19
+ def disabled
20
+ render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
21
+ end
22
+
23
+ # Loading: aria-busy and disabled, the pending loader shown, with and
24
+ # without busy words.
25
+ def loading
26
+ render_with_template(locals: { tones: Studio::ButtonComponent::TONES.keys })
27
+ end
28
+
29
+ # An icon before the label, and a link that looks like a button.
30
+ def icon_and_link
31
+ render_with_template
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ <%= render(Studio::EmptyStateComponent.new(detail: "Invite someone to start the team.")) do |empty| %>
2
+ <% empty.with_icon do %><svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M2.25 13.5V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.5M2.25 13.5l2.4-7.2A2.25 2.25 0 0 1 6.784 4.5h10.432a2.25 2.25 0 0 1 2.134 1.8l2.4 7.2"/></svg><% end %>
3
+ <% empty.with_action(label: "Invite a member", href: "#") %>
4
+ No members yet.
5
+ <% end %>
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Every state Studio::EmptyStateComponent can show: the message alone, with
5
+ # detail, with an icon and an action, and bare (inside a frame of its own).
6
+ # No @param tags: the gallery takes no input.
7
+ class EmptyStateComponentPreview < ViewComponent::Preview
8
+ # The message alone: what the components/empty_state partial renders.
9
+ def message
10
+ render Studio::EmptyStateComponent.new(message: "No errors logged yet.")
11
+ end
12
+
13
+ # A message and one line of detail.
14
+ def with_detail
15
+ render Studio::EmptyStateComponent.new(message: "No errors logged yet.",
16
+ detail: "Errors appear here when ErrorLog.capture! records one.")
17
+ end
18
+
19
+ # An icon, the message, the detail and the one action.
20
+ def with_icon_and_action
21
+ render_with_template
22
+ end
23
+
24
+ # Without the card, for a place that has its own frame.
25
+ def bare
26
+ render Studio::EmptyStateComponent.new(message: "Nothing matches that filter.", framed: false)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,14 @@
1
+ <%
2
+ tones = { "passed" => :success, "blocked" => :danger, "waiting" => :warning }
3
+ columns = [
4
+ { key: :name, label: "Member", sortable: true },
5
+ { key: :role, label: "Role" },
6
+ { key: :tasks, label: "Tasks", sortable: true, align: :right },
7
+ { key: :status, label: "Status", align: :center,
8
+ cell: ->(row) { render Studio::BadgeComponent.new(text: row[:status], tone: tones.fetch(row[:status], :muted)) } }
9
+ ]
10
+ %>
11
+ <div class="card overflow-hidden" data-specimen="table">
12
+ <%= render Studio::TableComponent.new(rows: rows, columns: columns, sort: sort, direction: direction,
13
+ sort_url: ->(key, dir) { "?sort=#{key}&direction=#{dir}" }, empty: "No members yet.", min_width: 520) %>
14
+ </div>
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Every state Studio::TableComponent can show: rows, a sorted column in each
5
+ # direction, and the empty row. No @param tags: the gallery takes no input.
6
+ class TableComponentPreview < ViewComponent::Preview
7
+ ROWS = [
8
+ { name: "Ada Lovelace", role: "Admin", tasks: 12, status: "passed" },
9
+ { name: "Grace Hopper", role: "Builder", tasks: 7, status: "blocked" },
10
+ { name: "Katherine Johnson", role: "Reviewer", tasks: 3, status: "waiting" }
11
+ ].freeze
12
+
13
+ # Rows, a badge cell, and no sort.
14
+ def rows
15
+ render_with_template(locals: { rows: ROWS, sort: nil, direction: nil })
16
+ end
17
+
18
+ # Sorted by name, ascending: aria-sort and the up arrow.
19
+ def sorted_ascending
20
+ render_with_template(template: "studio/table_component_preview/rows", locals: { rows: ROWS, sort: "name", direction: "asc" })
21
+ end
22
+
23
+ # Sorted by tasks, descending.
24
+ def sorted_descending
25
+ render_with_template(template: "studio/table_component_preview/rows", locals: { rows: ROWS.sort_by { |r| -r[:tasks] }, sort: "tasks", direction: "desc" })
26
+ end
27
+
28
+ # No rows: the empty row, an EmptyStateComponent across every column.
29
+ def empty
30
+ render_with_template(template: "studio/table_component_preview/rows", locals: { rows: [], sort: nil, direction: nil })
31
+ end
32
+ end
33
+ end
@@ -0,0 +1 @@
1
+ <%= content_tag(link? ? :a : :button, **element_attributes) do %><% if pending_loader? %><%= studio_loader_pending(style: :spinner, size: 16) %><% end %><% if icon? %><span class="inline-flex shrink-0" aria-hidden="true" data-loader-idle><%= icon %></span><% end %><% if busy_label.present? && !link? %><span data-loader-idle><%= text %></span><span data-loader-busy><%= busy_label %></span><% else %><span><%= text %></span><% end %><% end %>