keystone_ui-preferences 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbf5cec0b85185ca3abee2d52c65c7e80f26044c9a0c7fb9a19df0fdba9464c6
4
- data.tar.gz: a1885de65a350f4f7025fa2bdb1eb160a11d1f9402a4b38615672a4621a4d2bc
3
+ metadata.gz: f37c2cf73a8559ec91481efe3e542f12f3cd88363dce8de5396a3b0b70ed63dd
4
+ data.tar.gz: d4335e98170923ea6920e602a99c6b55fb81be63236834f37b1ffbc4d1a5ebe1
5
5
  SHA512:
6
- metadata.gz: 834b8955f9a2048c833372321b52c13829376dc0d906e4c66d30bd4ef5d9b356f894057f6b1005c166d5b72e9fe33f1df7556f52bd6a9182e59613885c24771e
7
- data.tar.gz: 30eb7dbafec075ee43fd6d1cc80a1af79fa6b9ef8c3eb1f68ef250f12f7a1fb042f44b9b1dccdd86580a52ac3de5c5ed15e4495ad0c8476887ba2dfac1bf53b1
6
+ metadata.gz: 799cef36ff33e44be4fb48c58604e129e81002205518c2d2dc5f59a09c3a1e4f097c6d0fe8d4cbe1bf276774542150c9e6573e72208700d20aca41a64d589e77
7
+ data.tar.gz: 25617ce106f11bc189f115ad36ec2979884a90adb9f973bc703da55a11d8ab73a368f62fc15d76bc327cfd0c5333b5907125959ccd5a357146d89ede96b89fc5
data/README.md CHANGED
@@ -65,6 +65,7 @@ KeystoneUi::Preferences.configure do |config|
65
65
  config.app_look_label = "Classic" # the label for the app's own look in the appearance section
66
66
  config.account_colors = true # whether an account may choose its colours, and its members their own
67
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
68
69
  end
69
70
  ```
70
71
 
@@ -159,11 +160,17 @@ Add the palette style tag to your layout's `<head>`:
159
160
 
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.
161
162
 
162
- 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" }` or `{ "template_name": "forest" }`. 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.
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.
163
164
 
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.
165
166
 
166
- 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. 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.
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.
167
174
 
168
175
  ## Appearance
169
176
 
@@ -175,11 +182,23 @@ SettingsHub.section :appearance, area: :user, title: "Appearance",
175
182
  runs: "KeystoneUi::Preferences::PickAppearance"
176
183
  ```
177
184
 
178
- The section looks and behaves as keystone_ui-colors' colour settings do. It offers Default, the five named templates 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.
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`.
179
192
 
180
- Saving a template keeps `{ "template_name": "forest" }` for that person under the `colours` key. 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.
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.
181
194
 
182
- A template that is not Default or one of the five 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.
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.
183
202
 
184
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.
185
204
 
@@ -211,7 +230,7 @@ An app that keeps people off their own looks sets `user_looks` to false. The app
211
230
 
212
231
  ## Account appearance
213
232
 
214
- 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:
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`:
215
234
 
216
235
  ```ruby
217
236
  SettingsHub.section :account_appearance, area: :account, title: "Appearance",
@@ -220,11 +239,11 @@ SettingsHub.section :account_appearance, area: :account, title: "Appearance",
220
239
  runs: "KeystoneUi::Preferences::PickAccountAppearance"
221
240
  ```
222
241
 
223
- The section looks and behaves as keystone_ui-colors' account picker does. It offers Default, the five named templates and Custom with the three colour pickers, 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.
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.
224
243
 
225
- Saving keeps the account's colours under the `colours` key, as `{ "template_name": "forest" }` or `{ "accent": "#e11d48", "surface": "#f5e6c8", "text": "#3b2f1e" }`, 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 and look pass the same checks as a person's, and a template, colour or look that fails one is refused with a message, and nothing is saved. A save with no account is refused too.
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.
226
245
 
227
- While the colours switch is off, every member sees the account's colours, 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.
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.
228
247
 
229
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.
230
249
 
@@ -242,26 +261,24 @@ A choice made with keystone_ui's theme toggle in that browser still sets light o
242
261
 
243
262
  ## Moving off keystone_ui-colors
244
263
 
245
- 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 adds it to the Gemfile and runs its install generator before step 1.
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.
246
265
 
247
- 1. Update keystone_ui-preferences.
248
- 2. Run its update generator, which copies the migration below into `db/migrate`.
249
- 3. Remove keystone_ui-colors from the Gemfile and run `bundle install`.
250
- 4. Change every `KeystoneUi::Colors` name the app uses to its replacement. 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).
251
- 5. Register the person's appearance section and the account's appearance section, the account's behind `capability: :manage_account`, as [Appearance](#appearance) and [Account appearance](#account-appearance) show.
252
- 6. Call `super` from any `after_settings_change` the app defines.
253
- 7. Run `bin/rails db:migrate`.
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`.
254
272
 
