studio-engine 0.77.4 → 0.79.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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +28 -0
- data/app/assets/tailwind/studio_engine/engine.css +5 -2
- data/app/helpers/studio_navbar_helper.rb +24 -0
- data/app/views/components/_user_nav.html.erb +35 -26
- data/app/views/layouts/_navbar.html.erb +16 -3
- data/app/views/layouts/studio/_flash.html.erb +1 -1
- data/app/views/studio/board/_board.html.erb +1 -1
- data/app/views/studio/modals/shared/_email_field.html.erb +1 -1
- data/app/views/style/_modal_specimen.html.erb +1 -1
- data/app/views/style/_modals.html.erb +6 -6
- data/app/views/style/_tasks.html.erb +2 -2
- data/app/views/style/_theme.html.erb +2 -1
- data/app/views/style/_tricks.html.erb +3 -3
- data/app/views/theme_settings/edit.html.erb +4 -4
- data/lib/studio/navbar_links.rb +92 -0
- data/lib/studio/theme_resolver.rb +56 -9
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +26 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a41688fc9d2fd9a08b96ea00d90e971d3be7618577acf83887d7c64b71076d3
|
|
4
|
+
data.tar.gz: 6930fd3598433185b2c578ebc2f287a54c972828ab73f068800521f76793a82a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c089670e6c2ba85bac8a7851f13f4fa033801e738583974d7207d1960ebccf181530feec5ef3c4cd8f4ae5300d793b5d54eaeb91f46cd9732287f2d44d31ab13
|
|
7
|
+
data.tar.gz: c6e24cd7de1298e73e58309da359ffcdae06d150e0b13afba52f90f8864782d6c197b22cf7b61279d88a1d98e52b562eafb7f4e76cd968f6088bb77c3fb3b011
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.79.0 — 2026-09-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The signed-in user nav fits a phone.** `components/_user_nav` draws its theme
|
|
12
|
+
toggle from `md` up only (the navbar's phone row already has one, so phones
|
|
13
|
+
showed two), and the name and avatar are now ONE link to the account page, one
|
|
14
|
+
tab stop, where they were two links to the same place. Below `md` the avatar
|
|
15
|
+
stands alone and the name is screen-reader text. The desktop sidebar button
|
|
16
|
+
passed as `extra_icons_html`, the admin menu, the account destination, the
|
|
17
|
+
second row and the banners are unchanged. From `md` up the name now sits beside
|
|
18
|
+
the avatar, centred on it, rather than on the top row. Apps that shadow the
|
|
19
|
+
partial (mcritchie-studio, turf-monster, cyvasse) are unaffected until they
|
|
20
|
+
drop their copy.
|
|
21
|
+
- **Default button fills clear WCAG AA under their white label.** The default
|
|
22
|
+
success green is `#367E3A` (5.0:1 under white), was `#4BAF50` (2.78:1); a
|
|
23
|
+
configured success colour paints as before. While an app leaves its primary
|
|
24
|
+
at the engine default `#8E82FE` (3.1:1), the resolver emits `--color-cta-fill`
|
|
25
|
+
(`#7268CB`), which `btn-primary`, `btn-outline`'s hover and the user nav's
|
|
26
|
+
level bar paint; `--color-cta` stays the violet, because apps also paint it
|
|
27
|
+
as text on dark surfaces. A configured primary emits no fill, so those fall
|
|
28
|
+
back to the app's own `--color-cta` as before. `--color-cta-hover` is held to
|
|
29
|
+
AA too, unchanged wherever `darken(primary, 0.30)` already passes. Engine views
|
|
30
|
+
that painted text or icons with the raw `--color-success` now read
|
|
31
|
+
`--color-success-ink` (the darker default green is 3.49:1 as text on the dark
|
|
32
|
+
page).
|
|
33
|
+
|
|
34
|
+
## 0.78.0 — 2026-09-29
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`Studio.navbar_links` puts app links in the engine navbar.** An Array, or a
|
|
39
|
+
callable receiving the view, of `{ label:, href:, active:, badge: }` renders in
|
|
40
|
+
the desktop bar and the phone row, with `aria-current` on the active link and
|
|
41
|
+
an optional badge such as `"#12"`. An app that registers nothing renders the
|
|
42
|
+
same navbar bytes as before. See the README, *Navigation*.
|
|
43
|
+
|
|
7
44
|
## 0.77.4 — 2026-09-28
|
|
8
45
|
|
|
9
46
|
### Fixed
|
data/README.md
CHANGED
|
@@ -583,6 +583,34 @@ undefined, so mount `studio/modals/host` before writing a trigger.
|
|
|
583
583
|
Full contract, including what the partial may assume: the doc comment at the top
|
|
584
584
|
of `app/views/style/_host.html.erb`.
|
|
585
585
|
|
|
586
|
+
### Navigation — `Studio.sidebar_sections` and `Studio.navbar_links`
|
|
587
|
+
|
|
588
|
+
Two seams, both empty by default, so the navbar is unchanged until an app opts in.
|
|
589
|
+
|
|
590
|
+
- `sidebar_sections` fills the slide-out link sidebar (the cog trigger in the icon
|
|
591
|
+
rail). Rules: `lib/studio/sidebar_sections.rb`.
|
|
592
|
+
- `navbar_links` fills the navbar's own link slots: the desktop bar beside the
|
|
593
|
+
logo and the phone row under it. Rules: `lib/studio/navbar_links.rb`.
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
Studio.configure do |config|
|
|
597
|
+
# An Array, or a callable receiving the view (so a badge can read current_user).
|
|
598
|
+
config.navbar_links = ->(view) {
|
|
599
|
+
[ { label: "Contests", href: view.contests_path, active: %r{\A/contests} },
|
|
600
|
+
{ label: "Rank", href: "/rank",
|
|
601
|
+
badge: (view.logged_in? ? "##{view.current_user.rank}" : nil) } ]
|
|
602
|
+
}
|
|
603
|
+
end
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
- `label:` and `href:` are required; both are escaped.
|
|
607
|
+
- `active:` is `true`/`false`, a Regexp, or a callable taking `request.path`.
|
|
608
|
+
Omitted, a link is active when the path equals its href's path. The active
|
|
609
|
+
link gets `aria-current="page"` and the primary tone.
|
|
610
|
+
- `badge:` is optional short text, such as `"#12"`, shown as a pill in the link.
|
|
611
|
+
- A bad entry raises `Studio::NavbarLinks::InvalidLink` naming its index: at
|
|
612
|
+
assignment for a static Array, at render for a callable.
|
|
613
|
+
|
|
586
614
|
### User nav slots
|
|
587
615
|
|
|
588
616
|
`components/_user_nav.html.erb` renders the right-side navbar user section.
|
|
@@ -230,9 +230,12 @@ body.modal-open .studio-app-banner {
|
|
|
230
230
|
@utility with unlayered overrides. Defaults resolve to the same white as
|
|
231
231
|
before (a literal #fff — the engine's both-modes guard bars raw
|
|
232
232
|
--color-white references, which only exist in Tailwind's compiled theme). */
|
|
233
|
+
/* The fill is --color-cta-fill when the resolver emits one — only while the
|
|
234
|
+
app leaves its primary at the engine default, whose own hex is 3.1:1 under
|
|
235
|
+
white — and otherwise the app's --color-cta, as it always was. */
|
|
233
236
|
@utility btn-primary {
|
|
234
237
|
color: var(--btn-primary-fg, #fff);
|
|
235
|
-
background-color: var(--color-cta);
|
|
238
|
+
background-color: var(--color-cta-fill, var(--color-cta));
|
|
236
239
|
&:hover {
|
|
237
240
|
color: var(--btn-primary-fg-hover, var(--btn-primary-fg, #fff));
|
|
238
241
|
background-color: var(--color-cta-hover);
|
|
@@ -258,7 +261,7 @@ body.modal-open .studio-app-banner {
|
|
|
258
261
|
border-color: var(--color-cta);
|
|
259
262
|
&:hover {
|
|
260
263
|
@apply text-white;
|
|
261
|
-
background-color: var(--color-cta);
|
|
264
|
+
background-color: var(--color-cta-fill, var(--color-cta));
|
|
262
265
|
}
|
|
263
266
|
}
|
|
264
267
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The navbar-links view seam. Links resolve once per render pass, since the
|
|
4
|
+
# host lambda may read current_user or the database.
|
|
5
|
+
module StudioNavbarHelper
|
|
6
|
+
NAVBAR_LINK_CLASS = "inline-flex items-center gap-1.5 whitespace-nowrap font-medium transition"
|
|
7
|
+
NAVBAR_BADGE_CLASS = "rounded-full border border-subtle px-1.5 py-0.5 text-[10px] leading-none " \
|
|
8
|
+
"font-semibold text-muted tabular-nums"
|
|
9
|
+
|
|
10
|
+
def studio_navbar_links
|
|
11
|
+
@studio_navbar_links ||= Studio.navbar_links_for(self)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# One resolved link. size is the text utility: text-sm on desktop, text-xs
|
|
15
|
+
# in the phone row, matching the links apps hand-wrote in forked navbars.
|
|
16
|
+
def studio_navbar_link_tag(link, size:)
|
|
17
|
+
tone = link[:active] ? "text-primary" : "text-secondary hover:text-primary"
|
|
18
|
+
link_to link[:href], class: "#{NAVBAR_LINK_CLASS} #{size} #{tone}",
|
|
19
|
+
aria: { current: ("page" if link[:active]) } do
|
|
20
|
+
badge = content_tag(:span, link[:badge], class: NAVBAR_BADGE_CLASS) if link[:badge]
|
|
21
|
+
safe_join([link[:label], badge].compact)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
end
|
|
41
41
|
end %>
|
|
42
42
|
|
|
43
|
-
<%# Where the username and the avatar
|
|
43
|
+
<%# Where the account link (the username and the avatar, one link) points.
|
|
44
44
|
|
|
45
45
|
A HOST'S OWN account_path WINS: an app that already has an account page keeps
|
|
46
46
|
it rather than being repointed at a thinner /profile by a routine dependency
|
|
@@ -69,16 +69,14 @@
|
|
|
69
69
|
|
|
70
70
|
<div class="flex gap-2">
|
|
71
71
|
<% if logged_in? %>
|
|
72
|
-
<%# Left column: Div 1 + Div 2 stacked
|
|
73
|
-
|
|
74
|
-
defaults to min-width auto,
|
|
75
|
-
shrink below
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
The username link truncates on its own: overflow-hidden gives it an
|
|
79
|
-
automatic minimum size of zero inside Div 1. %>
|
|
72
|
+
<%# Left column: Div 1 + Div 2 stacked.
|
|
73
|
+
min-w-0 belongs HERE, on the flex item of the nav root: a flex item
|
|
74
|
+
defaults to min-width auto, and without it this column refuses to
|
|
75
|
+
shrink below balance + icons. On a 320px phone with a balance the
|
|
76
|
+
reserved column is 147px, and a column that will not give pushed the
|
|
77
|
+
avatar 17px past the screen edge (e2e/header_phone_width.spec.js). %>
|
|
80
78
|
<div class="flex-1 min-w-0">
|
|
81
|
-
<%# Div 1 (Bigger): balance + icons
|
|
79
|
+
<%# Div 1 (Bigger): balance + icons. %>
|
|
82
80
|
<div class="flex items-center gap-2 leading-none" :style="'padding: 0 6px;' + ($store.devMode ? 'background: cornflowerblue;' : '')">
|
|
83
81
|
<%= studio_nav_slot.call(balance_slot, balance_html) %>
|
|
84
82
|
<div class="flex items-center gap-2 ml-auto self-end">
|
|
@@ -86,14 +84,11 @@
|
|
|
86
84
|
<%# Skipped when the link sidebar carries the admin menu — two cog
|
|
87
85
|
glyphs side by side read as a double gear. %>
|
|
88
86
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
89
|
-
|
|
87
|
+
<%# Desktop only. Below md the engine navbar's phone row draws the
|
|
88
|
+
theme toggle, so a second one here put two on every phone. The
|
|
89
|
+
signed-out bar has always done it this way (layouts/_navbar). %>
|
|
90
|
+
<span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
|
|
90
91
|
</div>
|
|
91
|
-
<% nav_name_classes = "text-heading font-semibold hover:text-primary transition text-base leading-none truncate text-right" %>
|
|
92
|
-
<% if studio_nav_destination %>
|
|
93
|
-
<%= link_to current_user.display_name, studio_nav_destination, class: nav_name_classes %>
|
|
94
|
-
<% else %>
|
|
95
|
-
<span class="<%= nav_name_classes %>"><%= current_user.display_name %></span>
|
|
96
|
-
<% end %>
|
|
97
92
|
</div>
|
|
98
93
|
<% div2_content = studio_nav_slot.call(div2_slot, div2_html) %>
|
|
99
94
|
<% server_level = current_user.respond_to?(:level) && current_user.level.present? ? current_user.level : nil %>
|
|
@@ -154,7 +149,7 @@
|
|
|
154
149
|
@navbar-seeds-update.window="handleSeedsUpdate($event.detail)"
|
|
155
150
|
class="relative mt-0.5" style="height: 14px;">
|
|
156
151
|
<%# Fill bar %>
|
|
157
|
-
<div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
|
|
152
|
+
<div class="absolute inset-y-0 left-0 rounded z-0" style="background: var(--color-cta-fill, var(--color-cta)); transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);" :style="{ width: barWidth + '%' }"></div>
|
|
158
153
|
<%# Text layer 1: muted (visible outside bar) %>
|
|
159
154
|
<div class="absolute inset-0 flex items-center justify-between px-1.5 font-mono text-muted z-[1] nav-bar-text">
|
|
160
155
|
<span>
|
|
@@ -196,19 +191,33 @@
|
|
|
196
191
|
</style>
|
|
197
192
|
<% end %>
|
|
198
193
|
</div>
|
|
199
|
-
<%# Div 3 (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
<%# Div 3 (Account): the name and the avatar are ONE link to the account
|
|
195
|
+
page, one tab stop — they used to be two links to the same place.
|
|
196
|
+
Below md the name is screen-reader text and the avatar stands alone,
|
|
197
|
+
so a phone never shows the name truncated to "Guest 55…"; from md up
|
|
198
|
+
it shows beside the avatar and truncates when the row is tight.
|
|
199
|
+
|
|
200
|
+
The link never shrinks (the avatar must stay on screen); the icon
|
|
201
|
+
column beside it is what gives way. So the name cannot truncate by
|
|
202
|
+
flex shrinking: from md up its box carries a max width instead, and
|
|
203
|
+
the name ellipsizes inside it. The avatar is aria-hidden: the name is
|
|
204
|
+
the link's accessible name. %>
|
|
205
|
+
<% account_classes = "group flex items-center gap-2 flex-shrink-0" %>
|
|
206
|
+
<% account_inner = capture do %>
|
|
207
|
+
<span class="sr-only md:not-sr-only md:max-w-40"><span class="block truncate text-right text-heading font-semibold text-base leading-none transition<%= " group-hover:text-primary" if studio_nav_destination %>" data-nav-name><%= current_user.display_name %></span></span>
|
|
208
|
+
<span class="flex-shrink-0 flex items-center" aria-hidden="true">
|
|
206
209
|
<%= render "components/avatar", user: current_user, size: "nav" %>
|
|
207
210
|
</span>
|
|
208
211
|
<% end %>
|
|
212
|
+
<% if studio_nav_destination %>
|
|
213
|
+
<%= link_to account_inner, studio_nav_destination, class: "#{account_classes} hover:opacity-80 transition", "data-nav-account": "", "x-bind:style": "$store.devMode && 'background: lightgreen'" %>
|
|
214
|
+
<% else %>
|
|
215
|
+
<span class="<%= account_classes %>" data-nav-account x-bind:style="$store.devMode && 'background: lightgreen'"><%= account_inner %></span>
|
|
216
|
+
<% end %>
|
|
209
217
|
<% else %>
|
|
210
218
|
<%= render "components/admin_dropdown" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
|
|
211
|
-
|
|
219
|
+
<%# Desktop only, as above: the phone row carries the toggle below md. %>
|
|
220
|
+
<span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
|
|
212
221
|
<div class="flex items-center gap-3 ml-auto">
|
|
213
222
|
<%= link_to "Log in", login_path, class: "text-heading hover:text-primary text-sm font-semibold transition" %>
|
|
214
223
|
<%= link_to "Sign up", signup_path, class: "btn btn-primary btn-sm" %>
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
Preview renders skip it too: the /admin/navbar page renders this partial
|
|
27
27
|
repeatedly and the panels carry page-unique ids. %>
|
|
28
28
|
<% sidebar_active = !is_preview && respond_to?(:studio_sidebar?) && studio_sidebar? %>
|
|
29
|
+
<%# Studio.navbar_links: links for the desktop bar and phone row.
|
|
30
|
+
Empty by default, which renders the same bytes as before. %>
|
|
31
|
+
<% navbar_links = respond_to?(:studio_navbar_links) ? studio_navbar_links : [] %>
|
|
29
32
|
<% if sidebar_active %>
|
|
30
33
|
<% sidebar_trigger_desktop = capture { render "components/link_sidebar_trigger", class_name: "hidden md:inline-flex" } %>
|
|
31
34
|
<% extra_icons_html = safe_join([extra_icons_html, sidebar_trigger_desktop].compact) %>
|
|
@@ -141,8 +144,11 @@
|
|
|
141
144
|
spans drawing outside it, which is the bug rather than the fix. %>
|
|
142
145
|
<h1 class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></h1>
|
|
143
146
|
<% end %>
|
|
144
|
-
<%# Desktop nav —
|
|
145
|
-
<nav class="hidden md:flex items-center gap-4"
|
|
147
|
+
<%# Desktop nav — Studio.navbar_links %>
|
|
148
|
+
<nav class="hidden md:flex items-center gap-4"<%= ' aria-label="Main"'.html_safe if navbar_links.any? %>>
|
|
149
|
+
<% navbar_links.each do |link| %>
|
|
150
|
+
<%= studio_navbar_link_tag(link, size: "text-sm") %>
|
|
151
|
+
<% end %>
|
|
146
152
|
</nav>
|
|
147
153
|
</div>
|
|
148
154
|
</div>
|
|
@@ -161,8 +167,15 @@
|
|
|
161
167
|
<% end %>
|
|
162
168
|
</div>
|
|
163
169
|
</div>
|
|
164
|
-
<%# Mobile sub-navbar: compact row hidden on desktop —
|
|
170
|
+
<%# Mobile sub-navbar: compact row hidden on desktop — Studio.navbar_links on the left %>
|
|
165
171
|
<div class="flex md:hidden items-center gap-3 px-4 py-1.5 border-t border-subtle bg-surface-alt">
|
|
172
|
+
<% if navbar_links.any? %>
|
|
173
|
+
<nav aria-label="Main" class="flex items-center gap-3 overflow-x-auto">
|
|
174
|
+
<% navbar_links.each do |link| %>
|
|
175
|
+
<%= studio_navbar_link_tag(link, size: "text-xs") %>
|
|
176
|
+
<% end %>
|
|
177
|
+
</nav>
|
|
178
|
+
<% end %>
|
|
166
179
|
<span class="ml-auto flex items-center gap-3">
|
|
167
180
|
<%= render "components/link_sidebar_trigger" if sidebar_active %>
|
|
168
181
|
<%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
</template>
|
|
122
122
|
<template x-if="!toast.image">
|
|
123
123
|
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-inset shrink-0" style="margin-top: 2px;">
|
|
124
|
-
<svg x-show="toast.type === 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
124
|
+
<svg x-show="toast.type === 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-success-ink)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
125
125
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
|
126
126
|
</svg>
|
|
127
127
|
<svg x-show="toast.type !== 'notice'" xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" style="color: var(--color-danger)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
x-transition:leave-end="opacity-0 translate-x-8"
|
|
227
227
|
class="bg-surface border rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg backdrop-blur-sm"
|
|
228
228
|
:style="toast.type === 'success'
|
|
229
|
-
? 'border-color: var(--color-success); color: var(--color-success)'
|
|
229
|
+
? 'border-color: var(--color-success); color: var(--color-success-ink)'
|
|
230
230
|
: 'border-color: var(--color-danger); color: var(--color-danger)'">
|
|
231
231
|
<span x-text="toast.message"></span>
|
|
232
232
|
</div>
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
59
59
|
<span x-show="status === 'loading'" x-cloak class="spinner text-secondary" aria-hidden="true"></span>
|
|
60
60
|
<svg x-show="status === 'valid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
61
|
-
style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
|
61
|
+
style="color: var(--color-success-ink)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
|
|
62
62
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
|
|
63
63
|
</svg>
|
|
64
64
|
<svg x-show="status === 'invalid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
<svg x-show="!copiedRef" class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true">
|
|
96
96
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
|
97
97
|
</svg>
|
|
98
|
-
<svg x-show="copiedRef" x-cloak class="w-3 h-3 shrink-0" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
98
|
+
<svg x-show="copiedRef" x-cloak class="w-3 h-3 shrink-0" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
99
99
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
100
100
|
</svg>
|
|
101
101
|
<span class="sr-only" x-text="copiedRef ? 'Copied' : ''"></span>
|
|
@@ -868,7 +868,7 @@
|
|
|
868
868
|
<div class="flex flex-wrap items-center gap-3">
|
|
869
869
|
<h3 class="text-xl font-bold text-heading">Profile Leveling</h3>
|
|
870
870
|
<% if leveling_on %>
|
|
871
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
|
|
871
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">leveling on</span>
|
|
872
872
|
<% else %>
|
|
873
873
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
|
|
874
874
|
<% end %>
|
|
@@ -958,7 +958,7 @@
|
|
|
958
958
|
glow_when: ds_glow.call("join-newsletter", celebrate: true) } do %>
|
|
959
959
|
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
960
960
|
<div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
|
|
961
|
-
<svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
961
|
+
<svg class="w-4 h-4" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
962
962
|
</div>
|
|
963
963
|
<div x-show="opts.leveling" class="flex gap-1">
|
|
964
964
|
<span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
|
|
@@ -1022,7 +1022,7 @@
|
|
|
1022
1022
|
<div class="flex flex-wrap items-center gap-3">
|
|
1023
1023
|
<h3 class="text-xl font-bold text-heading">Web3</h3>
|
|
1024
1024
|
<% if web3_on %>
|
|
1025
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
|
|
1025
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
|
|
1026
1026
|
<% else %>
|
|
1027
1027
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
1028
1028
|
<% end %>
|
|
@@ -1192,7 +1192,7 @@
|
|
|
1192
1192
|
<div class="flex flex-wrap items-center gap-3">
|
|
1193
1193
|
<h3 class="text-xl font-bold text-heading">Contest entry & eligibility</h3>
|
|
1194
1194
|
<% if age_gate_on %>
|
|
1195
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">age gate on</span>
|
|
1195
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">age gate on</span>
|
|
1196
1196
|
<% else %>
|
|
1197
1197
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">age gate off</span>
|
|
1198
1198
|
<% end %>
|
|
@@ -1312,7 +1312,7 @@
|
|
|
1312
1312
|
open_expr: "$store.dsModals.open('ds-success')" } do %>
|
|
1313
1313
|
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
1314
1314
|
<div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
|
|
1315
|
-
<svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
1315
|
+
<svg class="w-4 h-4" style="color: var(--color-success-ink)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
|
1316
1316
|
</div>
|
|
1317
1317
|
<span class="block h-2 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
1318
1318
|
<span class="block h-4 w-full rounded" style="background: var(--color-cta)"></span>
|
|
@@ -1441,7 +1441,7 @@
|
|
|
1441
1441
|
<div class="flex flex-wrap items-center gap-3">
|
|
1442
1442
|
<h3 class="text-xl font-bold text-heading">Rewards</h3>
|
|
1443
1443
|
<% if leveling_on %>
|
|
1444
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
|
|
1444
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
|
|
1445
1445
|
<% else %>
|
|
1446
1446
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
1447
1447
|
<% end %>
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
<%= render "studio/board_assets" %>
|
|
86
86
|
<div class="space-y-3">
|
|
87
87
|
<div class="flex items-center gap-2">
|
|
88
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Live</span>
|
|
88
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Live</span>
|
|
89
89
|
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · demo — drag a card between columns</p>
|
|
90
90
|
</div>
|
|
91
91
|
<%= render "studio/board/board",
|
|
@@ -268,7 +268,7 @@
|
|
|
268
268
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
269
269
|
<div class="card p-5 space-y-2">
|
|
270
270
|
<div class="flex items-center gap-2">
|
|
271
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Shared</span>
|
|
271
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shared</span>
|
|
272
272
|
<p class="text-heading font-semibold text-sm">What every board inherits</p>
|
|
273
273
|
</div>
|
|
274
274
|
<ul class="text-secondary text-sm space-y-1 list-disc list-inside">
|
|
@@ -183,12 +183,13 @@
|
|
|
183
183
|
function dsThemeEditor(seed) {
|
|
184
184
|
return {
|
|
185
185
|
colors: Object.assign({ primary: '#000', dark: '#0f172a', light: '#ffffff',
|
|
186
|
-
success: '#
|
|
186
|
+
success: '#367E3A', warning: '#FF7C47', danger: '#EF4444',
|
|
187
187
|
accent: '#6366F1' }, seed || {}),
|
|
188
188
|
saving: false,
|
|
189
189
|
updatePreview() {
|
|
190
190
|
var d = document.documentElement.style;
|
|
191
191
|
d.setProperty('--color-cta', this.colors.primary);
|
|
192
|
+
d.setProperty('--color-cta-fill', this.colors.primary);
|
|
192
193
|
if (this.colors.success) d.setProperty('--color-success', this.colors.success);
|
|
193
194
|
if (this.colors.warning) d.setProperty('--color-warning', this.colors.warning);
|
|
194
195
|
if (this.colors.danger) d.setProperty('--color-danger', this.colors.danger);
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
locals: { klass: ".badge", usage: %(<span class="badge">Neutral</span>) } do %>
|
|
91
91
|
<div class="flex flex-wrap items-center justify-center gap-2">
|
|
92
92
|
<span class="badge text-body">Neutral</span>
|
|
93
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Success</span>
|
|
93
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Success</span>
|
|
94
94
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Warning</span>
|
|
95
95
|
<span class="badge" style="color: var(--color-danger); border-color: var(--color-danger)">Danger</span>
|
|
96
96
|
</div>
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
<%= render layout: "style/specimen",
|
|
175
175
|
locals: { klass: "in a status badge",
|
|
176
176
|
usage: %(<span class="badge">Shipped ) + erb.call("at_time_tag(release.shipped_at)") + "</span>" } do %>
|
|
177
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
|
|
177
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">Shipped <%= at_time_tag(42.minutes.ago) %></span>
|
|
178
178
|
<% end %>
|
|
179
179
|
</div>
|
|
180
180
|
</section>
|
|
@@ -442,7 +442,7 @@
|
|
|
442
442
|
<div class="flex flex-wrap items-center gap-3">
|
|
443
443
|
<h3 class="text-xl font-bold text-heading">Leveling</h3>
|
|
444
444
|
<% if leveling_on %>
|
|
445
|
-
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
|
|
445
|
+
<span class="badge" style="color: var(--color-success-ink); border-color: var(--color-success)">enabled on this app</span>
|
|
446
446
|
<% else %>
|
|
447
447
|
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
|
|
448
448
|
<% end %>
|
|
@@ -71,13 +71,13 @@
|
|
|
71
71
|
<p class="text-sm mb-3" :style="{ color: '#94a3b8' }">Card on surface with secondary text.</p>
|
|
72
72
|
<div class="flex gap-2">
|
|
73
73
|
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
|
|
74
|
-
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#
|
|
74
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
|
|
75
75
|
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
|
|
76
76
|
</div>
|
|
77
77
|
</div>
|
|
78
78
|
<div class="flex gap-2">
|
|
79
79
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
|
|
80
|
-
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#
|
|
80
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
|
|
81
81
|
</div>
|
|
82
82
|
</div>
|
|
83
83
|
</div>
|
|
@@ -94,13 +94,13 @@
|
|
|
94
94
|
<p class="text-sm mb-3" :style="{ color: '#64748b' }">Card on surface with secondary text.</p>
|
|
95
95
|
<div class="flex gap-2">
|
|
96
96
|
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.primary }">Primary</button>
|
|
97
|
-
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#
|
|
97
|
+
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.success || '#367E3A' }">Accent</button>
|
|
98
98
|
<button class="btn btn-sm text-white rounded-lg px-3 py-1.5 text-xs font-bold" :style="{ backgroundColor: colors.danger || '#EF4444' }">Danger</button>
|
|
99
99
|
</div>
|
|
100
100
|
</div>
|
|
101
101
|
<div class="flex gap-2">
|
|
102
102
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: colors.primary + '33', color: colors.primary, border: '1px solid ' + colors.primary + '55' }">Badge</span>
|
|
103
|
-
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#
|
|
103
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" :style="{ backgroundColor: (colors.success || '#367E3A') + '33', color: colors.success || '#367E3A', border: '1px solid ' + (colors.success || '#367E3A') + '55' }">Success</span>
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|
|
106
106
|
</div>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Resolves Studio.navbar_links, the links the engine navbar renders in its
|
|
4
|
+
# desktop bar and phone row. Pure Ruby, so the unit suite covers it without
|
|
5
|
+
# the dummy app.
|
|
6
|
+
#
|
|
7
|
+
# Declared as an Array or a callable (receives the view context, so a badge can
|
|
8
|
+
# read current_user). Each entry resolves to:
|
|
9
|
+
#
|
|
10
|
+
# { label: "Contests", href: "/contests", active: true, badge: "#12" }
|
|
11
|
+
#
|
|
12
|
+
# `active` may be true/false, a Regexp or a callable matched against
|
|
13
|
+
# request.path; omitted, it is true when the path equals the href's path.
|
|
14
|
+
# `badge` is optional short text. A bad entry raises InvalidLink by index.
|
|
15
|
+
module Studio
|
|
16
|
+
module NavbarLinks
|
|
17
|
+
class InvalidLink < ArgumentError; end
|
|
18
|
+
|
|
19
|
+
KEYS = %i[label href active badge].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def resolve(declared, view)
|
|
24
|
+
links = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
25
|
+
path = request_path(view)
|
|
26
|
+
entries(links).each_with_index.map do |link, index|
|
|
27
|
+
link = check!(link, index)
|
|
28
|
+
{ label: link[:label].to_s, href: link[:href].to_s,
|
|
29
|
+
active: active?(link, path), badge: badge(link[:badge]) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Shape check without a request: a static list fails at boot, not on the
|
|
34
|
+
# first page render. A callable is checked when it resolves.
|
|
35
|
+
def validate!(declared)
|
|
36
|
+
return nil if declared.respond_to?(:call)
|
|
37
|
+
|
|
38
|
+
entries(declared).each_with_index { |link, index| check!(link, index) }
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def entries(links)
|
|
43
|
+
return [] if links.nil?
|
|
44
|
+
return links if links.is_a?(Array)
|
|
45
|
+
|
|
46
|
+
raise InvalidLink, "Studio.navbar_links must be an Array or a callable returning an Array " \
|
|
47
|
+
"(got #{links.class})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check!(link, index)
|
|
51
|
+
where = "Studio.navbar_links[#{index}]"
|
|
52
|
+
raise InvalidLink, "#{where} must be a Hash (got #{link.class})" unless link.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
link = link.to_h { |key, value| [key.to_sym, value] }
|
|
55
|
+
unknown = link.keys - KEYS
|
|
56
|
+
raise InvalidLink, "#{where} has unknown key :#{unknown.first}" if unknown.any?
|
|
57
|
+
|
|
58
|
+
%i[label href].each do |key|
|
|
59
|
+
raise InvalidLink, "#{where} needs a :#{key}" if link[key].to_s.strip.empty?
|
|
60
|
+
end
|
|
61
|
+
unless [nil, true, false].include?(link[:active]) || link[:active].is_a?(Regexp) ||
|
|
62
|
+
link[:active].respond_to?(:call)
|
|
63
|
+
raise InvalidLink, "#{where} :active must be true, false, a Regexp or a callable"
|
|
64
|
+
end
|
|
65
|
+
unless link[:badge].nil? || [String, Symbol, Numeric].any? { |type| link[:badge].is_a?(type) }
|
|
66
|
+
raise InvalidLink, "#{where} :badge must be short text"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
link
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def active?(link, path)
|
|
73
|
+
case (rule = link[:active])
|
|
74
|
+
when true, false then rule
|
|
75
|
+
when nil then !path.nil? && path == link[:href].to_s.split(/[?#]/).first
|
|
76
|
+
when Regexp then !path.nil? && rule.match?(path)
|
|
77
|
+
else !path.nil? && rule.call(path) ? true : false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def badge(value)
|
|
82
|
+
text = value.to_s.strip
|
|
83
|
+
text.empty? ? nil : text
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def request_path(view)
|
|
87
|
+
return nil unless view.respond_to?(:request)
|
|
88
|
+
|
|
89
|
+
view.request&.path
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -2,6 +2,18 @@ module Studio
|
|
|
2
2
|
class ThemeResolver
|
|
3
3
|
ROLES = %i[primary dark light success warning danger accent].freeze
|
|
4
4
|
|
|
5
|
+
# The engine's default role colours — the resolver's fallbacks, and the
|
|
6
|
+
# same values lib/studio.rb ships as its theme_* defaults.
|
|
7
|
+
DEFAULT_PRIMARY = "#8E82FE"
|
|
8
|
+
# #367E3A, not the #4BAF50 it was until engine-navbar-phone-polish: the old
|
|
9
|
+
# green measured 2.78:1 under the white label btn-success draws on it. This
|
|
10
|
+
# is the same hue at 5.0:1 (the shade cyvasse chose for its own success).
|
|
11
|
+
DEFAULT_SUCCESS = "#367E3A"
|
|
12
|
+
|
|
13
|
+
# WCAG AA for normal-size text. Every filled button draws a 14px bold white
|
|
14
|
+
# label, which is not "large" text, so its fill owes 4.5:1 against white.
|
|
15
|
+
WHITE_LABEL_TARGET = 4.5
|
|
16
|
+
|
|
5
17
|
attr_reader :colors
|
|
6
18
|
|
|
7
19
|
# colors: hash of role => hex string (e.g. { primary: "#8E82FE", dark: "#1A1535", ... })
|
|
@@ -29,9 +41,9 @@ module Studio
|
|
|
29
41
|
|
|
30
42
|
def dark_mode_vars
|
|
31
43
|
dark_base = colors[:dark] || "#1A1535"
|
|
32
|
-
primary = colors[:primary] ||
|
|
44
|
+
primary = colors[:primary] || DEFAULT_PRIMARY
|
|
33
45
|
border_rgb = ColorScale.lighten(dark_base, 0.30)
|
|
34
|
-
success = colors[:success] ||
|
|
46
|
+
success = colors[:success] || DEFAULT_SUCCESS
|
|
35
47
|
warning = colors[:warning] || "#FF7C47"
|
|
36
48
|
danger = colors[:danger] || "#EF4444"
|
|
37
49
|
surfaces = dark_surfaces(dark_base)
|
|
@@ -69,8 +81,11 @@ module Studio
|
|
|
69
81
|
"--color-border" => ColorScale.with_opacity(border_rgb, 0.2),
|
|
70
82
|
"--color-border-strong" => ColorScale.with_opacity(border_rgb, 0.4),
|
|
71
83
|
"--color-shadow" => "transparent",
|
|
84
|
+
# --color-cta stays the brand colour: apps also paint it as TEXT on
|
|
85
|
+
# dark surfaces, where a darker shade would lose contrast. The button
|
|
86
|
+
# fill under a white label is a separate, derived var: see #fill_vars.
|
|
72
87
|
"--color-cta" => primary,
|
|
73
|
-
"--color-cta-hover" =>
|
|
88
|
+
"--color-cta-hover" => cta_hover(primary),
|
|
74
89
|
"--color-success" => success,
|
|
75
90
|
"--color-warning" => warning,
|
|
76
91
|
"--color-danger" => danger,
|
|
@@ -99,7 +114,7 @@ module Studio
|
|
|
99
114
|
"--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
|
|
100
115
|
"--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
|
|
101
116
|
"--color-accent" => colors[:accent] || "#F72585"
|
|
102
|
-
}
|
|
117
|
+
}.merge(fill_vars(primary, success))
|
|
103
118
|
end
|
|
104
119
|
|
|
105
120
|
# Every dark-mode background a text var can sit on (page, surface,
|
|
@@ -166,6 +181,38 @@ module Studio
|
|
|
166
181
|
contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + tints)
|
|
167
182
|
end
|
|
168
183
|
|
|
184
|
+
# The lightest shade of `color` that carries a white label at AA: the
|
|
185
|
+
# colour itself when it already passes, otherwise the same hue darkened in
|
|
186
|
+
# 0.02 steps until it does. A fill shade, derived the way the text inks are.
|
|
187
|
+
def white_label_fill(color)
|
|
188
|
+
contrast_ink(color, direction: :darken, start: 0.0, target: WHITE_LABEL_TARGET, against: ["#ffffff"])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# --color-cta-fill: the fill btn-primary, btn-outline's hover and the user
|
|
192
|
+
# nav's level bar paint under a white label (the same in both modes).
|
|
193
|
+
# Emitted ONLY while the app leaves its primary at the engine default
|
|
194
|
+
# (#8E82FE, 3.1:1 under white), which is the engine's to make accessible.
|
|
195
|
+
# A CONFIGURED primary emits no fill, so those rules fall back to
|
|
196
|
+
# var(--color-cta), the app's own choice: mcritchie-industries pairs a navy
|
|
197
|
+
# label with its orange (a darkened fill would drop it to 3.3:1), and
|
|
198
|
+
# cyvasse sets --color-cta itself.
|
|
199
|
+
#
|
|
200
|
+
# Success needs no fill var: its DEFAULT is already AA under white
|
|
201
|
+
# (DEFAULT_SUCCESS), and a configured success colour is the app's own —
|
|
202
|
+
# turf-monster's suite pins that a configured #4BAF50 still paints as-is.
|
|
203
|
+
def fill_vars(primary, _success)
|
|
204
|
+
return {} unless primary.to_s.casecmp?(DEFAULT_PRIMARY)
|
|
205
|
+
|
|
206
|
+
{ "--color-cta-fill" => white_label_fill(primary) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The hover fill is always derived, so it is always held to AA under white:
|
|
210
|
+
# darken(primary, 0.30) as before when that passes (every consumer palette
|
|
211
|
+
# today), darker only for a primary so light that 0.30 is not enough.
|
|
212
|
+
def cta_hover(primary)
|
|
213
|
+
contrast_ink(primary, direction: :darken, start: 0.30, target: WHITE_LABEL_TARGET, against: ["#ffffff"])
|
|
214
|
+
end
|
|
215
|
+
|
|
169
216
|
# Bounded, clamped search: raise the blend amount from `start` until the
|
|
170
217
|
# ink clears `target` contrast against every background in `against`.
|
|
171
218
|
# Clamps at 1.0 (pure white/black), so a pathological base degrades to the
|
|
@@ -182,7 +229,7 @@ module Studio
|
|
|
182
229
|
|
|
183
230
|
# Generate --color-primary-{50..900} + RGB variants for Tailwind opacity support
|
|
184
231
|
def primary_palette_vars
|
|
185
|
-
primary = colors[:primary] ||
|
|
232
|
+
primary = colors[:primary] || DEFAULT_PRIMARY
|
|
186
233
|
scale = ColorScale.generate(primary)
|
|
187
234
|
vars = {}
|
|
188
235
|
|
|
@@ -202,8 +249,8 @@ module Studio
|
|
|
202
249
|
|
|
203
250
|
def light_mode_vars
|
|
204
251
|
light_base = colors[:light] || "#f8fafc"
|
|
205
|
-
primary = colors[:primary] ||
|
|
206
|
-
success = colors[:success] ||
|
|
252
|
+
primary = colors[:primary] || DEFAULT_PRIMARY
|
|
253
|
+
success = colors[:success] || DEFAULT_SUCCESS
|
|
207
254
|
warning = colors[:warning] || "#FF7C47"
|
|
208
255
|
danger = colors[:danger] || "#EF4444"
|
|
209
256
|
surfaces = light_surfaces(light_base)
|
|
@@ -231,7 +278,7 @@ module Studio
|
|
|
231
278
|
"--color-border-strong" => ColorScale.darken(light_base, 0.15),
|
|
232
279
|
"--color-shadow" => "rgba(0,0,0,0.05)",
|
|
233
280
|
"--color-cta" => primary,
|
|
234
|
-
"--color-cta-hover" =>
|
|
281
|
+
"--color-cta-hover" => cta_hover(primary),
|
|
235
282
|
"--color-success" => success,
|
|
236
283
|
"--color-warning" => warning,
|
|
237
284
|
"--color-danger" => danger,
|
|
@@ -256,7 +303,7 @@ module Studio
|
|
|
256
303
|
"--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
|
|
257
304
|
"--color-success-ink" => status_ink(success, surfaces, direction: :darken),
|
|
258
305
|
"--color-accent" => colors[:accent] || "#F72585"
|
|
259
|
-
}
|
|
306
|
+
}.merge(fill_vars(primary, success))
|
|
260
307
|
end
|
|
261
308
|
end
|
|
262
309
|
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "studio/js_literal"
|
|
|
12
12
|
require "studio/js_identifier"
|
|
13
13
|
require "studio/partial_block"
|
|
14
14
|
require "studio/sidebar_sections"
|
|
15
|
+
require "studio/navbar_links"
|
|
15
16
|
require "studio/profile_sections"
|
|
16
17
|
require "studio/profile_image"
|
|
17
18
|
require "studio/oauth_identity"
|
|
@@ -174,6 +175,23 @@ module Studio
|
|
|
174
175
|
# end
|
|
175
176
|
mattr_accessor :sidebar_sections, default: []
|
|
176
177
|
|
|
178
|
+
# ---- Navbar links ----
|
|
179
|
+
# Links for the engine navbar's desktop bar and phone row. The default []
|
|
180
|
+
# renders nothing, so the navbar stays byte-identical until an app opts in.
|
|
181
|
+
# An Array, or a callable receiving the view, of
|
|
182
|
+
# { label:, href:, active: (bool, Regexp or ->(path)), badge: (optional) }.
|
|
183
|
+
# Rules: lib/studio/navbar_links.rb.
|
|
184
|
+
#
|
|
185
|
+
# config.navbar_links = ->(view) {
|
|
186
|
+
# [ { label: "Contests", href: view.contests_path, active: %r{\A/contests} },
|
|
187
|
+
# { label: "Rank", href: "/rank", badge: ("#12" if view.logged_in?) } ] }
|
|
188
|
+
mattr_reader :navbar_links, default: []
|
|
189
|
+
|
|
190
|
+
def self.navbar_links=(links)
|
|
191
|
+
NavbarLinks.validate!(links)
|
|
192
|
+
@@navbar_links = links
|
|
193
|
+
end
|
|
194
|
+
|
|
177
195
|
# ---- The shared profile page (/profile) ----
|
|
178
196
|
# The rows that make up /profile. `nil` — the default — means "the engine's
|
|
179
197
|
# standard page", NOT "no rows": a brand-new app with an empty initializer gets
|
|
@@ -589,7 +607,9 @@ module Studio
|
|
|
589
607
|
mattr_accessor :theme_primary, default: "#8E82FE"
|
|
590
608
|
mattr_accessor :theme_dark, default: "#1A1535"
|
|
591
609
|
mattr_accessor :theme_light, default: "#f8fafc"
|
|
592
|
-
|
|
610
|
+
# #367E3A: the default green's AA shade under a white label (5.0:1). The old
|
|
611
|
+
# #4BAF50 measured 2.78:1 on btn-success. Studio::ThemeResolver::DEFAULT_SUCCESS.
|
|
612
|
+
mattr_accessor :theme_success, default: "#367E3A"
|
|
593
613
|
mattr_accessor :theme_warning, default: "#FF7C47"
|
|
594
614
|
mattr_accessor :theme_danger, default: "#EF4444"
|
|
595
615
|
mattr_accessor :theme_accent, default: "#F72585"
|
|
@@ -877,6 +897,11 @@ module Studio
|
|
|
877
897
|
SidebarSections.resolve(sidebar_sections, view)
|
|
878
898
|
end
|
|
879
899
|
|
|
900
|
+
# Navbar links resolved for a view context (lib/studio/navbar_links.rb).
|
|
901
|
+
def self.navbar_links_for(view)
|
|
902
|
+
NavbarLinks.resolve(navbar_links, view)
|
|
903
|
+
end
|
|
904
|
+
|
|
880
905
|
# The engine's standard /profile rows. Hosts compose against this rather than
|
|
881
906
|
# restating a literal list, so a later release that adds a standard row
|
|
882
907
|
# delivers it to every app that used the seam as intended.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.79.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -345,6 +345,7 @@ files:
|
|
|
345
345
|
- app/helpers/studio/fizz_helper.rb
|
|
346
346
|
- app/helpers/studio/geo_helper.rb
|
|
347
347
|
- app/helpers/studio_email_delivery_helper.rb
|
|
348
|
+
- app/helpers/studio_navbar_helper.rb
|
|
348
349
|
- app/helpers/studio_sidebar_helper.rb
|
|
349
350
|
- app/helpers/studio_theme_helper.rb
|
|
350
351
|
- app/jobs/error_log_cleanup_job.rb
|
|
@@ -572,6 +573,7 @@ files:
|
|
|
572
573
|
- lib/studio/log_rotation.rb
|
|
573
574
|
- lib/studio/mail_transport.rb
|
|
574
575
|
- lib/studio/name_parts.rb
|
|
576
|
+
- lib/studio/navbar_links.rb
|
|
575
577
|
- lib/studio/newsletter.rb
|
|
576
578
|
- lib/studio/oauth_identity.rb
|
|
577
579
|
- lib/studio/partial_block.rb
|