Files
SealShare/docs/plans/colour-profiles.md
T
Andreas Reinhold / reiniandClaude Opus 5 21bea9646d
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 2m8s
docker / test (8.5) (push) Successful in 2m15s
docker / build-and-push (push) Failing after 7m22s
docker / release (push) Skipped
Let the admin choose one of eight colour profiles
Indigo (the default), Blue, Teal, Green, Amber, Rose and Violet in the
Vibrant style and Graphite in the Neutral style are generated from
config into the stylesheet. Admin settings opens with a colour profile
card: a swatch previews the profile on the page, and Save Settings
stores it as color_profile, which AppServiceProvider hands to the
package's resolver, so every page, mail and error page wears it. An
unknown profile is refused, and a saved one that disappears falls back
to indigo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 15:06:47 +02:00

14 KiB
Raw Blame History

Colour profiles

Goal

An installation of SealShare can wear one of eight colour profiles instead of the single indigo scheme. The admin picks the profile in Admin settings, previews it on the page while choosing, and on Save it applies to everyone: signed-in users, recipients on the upload and download pages, the Markdown mails and the error pages. Livewire Material learns colour profiles in general — any application lists its own in config, the package generates them, switches between them before the first paint and follows the active one everywhere it draws colour — and ships it as 1.1.0 before SealShare tags 2.0.0.

Context