255
273
  ```bash
256
- bundle update keystone_ui-preferences
257
- bin/rails generate keystone_ui:preferences:update
258
- # remove keystone_ui-colors from the Gemfile
274
+ # set keystone_ui-preferences to ~> 0.7 and remove keystone_ui-colors from the Gemfile
259
275
  bundle install
260
- # change each KeystoneUi::Colors name, register both appearance sections, call super from after_settings_change
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
261
278
  bin/rails db:migrate
262
279
  ```
263
280
 
264
- 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 and the update generator as the step to run first. `bin/rails db:migrate` boots the app, so keystone_ui-colors comes out of the Gemfile before the migration runs, and the generator runs before keystone_ui-colors comes out so the copy migration is in `db/migrate` when it does. 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 migration runs.
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.
265
282
 
266
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:
267
284
 
@@ -29,8 +29,8 @@ module KeystoneUi
29
29
  end
30
30
 
31
31
  def keystone_palette_style_tag
32
- colours = keystone_appearance_choice.colours
33
- helpers.content_tag(:style, PaletteCss.new(**colours).to_s)
32
+ choice = keystone_appearance_choice
33
+ helpers.content_tag(:style, [ ThemeCss.new(palette: choice.colours).to_s, choice.theme_css ].compact.join("\n"))
34
34
  end
35
35
 
36
36
  def keystone_theme_mode
@@ -29,6 +29,29 @@ export default class extends Controller {
29
29
  this.highlightTemplate(label.dataset.template)
30
30
  }
31
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
+
32
55
  customColorChanged() {
33
56
  if (this.hasCustomRadioTarget) this.customRadioTarget.checked = true
34
57
  this.highlightTemplate("custom")
@@ -51,9 +74,11 @@ export default class extends Controller {
51
74
  const input = wrapper.querySelector("input[type='hidden']")
52
75
  const swatch = wrapper.querySelector("[data-color-picker-target='swatch']")
53
76
  const hexLabel = wrapper.querySelector("[data-color-picker-target='hexLabel']")
77
+ const text = wrapper.querySelector("[data-color-picker-target='text']")
54
78
 
55
79
  if (input) input.value = hex
56
80
  if (swatch) swatch.style.backgroundColor = hex
57
81
  if (hexLabel) hexLabel.textContent = hex
82
+ if (text) text.value = hex
58
83
  }
59
84
  }
@@ -8,9 +8,10 @@ module KeystoneUi
8
8
  PALETTE_NAME = /\A[a-z][a-z0-9_]*\z/
9
9
  WHITE = "#ffffff"
10
10
 
11
- def initialize(person:, account:)
11
+ def initialize(person:, account:, own_look: nil)
12
12
  @person = person
13
13
  @account = account
14
+ @own_look = own_look
14
15
  end
15
16
 
16
17
  def colours
@@ -35,6 +36,15 @@ module KeystoneUi
35
36
  !colours[:background].casecmp?(WHITE)
36
37
  end
37
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
+
38
48
  private
39
49
 
40
50
  def chosen_colours
@@ -42,6 +52,8 @@ module KeystoneUi
42
52
  return default_colours if saved["template_name"] == DEFAULT_TEMPLATE
43
53
  return template_colours if template_offered?
44
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
45
57
 
46
58
  surface = catalogue_colour("surface", KeystoneUi::Looks::Palettes::SURFACES) || configuration.default_surface
47
59
  {
@@ -52,6 +64,51 @@ module KeystoneUi
52
64
  }
53
65
  end
54
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
+
55
112
  def saved_look(owner)
56
113
  LayoutChoice.new(person: owner, account: nil, component_key: "look").value.to_h["look"]
57
114
  end
@@ -72,6 +129,14 @@ module KeystoneUi
72
129
 
73
130
  def fallback_colours
74
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
75
140
  return default_colours if @account.nil? || colours_choice.preference.owned_by?(@account)
76
141
 
77
142
  AppearanceChoice.new(person: @account, account: nil).colours
@@ -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
@@ -8,12 +8,13 @@ module KeystoneUi
8
8
  end
9
9
 
10
10
  def refusal
