keystone_ui-preferences 0.6.0 → 0.8.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/README.md +163 -3
- data/app/controllers/concerns/keystone_ui/preferences/component_preferences.rb +36 -1
- data/app/javascript/keystone_ui/preferences/theme_settings_controller.js +84 -0
- data/app/models/keystone_ui/preferences/appearance_choice.rb +172 -0
- data/app/models/keystone_ui/preferences/checked_theme.rb +109 -0
- data/app/models/keystone_ui/preferences/chosen_colours.rb +44 -0
- data/app/models/keystone_ui/preferences/layout_choice.rb +6 -2
- data/app/models/keystone_ui/preferences/pick_account_appearance.rb +64 -0
- data/app/models/keystone_ui/preferences/pick_appearance.rb +90 -0
- data/app/models/keystone_ui/preferences/sent_theme.rb +49 -0
- data/app/models/keystone_ui/preferences/theme_css.rb +89 -0
- data/app/models/keystone_ui/preferences/theme_reader.rb +34 -0
- data/app/models/keystone_ui/preferences/theme_roles.rb +64 -0
- data/app/views/keystone_ui/preferences/settings/_account_appearance.html.erb +41 -0
- data/app/views/keystone_ui/preferences/settings/_appearance.html.erb +40 -0
- data/app/views/keystone_ui/preferences/settings/_colour_choices.html.erb +95 -0
- data/app/views/keystone_ui/preferences/settings/_look_choices.html.erb +8 -0
- data/app/views/keystone_ui/preferences/settings/_theme_settings.html.erb +17 -0
- data/lib/generators/keystone_ui/preferences/install/install_generator.rb +6 -1
- data/lib/generators/keystone_ui/preferences/update/templates/copy_keystone_ui_colors_theme_preferences.rb.erb +55 -0
- data/lib/generators/keystone_ui/preferences/update/update_generator.rb +15 -1
- data/lib/keystone_ui/preferences/colors_check.rb +22 -0
- data/lib/keystone_ui/preferences/configuration.rb +11 -1
- data/lib/keystone_ui/preferences/engine.rb +24 -0
- data/lib/keystone_ui/preferences/version.rb +1 -1
- data/lib/keystone_ui/preferences.rb +1 -0
- metadata +48 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f37c2cf73a8559ec91481efe3e542f12f3cd88363dce8de5396a3b0b70ed63dd
|
|
4
|
+
data.tar.gz: d4335e98170923ea6920e602a99c6b55fb81be63236834f37b1ffbc4d1a5ebe1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 799cef36ff33e44be4fb48c58604e129e81002205518c2d2dc5f59a09c3a1e4f097c6d0fe8d4cbe1bf276774542150c9e6573e72208700d20aca41a64d589e77
|
|
7
|
+
data.tar.gz: 25617ce106f11bc189f115ad36ec2979884a90adb9f973bc703da55a11d8ab73a368f62fc15d76bc327cfd0c5333b5907125959ccd5a357146d89ede96b89fc5
|
data/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# keystone_ui-preferences
|
|
2
2
|
|
|
3
|
-
Per-user saved choices for Keystone UI components, such as a data table's hidden columns.
|
|
3
|
+
Per-user saved choices for Keystone UI components, such as a data table's hidden columns and the colours every page is drawn in.
|
|
4
4
|
|
|
5
5
|
A page gives a `ui_data_table` a `key:`. This gem saves each signed-in person's layout for that key and hands it back to keystone_ui, so the table renders from it and shows a Columns menu that saves to it. A person with nothing saved sees the hidden columns the table's own call names.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
Add the gem and run its install generator, which copies a migration for its table:
|
|
9
|
+
Add the gem and run its install generator, which copies a migration for its table and the template picker script the appearance section uses:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
gem "keystone_ui-preferences"
|
|
@@ -18,7 +18,14 @@ bin/rails generate keystone_ui:preferences:install
|
|
|
18
18
|
bin/rails db:migrate
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The script lands at `app/javascript/controllers/keystone_ui/preferences/theme_settings_controller.js`. Register it in `app/javascript/controllers/index.js`:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import ThemeSettingsController from "./keystone_ui/preferences/theme_settings_controller"
|
|
25
|
+
application.register("keystone-ui--preferences--theme-settings", ThemeSettingsController)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
After updating the gem, run its update generator to copy any migrations added since it was installed, and the template picker script when the app does not have it yet:
|
|
22
29
|
|
|
23
30
|
```bash
|
|
24
31
|
bin/rails generate keystone_ui:preferences:update
|
|
@@ -49,6 +56,16 @@ KeystoneUi::Preferences.configure do |config|
|
|
|
49
56
|
config.authentication_method = :authenticate_user! # runs before every save
|
|
50
57
|
config.current_account_method = :current_account # returns the account; nil (the default) skips the account level
|
|
51
58
|
config.max_value_bytes = 10_000 # the largest saved value accepted, in bytes
|
|
59
|
+
config.default_accent = "blue" # the accent for a person with no colours saved
|
|
60
|
+
config.default_surface = "zinc" # the surface for a person with no colours saved
|
|
61
|
+
config.default_background = "#ffffff" # the custom background for a person with no colours saved
|
|
62
|
+
config.default_text = "#18181b" # the custom text colour for a person with no colours saved
|
|
63
|
+
config.default_mode = "light" # the mode for a person with no mode saved and for a visitor
|
|
64
|
+
config.user_looks = true # whether a person may choose their own look
|
|
65
|
+
config.app_look_label = "Classic" # the label for the app's own look in the appearance section
|
|
66
|
+
config.account_colors = true # whether an account may choose its colours, and its members their own
|
|
67
|
+
config.account_looks = true # whether an account may choose its look
|
|
68
|
+
config.theme_fonts = [] # fonts the app serves itself that a pasted theme may name
|
|
52
69
|
end
|
|
53
70
|
```
|
|
54
71
|
|
|
@@ -133,6 +150,149 @@ A tab declared outside any group with keystone_ui's `navigation_tab` is one row
|
|
|
133
150
|
|
|
134
151
|
Saving keeps `"order": [{ "group": "Admin" }, { "group": "Sales", "tabs": ["orders", "quotes"] }, { "group": "Console" }]` for that person under the `navigation` key, naming groups by label and tabs by key, and a tab declared outside any group by its label alone. It leaves their saved placement as it was. keystone_ui draws the navigation in that order on the next page they open, with anything the order does not name after it in its declared order. Reset removes the saved order, so the navigation goes back to the declared order. An order that is not a list of groups is refused with a message, and nothing is saved.
|
|
135
152
|
|
|
153
|
+
## Colours
|
|
154
|
+
|
|
155
|
+
Add the palette style tag to your layout's `<head>`:
|
|
156
|
+
|
|
157
|
+
```erb
|
|
158
|
+
<%= keystone_palette_style_tag %>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
It writes the same CSS variables keystone_ui-colors writes: `--color-accent-50` to `--color-accent-950`, `--color-surface-50` to `--color-surface-950`, `--color-custom-background` and `--color-custom-text`. A layout that already calls it for keystone_ui-colors keeps working unchanged, and an app that loads both gems stops at boot.
|
|
162
|
+
|
|
163
|
+
The colours come from the `colours` key, read the way a table's layout is read: the person's own row, or the account's row when the account does not let members choose. A saved value looks like `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }`, `{ "template_name": "forest" }`, `{ "preset_name": "harbor" }` or a pasted theme, described under Appearance. A named accent or surface, such as `emerald` or `stone`, uses that palette's shades from keystone_ui-looks, and a hex value has its shades generated. A custom hex surface is also the custom background. A saved accent or surface that is neither a name of lowercase letters, digits and underscores nor a six-digit hex, and a saved text colour that is not a six-digit hex, is drawn as the configured default for that colour instead.
|
|
164
|
+
|
|
165
|
+
A saved `template_name` draws that template's accent, surface, background and text from keystone_ui-looks, whatever else the value holds. The template named `default` is built from the four defaults in the configuration. A person with nothing saved, and a visitor who is not signed in, see those four defaults.
|
|
166
|
+
|
|
167
|
+
A saved theme is written into the same style tag as a second rule, after the palette, setting the `--ks-*` variables of keystone_ui-styles that each theme role maps to. A role's light value sets each of its variables and its dark value sets each variable's `-dark` partner, so Dark mode, and System mode on a device that prefers dark, draw the theme's dark values. A role with no dark value keeps today's dark colour, and a role that maps to no variable, such as `sidebar-ring`, sets nothing. The accent, neutral and danger hover colours are derived from `primary`, `secondary` and `destructive` by mixing in 15% white. The rule's selector is `:root, html:root[data-look]`, so a theme's colours win over the colours a look sets. React components from keystone_ui-react use the same `ks-` classes, so they draw the same theme colours. The theme is checked again before it is written. A saved theme holding a value that fails the check, such as one saved before a rule was tightened, or one that is not a set of roles each holding light and dark values, is not written, and the page draws the account's colours and theme instead, or the app's defaults when the account has none or its own theme is the one that fails. Each such page writes one warning to the app's log naming the owner's type and id and the first setting that fails, for example `keystone_ui-preferences: User 5 saved a theme whose --radius setting fails the theme check`, or says the theme is not a set of roles, for example `keystone_ui-preferences: User 5 saved a theme that is not a set of roles, so it fails the theme check`. The written rule is kept in `Rails.cache` under the colours row's versioned cache key and the writer's version, so a saved change, or a new release of the writer, shows on the next page. No theme is written while `account_colors` is off.
|
|
168
|
+
|
|
169
|
+
A saved theme's shape and type are written into the same rule. `radius` sets `--ks-radius-control` and `--ks-radius-surface`, `spacing` sets `--ks-spacing`, `border-width` sets `--ks-border-width`, `letter-spacing` sets `--ks-letter-spacing`, `font-sans` sets `--ks-font-body`, `font-heading` sets `--ks-font-heading`, and `font-weight-normal`, `font-weight-medium`, `font-weight-strong` and `font-weight-heading` set the matching `--ks-font-weight-*` variable. `font-mono` sets both `--ks-font-mono` and Tailwind's `--default-mono-font-family`, since keystone_ui-styles draws code with the second, and a theme with no mono font sets neither. `font-serif` is saved but sets nothing. A theme that sets all six shadow settings writes one shadow, `<offset-x> <offset-y> <blur> <spread>` in the shadow colour mixed to the shadow opacity, to `--ks-shadow-surface`, `--ks-shadow-overlay` and `--ks-shadow-menu`, and a theme missing any of the six writes no shadow. Shape and type have no dark values. For a person who saved a look of their own, the shape and type go in a separate `:root` rule, so their look's corners, spacing, shadows, borders and fonts win over the theme's while the theme's colours still win over the look's. A look that comes only from the account or the app's default does not outrank the theme's shape and type. Whether the person saved a look is part of the cache key, so saving or removing one shows on the next page.
|
|
170
|
+
|
|
171
|
+
A saved `preset_name` writes that preset theme from keystone_ui-looks into the same rule a saved theme is written into, looked up on each page rather than copied into the row, so a preset changed in a later keystone_ui-looks release reaches everyone who picked it. The preset's values are checked as a saved theme's are before they are written. The written rule is kept in `Rails.cache` under the preset's name, the keystone_ui-looks version, the writer's version and whether the person saved a look of their own.
|
|
172
|
+
|
|
173
|
+
A saved name keystone_ui-looks no longer has still draws a page. A missing template draws the account's colours, or the four defaults when the account has none or its own template is the missing one. A missing accent or surface name draws the default accent or surface. A missing preset falls back the same way as a missing template, to the account's colours and theme, or to the four defaults when the account has none or its own preset is the missing one. Each of these writes one warning to the app's log naming the owner's type and id and the missing name, for example `keystone_ui-preferences: User 5 saved the template "retired", which keystone_ui-looks does not have`. A saved value that is not a name or a six-digit hex falls back with no warning. The saved row is left as it is until the owner saves again, and the appearance section shows no template selected for it.
|
|
174
|
+
|
|
175
|
+
## Appearance
|
|
176
|
+
|
|
177
|
+
A person picks their colours in a settings_hub section the app registers:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
SettingsHub.section :appearance, area: :user, title: "Appearance",
|
|
181
|
+
renders: "keystone_ui/preferences/settings/appearance",
|
|
182
|
+
runs: "KeystoneUi::Preferences::PickAppearance"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The section looks and behaves as keystone_ui-colors' colour settings do. It offers Default, the five named templates from keystone_ui-looks, the six preset themes from keystone_ui-looks and Custom, with an accent, a surface and a text colour picker under them. The person's own saved choice is selected, or Default when they have none, and the pickers show the colours drawn for them now. Picking a template sets the pickers to that template's colours at once, and changing a picker selects Custom, through the template picker script the install generator copies. Without the script the section still saves, and the form saves with a full page load, so it works in a Hotwire Native web view.
|
|
186
|
+
|
|
187
|
+
Saving a template keeps `{ "template_name": "forest" }` for that person under the `colours` key. Saving a preset theme keeps only its name, as `{ "preset_name": "harbor" }`. Saving Custom keeps `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }`. Saving Default removes the person's `colours` row, so the account's colours, or the app's defaults, apply again. The next page the person opens is drawn in the saved colours.
|
|
188
|
+
|
|
189
|
+
A template or preset name that is not Default, one of the five templates or one of the six preset themes is refused with a message, and so is a Custom accent or surface that is neither a six-digit hex nor a palette name from keystone_ui-looks, and a text colour that is not a six-digit hex. Nothing is saved.
|
|
190
|
+
|
|
191
|
+
The Theme section also has a paste box for a theme in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export it. Only declarations in a `:root` block and a `.dark` block are read, either at the top level or inside an `@layer` rule, and only the theme's settings are kept. Those are the 32 shadcn colour roles: `background`, `foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `muted`, `muted-foreground`, `accent`, `accent-foreground`, `destructive`, `destructive-foreground`, `border`, `input`, `ring`, `chart-1` to `chart-5` and the eight `sidebar` roles. They are also shadcn's shape and type settings, `radius`, `spacing`, `letter-spacing`, `font-sans`, `font-serif`, `font-mono`, `shadow-color`, `shadow-opacity`, `shadow-blur`, `shadow-spread`, `shadow-offset-x` and `shadow-offset-y`. Last are the settings shadcn has no name for, `border-width`, `font-heading`, `font-weight-normal`, `font-weight-medium`, `font-weight-strong` and `font-weight-heading`. Every other declaration, selector and rule is ignored, including ready-made shadows such as `--shadow-sm`.
|
|
192
|
+
|
|
193
|
+
Each colour must be a hex colour or an `rgb()`, `hsl()` or `oklch()` colour written with plain numbers, such as `oklch(0.205 0 0)` or `oklch(1 0 0 / 10%)`. Saving keeps only the checked colours, as `{ "theme": { "primary": { "light": "oklch(0.205 0 0)", "dark": "oklch(0.922 0 0)" } } }` under the `colours` key, and the pasted text itself is never stored. A role with no value in the `.dark` block is kept with a light value alone.
|
|
194
|
+
|
|
195
|
+
Shape and type settings are kept from the `:root` block only, as `{ "radius": { "light": "0.5rem" } }`, and each is checked by its kind. A radius, spacing, border width or shadow blur must be a number followed by `px`, `rem` or `em`, or zero. A shadow's spread and offsets, and a letter spacing, may also be negative, and a letter spacing may be `normal`. A shadow colour is checked as a colour, a shadow opacity must be a number from 0 to 1, and a font weight must be a whole hundred from 100 to 900. A font list must be comma-separated names made of letters, digits, spaces and hyphens, each optionally in single or double quotes. A font in the list is kept only when the app names it in `theme_fonts` or it is one of the system fonts the gem lists: `system-ui`, `ui-sans-serif`, `ui-serif`, `ui-monospace`, `ui-rounded`, `sans-serif`, `serif`, `monospace`, `cursive`, `-apple-system`, `BlinkMacSystemFont`, `Segoe UI`, `Roboto`, `Helvetica`, `Helvetica Neue`, `Arial`, `Georgia`, `Times New Roman`, `Menlo`, `Monaco`, `Consolas`, `Courier New` and `SFMono-Regular`. Any other font is dropped, so the browser uses the next font in the list and nothing is requested from another site. A kept name of more than one word is written in double quotes and a one-word name is written bare. The gem loads no font file, so an app that names a font in `theme_fonts` serves that font itself. The Theme section then says the current choice is the person's own theme and selects no template. The palette style tag then writes the theme onto every page, as described under Colours.
|
|
196
|
+
|
|
197
|
+
Each refusal below saves nothing and says why. A paste over 20,000 characters is refused before any of it is read. A paste containing `url(` or `@import` anywhere is refused. A paste with no shadcn colour role in a `:root` or `.dark` block is refused. A colour that fails the check is refused with a message naming the role and whether it is the light or dark value. A shape or type value that fails its check is refused with a message naming the setting, and so is a font list left with no font the app offers. A paste is refused with the same message as other colours while the account sets the colours for everyone.
|
|
198
|
+
|
|
199
|
+
Under the paste box, a disclosure that starts closed lists every theme setting as its own field, named by its shadcn variable. Each colour role has a light and a dark keystone_ui colour picker, and the shadow colour has one, so a colour can be typed as hex, `rgb()`, `hsl()` or `oklch()`. Every other setting is a text field. The fields show the person's saved theme, so after a paste they hold the values that were read. Changing a field and pressing Save saves the fields as the person's theme, so a theme edited by hand stays the person's own. A field left blank leaves that setting out of the theme. Each value is checked and refused exactly as a pasted one is, with spaces around it removed first. The fields are saved only when they differ from the saved theme, so picking a template or changing the Accent, Surface or Text picker saves as before, and a paste sent in the same save wins over the fields. The disclosure needs no script, but the colour pickers need keystone_ui's colour picker script to take a typed colour.
|
|
200
|
+
|
|
201
|
+
The preset themes are option cards after the templates, each showing its light primary and secondary colours as swatches, and the person's saved preset is selected. Picking a preset card fills the theme fields with that preset's values, through the template picker script, and once a preset is saved the fields show its values. Theme fields sent unchanged, holding the picked or the saved preset's values, save the picked card as before. Changing a field after picking a preset saves the fields as the person's own theme in place of the preset. Without the script, picking a card and pressing Save still saves the preset.
|
|
202
|
+
|
|
203
|
+
The `colours` key follows the account layouts above. While an account turns off members-choose for it, the section says the account sets the colours for everyone and offers no colours, and a save that sends colours anyway is refused with a message and saves nothing.
|
|
204
|
+
|
|
205
|
+
## Mode
|
|
206
|
+
|
|
207
|
+
The appearance section also offers Light, Dark and System, as keystone_ui-colors' mode control does, with the person's saved mode selected, or `default_mode` when they have none. It offers Custom too when the person may choose their own colours, or when the colours that apply to them draw a background other than white. The mode is offered while the account sets the colours, and saving it there keeps the mode alone.
|
|
208
|
+
|
|
209
|
+
Saving keeps `{ "mode": "dark" }` for that person under the `mode` key. A mode is only ever read from the person's own row, never from the account's, so it applies whether or not the account lets members choose colours. A mode other than `light`, `dark`, `system` or `custom` is refused with a message, and nothing is saved.
|
|
210
|
+
|
|
211
|
+
The gem gives keystone_ui the mode through keystone_ui's theme mode supplier, so a layout whose `<html>` tag calls keystone_ui's `keystone_theme_attributes` is marked with the person's mode on every page:
|
|
212
|
+
|
|
213
|
+
```erb
|
|
214
|
+
<html <%= keystone_theme_attributes %>>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A visitor who is not signed in gets `default_mode`. A choice made with keystone_ui's theme toggle is kept in a cookie and wins over the saved mode in that browser. Saving in the appearance section clears that cookie, so the saved mode shows on the next page. The clearing runs in settings_hub's `after_settings_change` hook, which the `ComponentPreferences` concern defines, so an app that defines its own `after_settings_change` calls `super` from it.
|
|
218
|
+
|
|
219
|
+
## Look
|
|
220
|
+
|
|
221
|
+
When the app registers looks with keystone_ui, the appearance section also offers a look, as keystone_ui-colors' look control does. It lists the app's own look first, labelled with `app_look_label`, and then every registered look by name. The person's saved look is selected, or the app's own look when they have none.
|
|
222
|
+
|
|
223
|
+
Saving a look keeps `{ "look": "rounded" }` for that person under the `look` key. Saving the app's own look removes the person's `look` row, so the account's look, or the app's default look, applies again. A look name keystone_ui does not register is refused with a message, and nothing is saved.
|
|
224
|
+
|
|
225
|
+
A person's look comes from their own row while the account lets members choose their own look, then from the account's row, and a saved look the app no longer registers is passed over for the next one. With neither, keystone_ui uses its `default_look`. A visitor who is not signed in gets `default_look` too.
|
|
226
|
+
|
|
227
|
+
The gem gives keystone_ui the look through keystone_ui's look supplier, so the same `keystone_theme_attributes` call marks the `<html>` tag with `data-look` on every page.
|
|
228
|
+
|
|
229
|
+
An app that keeps people off their own looks sets `user_looks` to false. The appearance section then shows no look choice, a save ignores any look sent with it and keeps the rest, and a person's saved look is passed over for the account's look or the app's default look. With no looks registered the section shows no look choice.
|
|
230
|
+
|
|
231
|
+
## Account appearance
|
|
232
|
+
|
|
233
|
+
An admin sets the account's colours and look in a settings_hub section. Who may open it is the app's decision, so register it behind a capability of the app's choosing. settings_hub refuses a capability the app does not define, so use the one the app already uses for its other account sections in place of `:manage_account`:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
SettingsHub.section :account_appearance, area: :account, title: "Appearance",
|
|
237
|
+
capability: :manage_account,
|
|
238
|
+
renders: "keystone_ui/preferences/settings/account_appearance",
|
|
239
|
+
runs: "KeystoneUi::Preferences::PickAccountAppearance"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The section looks and behaves as keystone_ui-colors' account picker does. It offers Default, the five named templates, the six preset themes and Custom with the three colour pickers, the paste box and the theme fields the person's section has, then the app's own look and every registered look, with the account's saved choices selected, or Default and the app's own look when it has none. Under them are two switches, one for whether members choose their own colours and one for whether members choose their own look. Both are on until an admin turns them off.
|
|
243
|
+
|
|
244
|
+
Saving keeps the account's colours under the `colours` key, as `{ "template_name": "forest" }`, `{ "preset_name": "harbor" }`, `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }` or a pasted or hand-edited theme, and its look under the `look` key, with each switch in that row's `members_choose` column. Saving Default keeps `{ "template_name": "default" }`, and saving the app's own look keeps an empty look, so neither removes the row its switch is on. The colours, a pasted theme, the theme fields and the look pass the same checks as a person's, the theme fields are saved only when they differ from the account's saved theme or the picked or saved preset, and a template, preset, colour, theme value or look that fails one is refused with a message, and nothing is saved. A save with no account is refused too.
|
|
245
|
+
|
|
246
|
+
A member with no colours of their own sees the account's colours and theme. While the colours switch is off, every member sees the account's colours and theme, and their appearance section says the account sets the colours and offers no colour choice. While the look switch is off, every member sees the account's look, and their appearance section offers no look choice. A look a member sends then is ignored, and the rest of the save is kept. While the look switch is on, a member who chose a look keeps it, and a member with none sees the account's.
|
|
247
|
+
|
|
248
|
+
An app that keeps accounts off colours sets `account_colors` to false. Neither section then offers a colour choice or the colours switch, every page is drawn in the four configured defaults, and colours sent to either section are refused with a message. An app that keeps accounts off looks sets `account_looks` to false. The account's section then offers no look choice and no look switch, a look sent to it is ignored, and a saved account look is not applied.
|
|
249
|
+
|
|
250
|
+
## Keeping a page on the app's colours
|
|
251
|
+
|
|
252
|
+
A controller that declares `keystone_host_colors` draws its pages in the app's four default colours, `default_mode` and keystone_ui's `default_look`, whatever the signed-in person or their account saved. The declaration has the same name keystone_ui-colors gives it, so a controller that already declares it keeps working unchanged. It takes the same `only:` and `except:` options as `before_action`.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
class HomeController < ApplicationController
|
|
256
|
+
keystone_host_colors
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
A choice made with keystone_ui's theme toggle in that browser still sets light or dark on these pages.
|
|
261
|
+
|
|
262
|
+
## Moving off keystone_ui-colors
|
|
263
|
+
|
|
264
|
+
An app that saved colours, modes and looks with keystone_ui-colors keeps them by following these steps in this order. An app that has never installed keystone_ui-preferences runs its install generator at step 5 instead of the update generator.
|
|
265
|
+
|
|
266
|
+
1. Set keystone_ui-preferences to `~> 0.7` and remove keystone_ui-colors from the Gemfile, then run `bundle install`.
|
|
267
|
+
2. Change every `KeystoneUi::Colors` name the app uses to its replacement, and move any settings from a keystone_ui-colors initializer into a keystone_ui-preferences initializer. keystone_ui-colors' README lists each name and what replaces it under [Moving to keystone_ui-preferences](https://github.com/DYB-Development/keystone_ui-colors#moving-to-keystone_ui-preferences).
|
|
268
|
+
3. Register the person's appearance section and the account's appearance section, as [Appearance](#appearance) and [Account appearance](#account-appearance) show. Register the account's behind the capability the app already uses for its other account sections.
|
|
269
|
+
4. Call `super` from any `after_settings_change` the app defines.
|
|
270
|
+
5. Run its update generator, which copies the migration below into `db/migrate`.
|
|
271
|
+
6. Run `bin/rails db:migrate`.
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
# set keystone_ui-preferences to ~> 0.7 and remove keystone_ui-colors from the Gemfile
|
|
275
|
+
bundle install
|
|
276
|
+
# change each KeystoneUi::Colors name, move the initializer settings, register both appearance sections, call super from after_settings_change
|
|
277
|
+
bin/rails generate keystone_ui:preferences:update
|
|
278
|
+
bin/rails db:migrate
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Boot stops with `KeystoneUi::Preferences::ColorsCheck::Error` while keystone_ui-colors is loaded, and the message names keystone_ui-colors as the gem to remove. The update generator and `bin/rails db:migrate` both boot the app, so keystone_ui-colors comes out of the Gemfile before either runs. An initializer, route, controller or helper that still names `KeystoneUi::Colors`, such as a `KeystoneUi::Colors.configure` block, a route mounting `KeystoneUi::Colors::Engine`, `include KeystoneUi::Colors::CurrentPalette` or `helper KeystoneUi::Colors::PaletteHelper`, also stops the app from booting, so every name is changed before the generator runs.
|
|
282
|
+
|
|
283
|
+
The generator copies a migration that reads every row of `keystone_ui_colors_theme_preferences` and writes up to three rows for the same owner:
|
|
284
|
+
|
|
285
|
+
| keystone_ui-colors | keystone_ui-preferences |
|
|
286
|
+
|--------------------|-------------------------|
|
|
287
|
+
| `template_name`, including `default` | `colours` row holding `{"template_name": ...}` |
|
|
288
|
+
| `accent`, `surface` and `text` with no template | `colours` row holding `{"accent": ..., "surface": ..., "text": ...}` |
|
|
289
|
+
| `members_choose` | `members_choose` of the `colours` row |
|
|
290
|
+
| `mode` | `mode` row holding `{"mode": ...}`, written only when a mode was saved |
|
|
291
|
+
| `look` | `look` row holding `{"look": ...}`, written only when a look was saved |
|
|
292
|
+
| `members_choose_look` | `members_choose` of the `look` row, which holds `{}` for an account on the app's own look that sets the look for everyone |
|
|
293
|
+
|
|
294
|
+
An owner and key that already has a row keeps it, so a choice made in keystone_ui-preferences is never replaced by an older one, and running the migration again changes nothing. In an app without keystone_ui-colors' table the migration does nothing. Rolling it back changes neither table. Drop keystone_ui-colors' table once the copy has run.
|
|
295
|
+
|
|
136
296
|
## What is stored
|
|
137
297
|
|
|
138
298
|
One row per owner and component key, in `keystone_ui_preferences_component_preferences`:
|
|
@@ -8,7 +8,13 @@ module KeystoneUi
|
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
10
10
|
included do
|
|
11
|
-
helper_method :keystone_component_preference if respond_to?(:helper_method)
|
|
11
|
+
helper_method :keystone_component_preference, :keystone_palette_style_tag, :keystone_theme_mode, :keystone_look if respond_to?(:helper_method)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def keystone_host_colors(**options)
|
|
16
|
+
before_action :keystone_keep_host_colours, **options
|
|
17
|
+
end
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def keystone_component_preference(component_key)
|
|
@@ -22,6 +28,19 @@ module KeystoneUi
|
|
|
22
28
|
}
|
|
23
29
|
end
|
|
24
30
|
|
|
31
|
+
def keystone_palette_style_tag
|
|
32
|
+
choice = keystone_appearance_choice
|
|
33
|
+
helpers.content_tag(:style, [ ThemeCss.new(palette: choice.colours).to_s, choice.theme_css ].compact.join("\n"))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def keystone_theme_mode
|
|
37
|
+
keystone_appearance_choice.mode
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def keystone_look
|
|
41
|
+
keystone_appearance_choice.look
|
|
42
|
+
end
|
|
43
|
+
|
|
25
44
|
def keystone_layout_choice(component_key)
|
|
26
45
|
LayoutChoice.new(person: send(KeystoneUi::Preferences.configuration.current_owner_method), account: keystone_preferences_account, component_key: component_key)
|
|
27
46
|
end
|
|
@@ -30,6 +49,22 @@ module KeystoneUi
|
|
|
30
49
|
method = KeystoneUi::Preferences.configuration.current_account_method
|
|
31
50
|
send(method) if method
|
|
32
51
|
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def keystone_keep_host_colours
|
|
56
|
+
@keystone_host_colours = true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def keystone_appearance_choice
|
|
60
|
+
return AppearanceChoice.new(person: nil, account: nil) if @keystone_host_colours
|
|
61
|
+
|
|
62
|
+
AppearanceChoice.new(person: send(KeystoneUi::Preferences.configuration.current_owner_method), account: keystone_preferences_account)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def after_settings_change(section:, **)
|
|
66
|
+
cookies.delete(KeystoneUi::ThemeChoice::COOKIE) if section.runs.to_s == PickAppearance.name
|
|
67
|
+
end
|
|
33
68
|
end
|
|
34
69
|
end
|
|
35
70
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["accentPicker", "surfacePicker", "textPicker", "customRadio", "templateLabel"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.colorPickerTargets().forEach(picker => picker.addEventListener("change", () => this.customColorChanged()))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
colorPickerTargets() {
|
|
11
|
+
return [
|
|
12
|
+
this.hasAccentPickerTarget && this.accentPickerTarget,
|
|
13
|
+
this.hasSurfacePickerTarget && this.surfacePickerTarget,
|
|
14
|
+
this.hasTextPickerTarget && this.textPickerTarget
|
|
15
|
+
].filter(Boolean)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
selectTemplate(event) {
|
|
19
|
+
const input = event.currentTarget
|
|
20
|
+
const label = input.closest("[data-template]")
|
|
21
|
+
const accentHex = input.dataset.accentHex
|
|
22
|
+
const surfaceHex = input.dataset.surfaceHex
|
|
23
|
+
const textHex = input.dataset.textHex
|
|
24
|
+
|
|
25
|
+
this.setPickerValue(this.accentPickerTarget, accentHex)
|
|
26
|
+
this.setPickerValue(this.surfacePickerTarget, surfaceHex)
|
|
27
|
+
this.setPickerValue(this.textPickerTarget, textHex)
|
|
28
|
+
|
|
29
|
+
this.highlightTemplate(label.dataset.template)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
selectPreset(event) {
|
|
33
|
+
const input = event.currentTarget
|
|
34
|
+
const theme = JSON.parse(input.dataset.theme)
|
|
35
|
+
|
|
36
|
+
Object.entries(theme).forEach(([role, sides]) => {
|
|
37
|
+
Object.entries(sides).forEach(([side, value]) => this.setThemeField(`theme[${role}][${side}]`, value))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
this.highlightTemplate(input.closest("[data-template]").dataset.template)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setThemeField(name, value) {
|
|
44
|
+
const field = this.element.querySelector(`[name="${name}"]`)
|
|
45
|
+
if (!field) return
|
|
46
|
+
|
|
47
|
+
const picker = field.closest("[data-controller~='color-picker']")
|
|
48
|
+
if (picker) {
|
|
49
|
+
this.setPickerValue(picker, value)
|
|
50
|
+
} else {
|
|
51
|
+
field.value = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
customColorChanged() {
|
|
56
|
+
if (this.hasCustomRadioTarget) this.customRadioTarget.checked = true
|
|
57
|
+
this.highlightTemplate("custom")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
highlightTemplate(selected) {
|
|
61
|
+
this.templateLabelTargets.forEach(label => {
|
|
62
|
+
const border = label.querySelector("span")
|
|
63
|
+
if (label.dataset.template === selected) {
|
|
64
|
+
border.classList.remove("border-transparent")
|
|
65
|
+
border.classList.add("border-accent-500")
|
|
66
|
+
} else {
|
|
67
|
+
border.classList.remove("border-accent-500")
|
|
68
|
+
border.classList.add("border-transparent")
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setPickerValue(wrapper, hex) {
|
|
74
|
+
const input = wrapper.querySelector("input[type='hidden']")
|
|
75
|
+
const swatch = wrapper.querySelector("[data-color-picker-target='swatch']")
|
|
76
|
+
const hexLabel = wrapper.querySelector("[data-color-picker-target='hexLabel']")
|
|
77
|
+
const text = wrapper.querySelector("[data-color-picker-target='text']")
|
|
78
|
+
|
|
79
|
+
if (input) input.value = hex
|
|
80
|
+
if (swatch) swatch.style.backgroundColor = hex
|
|
81
|
+
if (hexLabel) hexLabel.textContent = hex
|
|
82
|
+
if (text) text.value = hex
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class AppearanceChoice
|
|
6
|
+
DEFAULT_TEMPLATE = "default"
|
|
7
|
+
HEX = /\A#\h{6}\z/
|
|
8
|
+
PALETTE_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
9
|
+
WHITE = "#ffffff"
|
|
10
|
+
|
|
11
|
+
def initialize(person:, account:, own_look: nil)
|
|
12
|
+
@person = person
|
|
13
|
+
@account = account
|
|
14
|
+
@own_look = own_look
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def colours
|
|
18
|
+
@colours ||= chosen_colours
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def mode
|
|
22
|
+
LayoutChoice.new(person: @person, account: nil, component_key: "mode").value.to_h["mode"] || configuration.default_mode
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def look
|
|
26
|
+
return nil unless @person
|
|
27
|
+
|
|
28
|
+
[ (saved_look(@person) if person_chooses_look?), (saved_look(@account) if configuration.account_looks) ].find { |look| KeystoneUi.configuration.looks.key?(look) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def person_chooses_look?
|
|
32
|
+
configuration.user_looks && LayoutChoice.new(person: @person, account: @account, component_key: "look").members_choose?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def custom_offered?
|
|
36
|
+
!colours[:background].casecmp?(WHITE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def theme_css
|
|
40
|
+
return unless configuration.account_colors
|
|
41
|
+
return preset_css if saved.key?("preset_name")
|
|
42
|
+
return unless saved.key?("theme")
|
|
43
|
+
return refused_theme_css if refused_theme
|
|
44
|
+
|
|
45
|
+
Rails.cache.fetch([ colours_choice.preference.cache_key_with_version, "theme_css", ThemeCss::VERSION, own_look? ]) { written_theme }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def chosen_colours
|
|
51
|
+
return default_colours unless configuration.account_colors
|
|
52
|
+
return default_colours if saved["template_name"] == DEFAULT_TEMPLATE
|
|
53
|
+
return template_colours if template_offered?
|
|
54
|
+
return fallback_colours if saved["template_name"].present?
|
|
55
|
+
return account_colours if saved["preset_name"].present? && !preset_offered?
|
|
56
|
+
return account_colours if refused_theme
|
|
57
|
+
|
|
58
|
+
surface = catalogue_colour("surface", KeystoneUi::Looks::Palettes::SURFACES) || configuration.default_surface
|
|
59
|
+
{
|
|
60
|
+
accent: catalogue_colour("accent", KeystoneUi::Looks::Palettes::ACCENTS) || configuration.default_accent,
|
|
61
|
+
surface: surface,
|
|
62
|
+
background: surface.start_with?("#") ? surface : configuration.default_background,
|
|
63
|
+
text: (saved["text"] if saved["text"].to_s.match?(HEX)) || configuration.default_text
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def preset_css
|
|
68
|
+
unless preset_offered?
|
|
69
|
+
warn_missing("preset", saved["preset_name"])
|
|
70
|
+
return account_theme_css
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Rails.cache.fetch([ "preset_css", saved["preset_name"], KeystoneUi::Looks::VERSION, ThemeCss::VERSION, own_look? ]) { written(KeystoneUi::Looks::Themes[saved["preset_name"]][:theme]) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def preset_offered?
|
|
77
|
+
KeystoneUi::Looks::Themes.names.map(&:to_s).include?(saved["preset_name"].to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def refused_theme_css
|
|
81
|
+
row = colours_choice.preference
|
|
82
|
+
Rails.logger.warn("keystone_ui-preferences: #{row.owner_type} #{row.owner_id} saved a theme #{refused_theme}")
|
|
83
|
+
account_theme_css
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def refused_theme
|
|
87
|
+
return unless saved.key?("theme")
|
|
88
|
+
return "that is not a set of roles, so it fails the theme check" unless theme_shaped?(saved["theme"])
|
|
89
|
+
|
|
90
|
+
@refused_theme ||= CheckedTheme.new(saved["theme"]).failing_setting&.then { |setting| "whose --#{setting} setting fails the theme check" }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def written_theme
|
|
94
|
+
written(saved["theme"])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def written(theme)
|
|
98
|
+
checked = CheckedTheme.new(theme)
|
|
99
|
+
ThemeCss.new(checked.value["theme"], own_look: own_look?).to_s unless checked.refusal
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def own_look?
|
|
103
|
+
return @own_look unless @own_look.nil?
|
|
104
|
+
|
|
105
|
+
@person.present? && person_chooses_look? && KeystoneUi.configuration.looks.key?(saved_look(@person))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def theme_shaped?(theme)
|
|
109
|
+
theme.is_a?(Hash) && theme.values.all?(Hash)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def saved_look(owner)
|
|
113
|
+
LayoutChoice.new(person: owner, account: nil, component_key: "look").value.to_h["look"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def catalogue_colour(kind, palettes)
|
|
117
|
+
value = saved[kind].to_s
|
|
118
|
+
return value if value.match?(HEX)
|
|
119
|
+
return unless value.match?(PALETTE_NAME)
|
|
120
|
+
return value if palettes.key?(value.to_sym)
|
|
121
|
+
|
|
122
|
+
warn_missing(kind, value)
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_colours
|
|
127
|
+
{ accent: configuration.default_accent, surface: configuration.default_surface, background: configuration.default_background, text: configuration.default_text }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fallback_colours
|
|
131
|
+
warn_missing("template", saved["template_name"])
|
|
132
|
+
account_colours
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def account_theme_css
|
|
136
|
+
AppearanceChoice.new(person: @account, account: nil, own_look: own_look?).theme_css unless @account.nil? || colours_choice.preference.owned_by?(@account)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def account_colours
|
|
140
|
+
return default_colours if @account.nil? || colours_choice.preference.owned_by?(@account)
|
|
141
|
+
|
|
142
|
+
AppearanceChoice.new(person: @account, account: nil).colours
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def warn_missing(kind, name)
|
|
146
|
+
row = colours_choice.preference
|
|
147
|
+
Rails.logger.warn("keystone_ui-preferences: #{row.owner_type} #{row.owner_id} saved the #{kind} #{name.to_s.inspect}, which keystone_ui-looks does not have")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def template_offered?
|
|
151
|
+
KeystoneUi::Looks::Templates.names.map(&:to_s).include?(saved["template_name"].to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def template_colours
|
|
155
|
+
template = KeystoneUi::Looks::Templates[saved["template_name"]]
|
|
156
|
+
{ accent: template[:accent].to_s, surface: template[:surface].to_s, background: template[:background], text: template[:text] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def saved
|
|
160
|
+
@saved ||= colours_choice.value.to_h
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def colours_choice
|
|
164
|
+
@colours_choice ||= LayoutChoice.new(person: @person, account: @account, component_key: "colours")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def configuration
|
|
168
|
+
KeystoneUi::Preferences.configuration
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module KeystoneUi
|
|
4
|
+
module Preferences
|
|
5
|
+
class CheckedTheme
|
|
6
|
+
NUMBER = '-?(?:\d+(?:\.\d+)?|\.\d+)%?'
|
|
7
|
+
GAP = '(?:\s*,\s*|\s+)'
|
|
8
|
+
HEX = /\A#(?:\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
9
|
+
FUNCTION = /\A(?:rgba?|hsla?|oklch)\(\s*#{NUMBER}#{GAP}#{NUMBER}#{GAP}#{NUMBER}(?:\s*[\/,]\s*#{NUMBER})?\s*\)\z/
|
|
10
|
+
COLOUR = Regexp.union(HEX, FUNCTION)
|
|
11
|
+
UNSIGNED_LENGTH = '(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em))'
|
|
12
|
+
LENGTH = /\A#{UNSIGNED_LENGTH}\z/
|
|
13
|
+
SIGNED_LENGTH = /\A-?#{UNSIGNED_LENGTH}\z/
|
|
14
|
+
FONT_NAME = "[A-Za-z0-9-]+(?: [A-Za-z0-9-]+)*"
|
|
15
|
+
FONT = "(?:#{FONT_NAME}|\"#{FONT_NAME}\"|'#{FONT_NAME}')"
|
|
16
|
+
FONT_LIST = /\A#{FONT}(?:\s*,\s*#{FONT})*\z/
|
|
17
|
+
WEIGHT = /\A[1-9]00\z/
|
|
18
|
+
OPACITY = /\A(?:0(?:\.\d+)?|\.\d+|1(?:\.0+)?)\z/
|
|
19
|
+
PATTERNS = {
|
|
20
|
+
colour: COLOUR, shadow_colour: COLOUR, length: LENGTH, signed_length: SIGNED_LENGTH, opacity: OPACITY, weight: WEIGHT,
|
|
21
|
+
letter_spacing: Regexp.union(SIGNED_LENGTH, /\Anormal\z/), font_list: FONT_LIST
|
|
22
|
+
}.freeze
|
|
23
|
+
REASONS = {
|
|
24
|
+
shadow_colour: "is not hex, rgb(), hsl() or oklch() written with plain numbers",
|
|
25
|
+
length: "is not a number followed by px, rem or em, or zero",
|
|
26
|
+
signed_length: "is not a number followed by px, rem or em, or zero",
|
|
27
|
+
opacity: "is not a number from 0 to 1",
|
|
28
|
+
weight: "is not a font weight from 100 to 900 in hundreds",
|
|
29
|
+
letter_spacing: "is not normal or a number followed by px, rem or em, or zero",
|
|
30
|
+
font_list: "is not a list of font names made of letters, digits, spaces and hyphens"
|
|
31
|
+
}.freeze
|
|
32
|
+
SIDES = %w[light dark].freeze
|
|
33
|
+
SYSTEM_FONTS = [
|
|
34
|
+
"system-ui", "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded", "sans-serif", "serif", "monospace", "cursive",
|
|
35
|
+
"-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", "Helvetica", "Helvetica Neue", "Arial", "Georgia",
|
|
36
|
+
"Times New Roman", "Menlo", "Monaco", "Consolas", "Courier New", "SFMono-Regular"
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
def initialize(values)
|
|
40
|
+
@values = values
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refusal
|
|
44
|
+
name, side, value = failure
|
|
45
|
+
return unless name
|
|
46
|
+
return Refusal.new("The --#{name} value names no font this app offers, so it was not saved.") if matches?(name, value)
|
|
47
|
+
|
|
48
|
+
reason(kind_of(name), name, side)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def failing_setting
|
|
52
|
+
failure&.first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def value
|
|
56
|
+
{ "theme" => kept.to_h { |name, sides| [ name, kind_of(name) == :font_list ? sides.transform_values { |list| offered_fonts(list).map { |font| written_font(font) }.join(", ") } : sides ] } }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def failure
|
|
62
|
+
kept.each do |name, sides|
|
|
63
|
+
sides.each do |side, value|
|
|
64
|
+
return [ name, side, value ] unless matches?(name, value) && names_offered_font?(name, value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def matches?(name, value)
|
|
71
|
+
value.to_s.match?(PATTERNS.fetch(kind_of(name)))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def names_offered_font?(name, value)
|
|
75
|
+
kind_of(name) != :font_list || offered_fonts(value).any?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sides_of(name)
|
|
79
|
+
kind_of(name) == :colour ? SIDES : SIDES.first(1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def kind_of(name)
|
|
83
|
+
ThemeRoles::ROLES.fetch(name)[:kind]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def offered_fonts(list)
|
|
87
|
+
offered = (KeystoneUi::Preferences.configuration.theme_fonts + SYSTEM_FONTS).map(&:downcase)
|
|
88
|
+
list.split(",").map { |font| font.strip.delete("\"'") }.select { |font| offered.include?(font.downcase) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def written_font(font)
|
|
92
|
+
font.include?(" ") ? "\"#{font}\"" : font
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reason(kind, name, side)
|
|
96
|
+
return Refusal.new("The #{side} --#{name} colour is not hex, rgb(), hsl() or oklch() written with plain numbers, so it was not saved.") if kind == :colour
|
|
97
|
+
|
|
98
|
+
Refusal.new("The --#{name} value #{REASONS.fetch(kind)}, so it was not saved.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def kept
|
|
102
|
+
@kept ||= ThemeRoles.names.each_with_object({}) do |name, kept|
|
|
103
|
+
sides = @values.to_h[name].to_h.slice(*sides_of(name))
|
|
104
|
+
kept[name] = sides if sides.any?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|