Livewire Material 1.0.1 (../livewire-material):

  • php artisan material:scheme {seed} --variant= --contrast= --success= --warning= --info= --output= (src/Console/SchemeCommand.php) runs resources/node/scheme.mjs (Google's material-color-utilities, a 93 KB bundle) through Node and writes resources/css/material-scheme.css:root, [data-theme='light'] { color-scheme: light; --md-sys-color-*: … } and [data-theme='dark'] { … }, about 60 roles each — and material-scheme.json ({seed, variant, spec, contrast, light, dark}).
  • Every component and token reads only --md-sys-color-* (resources/css/tokens/theme.css maps them to Tailwind colours). The package's own default is resources/css/tokens/scheme.css and .json.
  • <x-theme-script> (in <head>, before @vite) writes data-theme, data-theme-choice, data-theme-key, data-rail, data-rail-key on <html> before the first paint, and puts them back after a wire:navigate swap (onSwap). $store.theme lives in resources/js/theme.js.
  • Support\Scheme::load() / light() read config('livewire-material.scheme') (the JSON) merged over the package default; the mail theme (resources/views/mail/theme.blade.php) and the fallback styles of the error pages (Support\ErrorPage::fallbackStyles()) use it.
  • Tests: SchemeCommandTest (runs Node), TokensTest, MailThemeTest, ErrorPagesTest, ShowcaseTest, browser tests in three engines; CI on Gitea.

SealShare:

  • One scheme, #4f46e5 Vibrant (.ai/rules/css.md records the exact command).
  • The production Docker image has no Node, so nothing can be generated at runtime.
  • Settings → Appearance is the Light/Dark/System picker, stored per browser. All users are admins; recipients are guests.
  • App\Livewire\Admin\AdminSettings holds the settings in Setting (key/value) and saves them in saveSettings() with one validation call; the form ends in "Save Settings". It uses Toasts.
  • Octane: the application boots once per worker, so anything request-specific must be read per call.

Decisions

  • The admin chooses, nobody else — one profile for the whole installation; no per-user or per-visitor choice. Light, dark and system stay each visitor's own, as now.

  • Ready-made profiles, no free colour — generated ahead of time with material:scheme and shipped in the CSS: correct from the first frame, no generator in the browser, mails and error pages can follow.

  • Eight profiles, Vibrant style like todayindigo Indigo #4f46e5 (the default, today's), blue Blue #0b57d0, teal Teal #00897b, green Green #2e7d32, amber Amber #e8710a, rose Rose #c2185b, violet Violet #6750a4, all vibrant; graphite Graphite #5f6368 in the neutral style.

  • The mechanism is the package's, the profiles are the application's — Livewire Material gets profiles in its config; SealShare lists its eight in its published config. Other applications define their own.

  • Swatch picker at the top of Admin settings, previewed live, applied on Save — a "Colour profile" card with one swatch per profile (primary, secondary and tertiary dots, the name, a check on the chosen one); a click recolours the page at once; "Save Settings" stores it for everyone. Leaving without saving shows the saved profile on the next page.

  • Profiles are keyed by <html data-scheme> — the default profile also stands without the attribute, so the stylesheet works before the head script runs and with an unknown name:

    :root, [data-theme='light'] { /* default, light */ }
    [data-theme='dark'] { /* default, dark */ }
    [data-scheme='teal'], [data-scheme='teal'][data-theme='light'] { /* teal, light */ }
    [data-scheme='teal'][data-theme='dark'] { /* teal, dark */ }
    

    A profile's two-attribute selectors outrank the default's single ones, and its one-attribute selector comes later in the file than :root, so the order is part of the format.

  • The active profile is resolved on every use, never kept — the application registers a resolver once (Scheme::resolveProfileUsing(fn (): ?string => …)); the head script, the mail theme and the error pages call it each time they draw. A name that is not a generated profile, or no resolver, falls back to the JSON's default — the profile config (else the first profile) when the scheme was generated. Nothing request-specific is stored on a static, so Octane workers stay clean.

  • The JSON keeps its old top-level shapelight and dark are still the default profile's roles, beside default and profiles.{name}.{label, seed, variant, spec, contrast, light, dark}, so a reader of the 1.0 format keeps working.

  • material:scheme with a seed is unchanged — one scheme, as in 1.0. Without a seed it generates every configured profile; without either it fails with a message naming both ways.

  • A <x-scheme-picker> component in the package — native radios in a radiogroup, bound with wire:model (or x-model), each labelled with the profile's name and its three colours from the JSON; choosing one sets <html data-scheme> immediately (the preview). Errors for the bound property show under it.

  • The showcase can preview every profile — a profile menu in its app bar when profiles are configured, recolouring the showcase without storing anything.

  • Release — Livewire Material 1.1.0 (a feature), then SealShare's lock, all before 2.0.0. SealShare's changelog lists it under 2.0.0 "Added".

Out of scope

  • A colour picker for any colour, extracting a colour from the logo, or per-profile contrast levels.
  • Per-user or per-visitor profiles, or a profile switch outside Admin settings.
  • Changing the website's colours (it stays indigo) or adding profile screenshots.
  • New success/warning/info sources per profile — they stay the package defaults.

Implementation steps

Livewire Material 1.1.0 (../livewire-material)

  1. Config. config/livewire-material.php: 'profiles' => [] (name ⇒ label, seed, variant, optional contrast) and 'profile' => null (the fallback name), documented in the config comment beside scheme.
  2. Generator. SchemeCommand: seed becomes optional. Without it, read profiles; for each run scheme.mjs as today (validating seed, variant and contrast through the generator's own errors), then write the stylesheet in the format under Decisions — the default profile (the profile config, else the first) as the plain blocks, then every profile's blocks in config order — and the JSON with default, profiles and the default's top-level light/dark. The header comment names the command and says the profiles come from config. With neither a seed nor profiles, fail naming both.
  3. Scheme. Support\Scheme: resolveProfileUsing(?Closure $resolver): void, profiles(?string $path = null): array (name ⇒ label and light/dark roles, from the JSON), profile(?string $path = null): ?string (the resolver's answer if it names a profile in the JSON, else default from the JSON, else null), and load(?string $path = null, ?string $profile = null) returning that profile's roles merged over the package default (the active profile when $profile is null; the top-level roles for a 1.0 file). light() follows, so the mail theme and ErrorPage::fallbackStyles() draw the active profile without further change: the fallback's plain :root/[data-theme] blocks carry that profile's roles, which is all a page without its build needs. Every method reads the JSON on each call, as load() does today.
  4. Head script. <x-theme-script>: when the JSON has profiles, write data-scheme="{active profile}" on <html> with the others, and keep it through onSwap. $store.theme gains scheme (read from the attribute) and previewScheme(name) (sets the attribute, stores nothing).
  5. Picker. resources/views/components/scheme-picker.blade.php as under Decisions: props label, hint, profiles (default Scheme::profiles()), name; labels through __(). Each swatch is a label around a visually hidden native radio, drawn with Tailwind utilities (a surface-container tile, outline when checked, a check icon); its three dots are the only inline styles — background-color from that profile's light roles, which Scheme has already checked are #rrggbb — because they show another profile's colours than the page's. x-on:change calls $store.theme.previewScheme($event.target.value). With no profiles it renders nothing.
  6. Showcase. A profile menu in resources/views/showcase/layout.blade.php's app bar when profiles exist, calling previewScheme; the colour section already reads the variables, so it follows. src/Showcase/Sections.php gains the picker as an example (and the search index with it).
  7. Docs. resources/boost/skills/livewire-material-development/SKILL.md (Colour scheme: profiles, resolver, picker; the new component in Components), resources/boost/guidelines/core.blade.php (one line), README.md (Colour scheme and Configuration).
  8. Release. Verify in .verify (Feature + Browser in chrome, firefox, safari), push, watch CI, tag 1.1.0.

SealShare

  1. Package. composer update nonameweb/livewire-material to 1.1.0.
  2. Profiles. config/livewire-material.php: the eight profiles under Decisions and 'profile' => 'indigo'. Run php artisan material:scheme to regenerate resources/css/material-scheme.css and .json; npm run build. Update .ai/rules/css.md: the scheme is regenerated with php artisan material:scheme from the profiles in config, never hand-edited.
  3. Resolver. AppServiceProvider::boot(): Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile')).
  4. Admin settings. AdminSettings: public string $colorProfile, mounted from Scheme::profile(); validated with Rule::in(array_keys(Scheme::profiles())) in saveSettings() — the profiles actually generated into the stylesheet, not merely listed in config; saved with Setting::set('color_profile', $this->colorProfile). admin-settings.blade.php: a "Colour profile" card first in the form with <x-scheme-picker wire:model="colorProfile" :label="__('Colour profile')" /> and a hint that the choice applies to every page, mail and error page after saving.
  5. Docs. README features: "Colour Profiles — eight colour profiles, chosen by the admin". CHANGELOG 2.0.0 "Added". composer screenshots again (the admin settings shot shows the new card); the website's feature list gains the same line.

Testing

Package

  • SchemeCommandTest: with profiles configured and no seed, the JSON has default and every profile with light and dark roles, top-level light/dark equal the default's; the stylesheet has the default's plain blocks first and each profile's [data-scheme='…'] blocks after, with its hexes; a seed still writes the 1.0 format; neither fails with the message.
  • SchemeTest (new, Feature): profile() follows a resolver naming a profile, falls back on an unknown name, on no resolver and on a 1.0 file; load() returns the chosen profile's roles.
  • MailThemeTest: the mail's primary is the resolved profile's. ErrorPagesTest: the fallback styles carry the resolved profile's roles.
  • Components: <x-theme-script> renders data-scheme for the resolved profile and none without profiles; <x-scheme-picker> renders a radio per profile, checked from wire:model, with the labels.
  • Browser (three engines): --md-sys-color-primary on <html> is the profile's in light and in dark, and the default's without the attribute; choosing a swatch changes it at once; the attribute survives wire:navigate; the showcase menu previews a profile.

SealShare

  • AdminSettingsTest: a valid profile is saved and a toast dispatched; an unknown one fails validation and saves nothing.
  • ColourProfileTest (new, Feature): a guest's upload page renders data-scheme from the saved setting and the default without one; the reset-password mail uses the profile's primary.
  • tests/Browser/SealShareTest.php: in Admin settings a swatch recolours the page before saving; after Save and a reload, and on a guest's download page, the profile stays.
  • DesignLanguageTest and WebsiteTest keep passing.

Risks and open questions

  • Stylesheet size. Eight profiles × two themes × ~60 roles is about 60 KB before compression (a few KB gzipped); acceptable, and the CSS stays cacheable.
  • A query per page for the setting. Setting::get('color_profile') runs when the head script renders, like the site title already does; cache it later if it ever shows.
  • Swatch colours are inline styles. The design guard does not look at style attributes, so nothing stops them spreading; they stay inside <x-scheme-picker> and come only from Scheme's checked hexes, which the component test asserts.
  • A profile removed from config while saved leaves the setting pointing nowhere; the resolver's fallback to the default covers it, and Admin settings shows the default as chosen.
  • Config and stylesheet out of step. A profile added to config but not generated is not offered: the picker, the resolver and the validation all read the generated JSON. .ai/rules/css.md says to regenerate after changing profiles.
  • Open tabs keep the profile they loaded (or previewed) until their next full load; wire:navigate carries the page's current attribute forward.
  • Error pages without a build use the fallback styles, which draw the active profile directly; covered by ErrorPagesTest.