11
- return Refusal.new("Choose Default, one of the colour templates or Custom.") unless template_offered?
11
+ return Refusal.new("Choose Default, one of the colour templates, one of the preset themes or Custom.") unless template_offered?
12
12
 
13
13
  Refusal.new("Choose each colour as a six-digit hex value, such as #1d4ed8.") unless colours_offered?
14
14
  end
15
15
 
16
16
  def value
17
+ return { "preset_name" => @values[:template_name] } if preset?
17
18
  return { "template_name" => @values[:template_name] } if @values[:template_name].present?
18
19
 
19
20
  { "accent" => @values[:accent], "surface" => @values[:surface], "text" => @values[:text] }
@@ -22,7 +23,11 @@ module KeystoneUi
22
23
  private
23
24
 
24
25
  def template_offered?
25
- @values[:template_name].blank? || [ AppearanceChoice::DEFAULT_TEMPLATE, *KeystoneUi::Looks::Templates.names.map(&:to_s) ].include?(@values[:template_name])
26
+ @values[:template_name].blank? || preset? || [ AppearanceChoice::DEFAULT_TEMPLATE, *KeystoneUi::Looks::Templates.names.map(&:to_s) ].include?(@values[:template_name])
27
+ end
28
+
29
+ def preset?
30
+ KeystoneUi::Looks::Themes.names.map(&:to_s).include?(@values[:template_name].to_s)
26
31
  end
27
32
 
28
33
  def colours_offered?
@@ -15,7 +15,7 @@ module KeystoneUi
15
15
 
16
16
  return Refusal.new("This app does not let accounts choose their colours.") if colours_sent? && !KeystoneUi::Preferences.configuration.account_colors
17
17
 
18
- chosen = ChosenColours.new(@values) if colours_sent?
18
+ chosen = chosen_colours
19
19
  return chosen.refusal if chosen&.refusal
20
20
 
21
21
  keep("colours", (chosen ? { value: chosen.value } : {}).merge(switch(:members_choose)))
@@ -26,7 +26,17 @@ module KeystoneUi
26
26
  private
27
27
 
28
28
  def colours_sent?
29
- @values.key?(:template_name)
29
+ @values.key?(:template_name) || sent_theme.sent?
30
+ end
31
+
32
+ def chosen_colours
33
+ return sent_theme.checked if sent_theme.sent?
34
+
35
+ ChosenColours.new(@values) if colours_sent?
36
+ end
37
+
38
+ def sent_theme
39
+ @sent_theme ||= SentTheme.new(@values) { ComponentPreference.find_by(owner: @account, component_key: "colours")&.value.to_h }
30
40
  end
31
41
 
32
42
  def look_offered?
@@ -12,9 +12,10 @@ module KeystoneUi
12
12
  def call
13
13
  return Refusal.new("Choose Light, Dark, System or Custom for the mode.") unless mode_offered?
14
14
  return Refusal.new("Choose the app's own look or one of the looks it offers.") unless look_offered?
15
- return keep_mode_and_look unless @values.key?(:template_name)
15
+ return keep_mode_and_look unless @values.key?(:template_name) || sent_theme.sent?
16
16
  return Refusal.new("This app sets the colours for everyone, so this was not saved.") unless KeystoneUi::Preferences.configuration.account_colors
17
17
  return Refusal.new("Your account sets the colours for everyone, so this was not saved.") unless members_choose?
18
+ return keep_checked(sent_theme.checked) if sent_theme.sent?
18
19
  return use_default if @values[:template_name] == AppearanceChoice::DEFAULT_TEMPLATE
19
20
 
20
21
  chosen = ChosenColours.new(@values)
@@ -38,6 +39,17 @@ module KeystoneUi
38
39
  LayoutChoice.new(person: @person, account: @account, component_key: "colours").members_choose?
39
40
  end
40
41
 
42
+ def sent_theme
43
+ @sent_theme ||= SentTheme.new(@values) { preference.value.to_h }
44
+ end
45
+
46
+ def keep_checked(checked)
47
+ return checked.refusal if checked.refusal
48
+
49
+ preference.update!(value: checked.value)
50
+ keep_mode_and_look
51
+ end
52
+
41
53
  def use_default
42
54
  preference.destroy! if preference.persisted?
43
55
  keep_mode_and_look
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Preferences
5
+ class SentTheme
6
+ def initialize(values, &saved_colours)
7
+ @values = values
8
+ @saved_colours = saved_colours
9
+ end
10
+
11
+ def sent?
12
+ pasting? || editing?
13
+ end
14
+
15
+ def checked
16
+ return pasted if pasting?
17
+
18
+ CheckedTheme.new(typed)
19
+ end
20
+
21
+ private
22
+
23
+ def pasting?
24
+ @values[:pasted_theme].present?
25
+ end
26
+
27
+ def editing?
28
+ typed.any? && [ saved["theme"], preset_theme(saved["preset_name"]), preset_theme(@values[:template_name]) ].exclude?(typed)
29
+ end
30
+
31
+ def saved
32
+ @saved ||= @saved_colours.call
33
+ end
34
+
35
+ def preset_theme(name)
36
+ KeystoneUi::Looks::Themes[name][:theme] if KeystoneUi::Looks::Themes.names.map(&:to_s).include?(name.to_s)
37
+ end
38
+
39
+ def typed
40
+ @typed ||= @values[:theme].to_h.to_h { |name, sides| [ name.to_s, sides.to_h.to_h { |side, value| [ side.to_s, value.to_s.strip ] }.compact_blank ] }.compact_blank
41
+ end
42
+
43
+ def pasted
44
+ reader = ThemeReader.new(@values[:pasted_theme])
45
+ reader.refusal ? reader : CheckedTheme.new(reader.values)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Preferences
5
+ class ThemeCss
6
+ VERSION = "2"
7
+ SHADOW_SETTINGS = %w[shadow-offset-x shadow-offset-y shadow-blur shadow-spread shadow-color shadow-opacity].freeze
8
+ SHADOWS = %w[--ks-shadow-surface --ks-shadow-overlay --ks-shadow-menu].freeze
9
+ HOVERS = { "primary" => "--ks-color-accent-hover", "secondary" => "--ks-color-neutral-hover", "destructive" => "--ks-color-danger-hover" }.freeze
10
+
11
+ OUTRANKING_LOOKS = ":root, html:root[data-look]"
12
+
13
+ def initialize(theme = {}, own_look: false, palette: nil)
14
+ @theme = theme
15
+ @own_look = own_look
16
+ @palette = palette
17
+ end
18
+
19
+ def to_s
20
+ rules.map { |selector, lines| "#{selector} {\n#{lines.join("\n")}\n}" }.join("\n")
21
+ end
22
+
23
+ private
24
+
25
+ def rules
26
+ return { ":root" => palette_lines } if @palette
27
+ return { OUTRANKING_LOOKS => colour_lines, ":root" => shape_lines } if @own_look
28
+
29
+ { OUTRANKING_LOOKS => colour_lines + shape_lines }
30
+ end
31
+
32
+ def palette_lines
33
+ accent, surface, background, text = @palette.values_at(:accent, :surface, :background, :text)
34
+ shade_lines("accent", shades(accent) { KeystoneUi::Looks::Palettes.accent(accent) }) +
35
+ shade_lines("surface", shades(surface) { KeystoneUi::Looks::Palettes.surface(surface) }) +
36
+ [ declaration("--color-custom-background", background), declaration("--color-custom-text", text) ]
37
+ end
38
+
39
+ def shades(colour)
40
+ return KeystoneUi::Looks::Palettes.generate_shades(colour) if colour.start_with?("#")
41
+
42
+ yield
43
+ end
44
+
45
+ def shade_lines(name, shades)
46
+ shades.map { |shade, hex| declaration("--color-#{name}-#{shade}", hex) }
47
+ end
48
+
49
+ def colour_lines
50
+ lines(@theme.select { |role, _sides| colour?(role) })
51
+ end
52
+
53
+ def shape_lines
54
+ lines(@theme.reject { |role, _sides| colour?(role) }) + shadow_declarations
55
+ end
56
+
57
+ def colour?(role)
58
+ ThemeRoles::ROLES.fetch(role)[:kind] == :colour
59
+ end
60
+
61
+ def lines(roles)
62
+ roles.flat_map do |role, sides|
63
+ ThemeRoles::ROLES.fetch(role)[:variables].flat_map { |variable| declarations(variable, sides) } + hover_declarations(role, sides)
64
+ end
65
+ end
66
+
67
+ def shadow_declarations
68
+ x, y, blur, spread, colour, opacity = SHADOW_SETTINGS.map { |setting| @theme.dig(setting, "light") }
69
+ return [] unless x && y && blur && spread && colour && opacity
70
+
71
+ SHADOWS.map { |variable| declaration(variable, "#{x} #{y} #{blur} #{spread} color-mix(in srgb, #{colour} calc(#{opacity} * 100%), transparent)") }
72
+ end
73
+
74
+ def hover_declarations(role, sides)
75
+ return [] unless HOVERS.key?(role)
76
+
77
+ declarations(HOVERS[role], sides.transform_values { |colour| "color-mix(in oklab, #{colour} 85%, white)" })
78
+ end
79
+
80
+ def declarations(variable, sides)
81
+ { variable => sides["light"], "#{variable}-dark" => sides["dark"] }.compact.map { |name, value| declaration(name, value) }
82
+ end
83
+
84
+ def declaration(name, value)
85
+ " #{name}: #{value};"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Preferences
5
+ class ThemeReader
6
+ BLOCK = %r{(?:\A|(?<=[\};])|(?<=\*/)|@layer\s+[\w-]+\s*\{)\s*(:root|\.dark)\s*\{([^\{\}]*)\}}
7
+ DECLARATION = /--([a-z0-9-]+)\s*:\s*([^;]*)/
8
+ LIMIT = 20_000
9
+ FETCHES = /url\(|@import/i
10
+ SIDES = { ":root" => "light", ".dark" => "dark" }.freeze
11
+
12
+ def initialize(text)
13
+ @text = text.to_s
14
+ end
15
+
16
+ def refusal
17
+ return Refusal.new("The pasted theme is longer than 20,000 characters, so it was not saved.") if @text.length > LIMIT
18
+ return Refusal.new("The pasted theme contains url( or @import, so it was not saved.") if @text.match?(FETCHES)
19
+
20
+ Refusal.new("The pasted theme has no shadcn colour role in a :root or .dark block, so it was not saved.") if values.keys.none? { |name| ThemeRoles::ROLES.fetch(name)[:kind] == :colour }
21
+ end
22
+
23
+ def values
24
+ @values ||= @text.scan(BLOCK).each_with_object({}) do |(selector, body), values|
25
+ body.scan(DECLARATION).each do |name, value|
26
+ next unless ThemeRoles::ROLES.key?(name)
27
+
28
+ (values[name] ||= {})[SIDES.fetch(selector)] = value.strip
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Preferences
5
+ module ThemeRoles
6
+ ROLES = {
7
+ "background" => { kind: :colour, variables: %w[--ks-color-page] },
8
+ "foreground" => { kind: :colour, variables: %w[--ks-color-on-page] },
9
+ "card" => { kind: :colour, variables: %w[--ks-color-surface] },
10
+ "card-foreground" => { kind: :colour, variables: %w[--ks-color-on-surface] },
11
+ "popover" => { kind: :colour, variables: %w[--ks-color-overlay] },
12
+ "popover-foreground" => { kind: :colour, variables: %w[--ks-color-on-overlay --ks-color-on-overlay-muted] },
13
+ "primary" => { kind: :colour, variables: %w[--ks-color-accent] },
14
+ "primary-foreground" => { kind: :colour, variables: %w[--ks-color-on-accent] },
15
+ "secondary" => { kind: :colour, variables: %w[--ks-color-neutral] },
16
+ "secondary-foreground" => { kind: :colour, variables: %w[--ks-color-on-neutral] },
17
+ "muted" => { kind: :colour, variables: %w[--ks-color-fill-muted] },
18
+ "muted-foreground" => { kind: :colour, variables: %w[--ks-color-text-muted] },
19
+ "accent" => { kind: :colour, variables: %w[--ks-color-hover] },
20
+ "accent-foreground" => { kind: :colour, variables: %w[--ks-color-on-hover --ks-color-on-hover-muted] },
21
+ "destructive" => { kind: :colour, variables: %w[--ks-color-danger] },
22
+ "destructive-foreground" => { kind: :colour, variables: %w[--ks-color-on-danger] },
23
+ "border" => { kind: :colour, variables: %w[--ks-color-border] },
24
+ "input" => { kind: :colour, variables: %w[--ks-color-border-control] },
25
+ "ring" => { kind: :colour, variables: %w[--ks-color-focus] },
26
+ "chart-1" => { kind: :colour, variables: %w[--ks-color-chart-1] },
27
+ "chart-2" => { kind: :colour, variables: %w[--ks-color-chart-2] },
28
+ "chart-3" => { kind: :colour, variables: %w[--ks-color-chart-3] },
29
+ "chart-4" => { kind: :colour, variables: %w[--ks-color-chart-4] },
30
+ "chart-5" => { kind: :colour, variables: %w[--ks-color-chart-5] },
31
+ "sidebar" => { kind: :colour, variables: %w[--ks-color-nav] },
32
+ "sidebar-foreground" => { kind: :colour, variables: %w[--ks-color-nav-link --ks-color-nav-text] },
33
+ "sidebar-primary" => { kind: :colour, variables: %w[--ks-color-nav-indicator --ks-color-nav-active] },
34
+ "sidebar-primary-foreground" => { kind: :colour, variables: [] },
35
+ "sidebar-accent" => { kind: :colour, variables: %w[--ks-color-nav-hover] },
36
+ "sidebar-accent-foreground" => { kind: :colour, variables: %w[--ks-color-nav-text-hover] },
37
+ "sidebar-border" => { kind: :colour, variables: %w[--ks-color-nav-border] },
38
+ "sidebar-ring" => { kind: :colour, variables: [] },
39
+ "radius" => { kind: :length, variables: %w[--ks-radius-control --ks-radius-surface] },
40
+ "spacing" => { kind: :length, variables: %w[--ks-spacing] },
41
+ "border-width" => { kind: :length, variables: %w[--ks-border-width] },
42
+ "letter-spacing" => { kind: :letter_spacing, variables: %w[--ks-letter-spacing] },
43
+ "font-sans" => { kind: :font_list, variables: %w[--ks-font-body] },
44
+ "font-serif" => { kind: :font_list, variables: [] },
45
+ "font-mono" => { kind: :font_list, variables: %w[--ks-font-mono --default-mono-font-family] },
46
+ "font-heading" => { kind: :font_list, variables: %w[--ks-font-heading] },
47
+ "font-weight-normal" => { kind: :weight, variables: %w[--ks-font-weight-normal] },
48
+ "font-weight-medium" => { kind: :weight, variables: %w[--ks-font-weight-medium] },
49
+ "font-weight-strong" => { kind: :weight, variables: %w[--ks-font-weight-strong] },
50
+ "font-weight-heading" => { kind: :weight, variables: %w[--ks-font-weight-heading] },
51
+ "shadow-color" => { kind: :shadow_colour, variables: [] },
52
+ "shadow-opacity" => { kind: :opacity, variables: [] },
53
+ "shadow-blur" => { kind: :length, variables: [] },
54
+ "shadow-spread" => { kind: :signed_length, variables: [] },
55
+ "shadow-offset-x" => { kind: :signed_length, variables: [] },
56
+ "shadow-offset-y" => { kind: :signed_length, variables: [] }
57
+ }.freeze
58
+
59
+ def self.names
60
+ ROLES.keys
61
+ end
62
+ end
63
+ end
64
+ end
@@ -14,7 +14,7 @@
14
14
  <%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
15
15
  <div style="display: flex; flex-direction: column; gap: 2rem;">
16
16
  <% if offers_colours %>
17
- <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn } %>
17
+ <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true, own_theme_hint: "The account's current choice is its own theme." } %>
18
18
  <% end %>
19
19
 
20
20
  <% if offers_look %>
@@ -16,7 +16,7 @@
16
16
  <%= form_with(url: submit_url, method: :patch, data: { turbo: false, controller: "keystone-ui--preferences--theme-settings" }) do %>
17
17
  <div style="display: flex; flex-direction: column; gap: 2rem;">
18
18
  <% if chooses_colours %>
19
- <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn } %>
19
+ <%= render partial: "keystone_ui/preferences/settings/colour_choices", locals: { saved: saved, drawn: drawn, pastes: true, presets: true } %>
20
20
  <% end %>
21
21
 
22
22
  <% if appearance.person_chooses_look? && KeystoneUi.configuration.looks.any? %>
@@ -3,6 +3,7 @@
3
3
  palette_hex = ->(colour, palette) { colour.to_s.start_with?("#") ? colour.to_s : palette.call(colour)[500] }
4
4
  templates = { "default" => { accent: configuration.default_accent, surface: configuration.default_surface, text: configuration.default_text, label: "Default" } }
5
5
  KeystoneUi::Looks::Templates.all.each { |name, template| templates[name.to_s] = template }
6
+ saved_preset = KeystoneUi::Looks::Themes.all.find { |name, _preset| name.to_s == saved["preset_name"] }&.last
6
7
  %>
7
8
 
8
9
  <%= ui_section(title: "Theme", spacing: :sm) do %>
@@ -30,10 +31,31 @@
30
31
  <span><%= template[:label] %></span>
31
32
  <% end %>
32
33
  <% end %>
34
+ <% if local_assigns[:presets] %>
35
+ <% KeystoneUi::Looks::Themes.all.each do |name, preset| %>
36
+ <%= ui_option_card(
37
+ name: "template_name",
38
+ value: name.to_s,
39
+ selected: saved["preset_name"] == name.to_s,
40
+ input_data: {
41
+ action: "keystone-ui--preferences--theme-settings#selectPreset",
42
+ theme: preset[:theme].to_json
43
+ },
44
+ label_data: {
45
+ "keystone-ui--preferences--theme-settings-target": "templateLabel",
46
+ template: name.to_s
47
+ }
48
+ ) do %>
49
+ <span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 9999px; background-color: <%= preset[:theme].dig("primary", "light") %>;"></span>
50
+ <span style="display: inline-block; width: 1rem; height: 1rem; border-radius: 0.25rem; background-color: <%= preset[:theme].dig("secondary", "light") %>;"></span>
51
+ <span><%= preset[:label] %></span>
52
+ <% end %>
53
+ <% end %>
54
+ <% end %>
33
55
  <%= ui_option_card(
34
56
  name: "template_name",
35
57
  value: "",
36
- selected: saved["template_name"].blank?,
58
+ selected: saved["template_name"].blank? && !saved.key?("theme") && !saved.key?("preset_name"),
37
59
  input_data: {
38
60
  "keystone-ui--preferences--theme-settings-target": "customRadio"
39
61
  },
@@ -45,6 +67,13 @@
45
67
  <span>Custom</span>
46
68
  <% end %>
47
69
  </div>
70
+ <% if saved.key?("theme") %>
71
+ <p class="ks-hint"><%= local_assigns.fetch(:own_theme_hint, "Your current choice is your own theme.") %></p>
72
+ <% end %>
73
+ <% if local_assigns[:pastes] %>
74
+ <%= ui_form_field(attribute: "pasted_theme", label: "Paste a theme", type: :textarea, hint: "A :root block and a .dark block in shadcn/ui's variable names, as tweakcn and shadcn's theme pages export them.") %>
75
+ <%= render partial: "keystone_ui/preferences/settings/theme_settings", locals: { theme: saved_preset ? saved_preset[:theme] : saved["theme"].to_h } %>
76
+ <% end %>
48
77
  <% end %>
49
78
 
50
79
  <%= ui_section(title: "Accent Color", spacing: :sm) do %>
@@ -0,0 +1,17 @@
1
+ <%= ui_disclosure do |disclosure| %>
2
+ <% disclosure.with_summary { "Change each theme setting" } %>
3
+ <div style="display: flex; flex-wrap: wrap; gap: 1rem;">
4
+ <% KeystoneUi::Preferences::ThemeRoles::ROLES.each do |name, role| %>
5
+ <% (role[:kind] == :colour ? %w[light dark] : %w[light]).each do |side| %>
6
+ <% field_name = "theme[#{name}][#{side}]" %>
7
+ <% value = theme.dig(name, side).to_s %>
8
+ <% label = role[:kind] == :colour ? "--#{name}, #{side}" : "--#{name}" %>
9
+ <% if %i[colour shadow_colour].include?(role[:kind]) %>
10
+ <%= ui_color_picker(name: field_name, value: value, label: label) %>
11
+ <% else %>
12
+ <%= ui_form_field(attribute: field_name, label: label, value: value) %>
13
+ <% end %>
14
+ <% end %>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
@@ -6,8 +6,9 @@ module KeystoneUi
6
6
  class Error < StandardError; end
7
7
 
8
8
  MESSAGE = "keystone_ui-preferences and keystone_ui-colors both save colours, modes and looks, so the app cannot load both. " \
9
- "Run bin/rails generate keystone_ui:preferences:update first, so the migration copying keystone_ui-colors' saved choices is in db/migrate, " \
10
- "then remove keystone_ui-colors from the Gemfile and run bin/rails db:migrate."
9
+ "Remove keystone_ui-colors from the Gemfile and change every KeystoneUi::Colors name the app uses first, " \
10
+ "then run bin/rails generate keystone_ui:preferences:update, so the migration copying keystone_ui-colors' saved choices is in db/migrate, " \
11
+ "and then run bin/rails db:migrate."
11
12
 
12
13
  def initialize(colors_loaded:)
13
14
  @colors_loaded = colors_loaded
@@ -3,7 +3,7 @@
3
3
  module KeystoneUi
4
4
  module Preferences
5
5
  class Configuration
6
- attr_accessor :current_owner_method, :authentication_method, :current_account_method, :max_value_bytes, :default_accent, :default_surface, :default_background, :default_text, :default_mode, :user_looks, :app_look_label, :account_colors, :account_looks
6
+ attr_accessor :current_owner_method, :authentication_method, :current_account_method, :max_value_bytes, :default_accent, :default_surface, :default_background, :default_text, :default_mode, :user_looks, :app_look_label, :account_colors, :account_looks, :theme_fonts
7
7
 
8
8
  def initialize
9
9
  @current_owner_method = :current_user
@@ -19,6 +19,7 @@ module KeystoneUi
19
19
  @app_look_label = "Classic"
20
20
  @account_colors = true
21
21
  @account_looks = true
22
+ @theme_fonts = []
22
23
  end
23
24
 
24
25
  def name_component(component_key, name)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module KeystoneUi
4
4
  module Preferences
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui-preferences
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -15,28 +15,42 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.41.0
18
+ version: 0.43.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.41.0
25
+ version: 0.43.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: keystone_ui-looks
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.3.0
32
+ version: 0.4.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.3.0
39
+ version: 0.4.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: keystone_ui-styles
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.18.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.18.0
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: railties
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -96,18 +110,22 @@ files:
96
110
  - app/javascript/keystone_ui/preferences/theme_settings_controller.js
97
111
  - app/models/keystone_ui/preferences/appearance_choice.rb
98
112
  - app/models/keystone_ui/preferences/application_record.rb
113
+ - app/models/keystone_ui/preferences/checked_theme.rb
99
114
  - app/models/keystone_ui/preferences/chosen_colours.rb
100
115
  - app/models/keystone_ui/preferences/component_preference.rb
101
116
  - app/models/keystone_ui/preferences/kept.rb
102
117
  - app/models/keystone_ui/preferences/layout_choice.rb
103
118
  - app/models/keystone_ui/preferences/navigation_arrangement.rb
104
- - app/models/keystone_ui/preferences/palette_css.rb
105
119
  - app/models/keystone_ui/preferences/pick_account_appearance.rb
106
120
  - app/models/keystone_ui/preferences/pick_account_layouts.rb
107
121
  - app/models/keystone_ui/preferences/pick_appearance.rb
108
122
  - app/models/keystone_ui/preferences/pick_navigation_order.rb
109
123
  - app/models/keystone_ui/preferences/pick_navigation_placement.rb
110
124
  - app/models/keystone_ui/preferences/refusal.rb
125
+ - app/models/keystone_ui/preferences/sent_theme.rb
126
+ - app/models/keystone_ui/preferences/theme_css.rb
127
+ - app/models/keystone_ui/preferences/theme_reader.rb
128
+ - app/models/keystone_ui/preferences/theme_roles.rb
111
129
  - app/views/keystone_ui/preferences/settings/_account_appearance.html.erb
112
130
  - app/views/keystone_ui/preferences/settings/_account_layouts.html.erb
113
131
  - app/views/keystone_ui/preferences/settings/_appearance.html.erb
@@ -115,6 +133,7 @@ files:
115
133
  - app/views/keystone_ui/preferences/settings/_look_choices.html.erb
116
134
  - app/views/keystone_ui/preferences/settings/_navigation_order.html.erb
117
135
  - app/views/keystone_ui/preferences/settings/_navigation_placement.html.erb
136
+ - app/views/keystone_ui/preferences/settings/_theme_settings.html.erb
118
137
  - config/routes.rb
119
138
  - lib/generators/keystone_ui/preferences/install/install_generator.rb
120
139
  - lib/generators/keystone_ui/preferences/install/templates/create_keystone_ui_preferences_component_preferences.rb.erb
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module KeystoneUi
4
- module Preferences
5
- class PaletteCss
6
- def initialize(accent:, surface:, background:, text:)
7
- @accent = accent
8
- @surface = surface
9
- @background = background
10
- @text = text
11
- end
12
-
13
- def to_s
14
- lines = shade_lines("accent", shades(@accent) { KeystoneUi::Looks::Palettes.accent(@accent) }) + shade_lines("surface", shades(@surface) { KeystoneUi::Looks::Palettes.surface(@surface) })
15
- lines << " --color-custom-background: #{@background};" << " --color-custom-text: #{@text};"
16
- ":root {\n#{lines.join("\n")}\n}"
17
- end
18
-
19
- private
20
-
21
- def shades(colour)
22
- return KeystoneUi::Looks::Palettes.generate_shades(colour) if colour.start_with?("#")
23
-
24
- yield
25
- end
26
-
27
- def shade_lines(name, shades)
28
- shades.map { |shade, hex| " --color-#{name}-#{shade}: #{hex};" }
29
- end
30
- end
31
- end
32
- end