Keep planning notes out of the repository
docs/plans is ignored from now on; the notes stay on the machine that wrote them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
c95d0c43c2
commit
cd97612b4d
@@ -27,3 +27,6 @@ frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
/tests/Browser/Screenshots
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
# 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 today** — `indigo` 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:
|
||||
|
||||
```css
|
||||
: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 shape** — `light` 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
|
||||
|
||||
9. **Package.** `composer update nonameweb/livewire-material` to 1.1.0.
|
||||
10. **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.
|
||||
11. **Resolver.** `AppServiceProvider::boot()`: `Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'))`.
|
||||
12. **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.
|
||||
13. **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`.
|
||||
@@ -1,223 +0,0 @@
|
||||
# SealShare on Livewire Material (2.0.0)
|
||||
|
||||
> The package itself — its decisions, the wave plan (Phases 1–10) and its tests — moved to
|
||||
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
|
||||
> This file keeps what SealShare does once the package reaches `1.0.0`.
|
||||
|
||||
## Goal
|
||||
|
||||
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
|
||||
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
|
||||
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
|
||||
and "link ready" — shipped as SealShare 2.0.0.
|
||||
|
||||
## Context
|
||||
|
||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
||||
|
||||
**SealShare's UI surface** (inventory, 2026-09-13):
|
||||
|
||||
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
|
||||
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
|
||||
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
|
||||
`toast` 1 (never triggered).
|
||||
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
|
||||
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
|
||||
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
|
||||
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
|
||||
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
|
||||
Secondary text is `opacity-50/60/70`.
|
||||
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
|
||||
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
|
||||
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
|
||||
- Layouts: `layouts/app` → `app/sidebar` (centered `max-w-5xl` + footer nav), used by the
|
||||
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth` →
|
||||
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
|
||||
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
|
||||
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
|
||||
`pages/auth/register` (still referenced by `Fortify::registerView`,
|
||||
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
|
||||
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
|
||||
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
|
||||
- Settings `profile` and `password` show "Saved." through `components/action-message`,
|
||||
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
|
||||
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
|
||||
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
|
||||
maryUI's table.
|
||||
- Tests assert text only, never markup; no browser tests.
|
||||
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
|
||||
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
|
||||
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
|
||||
`docker.yml` **and** `lint.yml`.
|
||||
- Screens: setup, system password, upload, share created, share download, admin dashboard,
|
||||
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
|
||||
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
|
||||
CSS imported from `vendor/` needs the order swapped.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
|
||||
anonymous reads verified 2026-09-13).
|
||||
|
||||
## Decisions
|
||||
|
||||
The package's decisions are in its own plan. SealShare's:
|
||||
|
||||
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
||||
`material`, released as **2.0.0**.
|
||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
||||
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
||||
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
|
||||
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
||||
Appearance is a Light / Dark / System connected button group.
|
||||
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
||||
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
|
||||
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
|
||||
auth pages a centered card under the same bar. No rail, no bottom bar.
|
||||
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
|
||||
that morphs while files are dragged over, the wavy progress indicator for uploads, a
|
||||
shape-backed check when the link is ready; admin stats count up once. Instant under
|
||||
`prefers-reduced-motion`.
|
||||
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
|
||||
work for recipients on iOS below 18.4.
|
||||
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
|
||||
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
|
||||
binding, drop `alpinejs` from npm and the Flux step from CI.
|
||||
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
|
||||
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
|
||||
in review if you prefer the native confirm.)*
|
||||
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
|
||||
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
|
||||
*(Follows from the snackbar; not asked separately.)*
|
||||
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
|
||||
privacy-minded self-hosted app.
|
||||
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
|
||||
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
|
||||
dependency, approved).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
|
||||
- Everything the package plan puts out of scope.
|
||||
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
Step numbers continue the original plan's, so references elsewhere stay valid.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
|
||||
|
||||
34. **Branch** `material` from `main`; open the PR so CI runs.
|
||||
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
|
||||
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
|
||||
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
|
||||
maryUI goes **first** because its class components would shadow the package's same-named
|
||||
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
|
||||
it merges once, green (Decisions: one pass).
|
||||
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
|
||||
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
|
||||
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
|
||||
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
|
||||
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
|
||||
without blade-icons.
|
||||
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
|
||||
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
|
||||
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
|
||||
`resources/js/app.js` imports the package JS. Run
|
||||
`php artisan material:scheme "#4f46e5" --variant=vibrant` (chosen over Tonal Spot after
|
||||
comparing both on the upload page in both themes).
|
||||
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
|
||||
before `@vite` (it currently sits outside `<head>`). Publish the config with
|
||||
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
|
||||
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
|
||||
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
|
||||
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
|
||||
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
|
||||
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
|
||||
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
|
||||
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
|
||||
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
|
||||
`desktop-user-menu`. Fortify `home` → `/admin/dashboard`. Update `AuthenticationTest:22`
|
||||
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
|
||||
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
|
||||
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
|
||||
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
|
||||
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
|
||||
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
|
||||
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
|
||||
`<x-alert>` for storage full, filled primary "Create Share Link".
|
||||
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
|
||||
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
|
||||
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
|
||||
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
|
||||
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
|
||||
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
|
||||
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
|
||||
43. **Settings.** `pages/settings/layout` → `<x-section-nav>`; `partials/settings-heading`
|
||||
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
|
||||
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
|
||||
any listener) and `components/action-message` is deleted;
|
||||
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
|
||||
page); `two-factor` → `<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
|
||||
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form` →
|
||||
`<x-modal>` with a `danger` action.
|
||||
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
|
||||
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
|
||||
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
|
||||
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
|
||||
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
|
||||
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
|
||||
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
|
||||
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
|
||||
existing `data-test` attribute on the element that now plays its role.
|
||||
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
|
||||
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
|
||||
password-reset and verify-email mails.
|
||||
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
|
||||
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
|
||||
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
|
||||
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
|
||||
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
|
||||
never hand-edited; the download page stays free of anchored components; the theme key.
|
||||
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
|
||||
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
|
||||
`docker.yml`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Feature tests updated where redirects or text change: `AuthenticationTest`,
|
||||
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
|
||||
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
|
||||
`AdminDashboardTest`, `ShareDownloadTest`.
|
||||
- `DesignLanguageTest` through the package guard.
|
||||
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
|
||||
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
|
||||
delete share, remove logo and clear system password go through their dialogs' confirm
|
||||
actions.
|
||||
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
|
||||
copy link; the password-protected download page at 393px; admin table sort and delete
|
||||
dialog; a first visit follows the OS theme and Appearance switches it.
|
||||
- Narrow runs per step; the full suite on the PR's CI.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
|
||||
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
|
||||
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
|
||||
meanwhile.
|
||||
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
|
||||
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
|
||||
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
|
||||
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
|
||||
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
|
||||
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
|
||||
the Vibrant comparison in step 37 is the check.
|
||||
@@ -1,257 +0,0 @@
|
||||
# Screenshots and the SealShare website
|
||||
|
||||
## Goal
|
||||
|
||||
Two things that feed each other. First, one command — `composer screenshots` — produces every
|
||||
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
|
||||
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
|
||||
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
|
||||
is — software a company installs to run **its own upload platform**, so it exchanges files with
|
||||
customers securely without relying on an outside service — shows the screenshots, compares
|
||||
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
|
||||
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
|
||||
wording is corrected.
|
||||
|
||||
## Context
|
||||
|
||||
**MailifySMS, the model** (`../MailifySMS`):
|
||||
|
||||
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
|
||||
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
|
||||
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
|
||||
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
|
||||
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
|
||||
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
|
||||
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
|
||||
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
|
||||
The page's JS is one inline `<script>` at the end.
|
||||
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
|
||||
it can be uploaded wholesale". There is no deploy automation.
|
||||
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
|
||||
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
|
||||
|
||||
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
|
||||
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
|
||||
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
|
||||
in the hosting panel.
|
||||
|
||||
**SealShare.**
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
|
||||
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
|
||||
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
|
||||
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
|
||||
gate).
|
||||
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
|
||||
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
|
||||
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
|
||||
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
|
||||
that directory when a browser run starts, and has no reduced-motion emulation.
|
||||
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
|
||||
through the file input.
|
||||
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
|
||||
development tool).
|
||||
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
|
||||
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
|
||||
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
|
||||
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
|
||||
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
|
||||
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
|
||||
never stored. The server sees the plaintext while uploading and downloading. The README calls
|
||||
this "End-to-End Encryption", which it is not.
|
||||
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
|
||||
customer given that password can upload and send the link back.
|
||||
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
|
||||
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
|
||||
2.0.0 is settled separately.
|
||||
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
|
||||
`*.md` but would copy a `website/` directory into the image.
|
||||
|
||||
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
|
||||
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
|
||||
advertises end-to-end encryption.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
|
||||
hosted by anyone but the company that installs it: its own upload platform for exchanging files
|
||||
with customers, data on its own server, no dependence on an external service.
|
||||
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
|
||||
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
|
||||
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
|
||||
the landing page. Cloud suites (Nextcloud-style) are left out.
|
||||
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
|
||||
- **Colours copied from `material-scheme.json`, not sampled** — `website/css/theme.css` lists the
|
||||
roles it uses with the scheme's hexes, light by default and dark under
|
||||
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
|
||||
regenerated scheme is copied again. The site follows the visitor's system theme and has no
|
||||
toggle of its own.
|
||||
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
|
||||
`website/fonts/`; nothing is loaded from Google.
|
||||
- **Uploaded by hand, like MailifySMS** — `website/` is a faithful copy of what is live. You create
|
||||
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
|
||||
No hosting credentials anywhere in the repository or CI.
|
||||
- **Plausible** — `data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
|
||||
must be added in the Plausible account.
|
||||
- **English only.**
|
||||
- **Screenshots: desktop and phone, each in light and dark (20 images)** —
|
||||
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
|
||||
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
|
||||
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
|
||||
password prompt), `03-download`, `04-qr-code`.
|
||||
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
|
||||
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
|
||||
suite and CI never run it. It reuses Playwright and the in-process server.
|
||||
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
|
||||
frozen with `travelTo()`, the site title and branding at their defaults, one admin
|
||||
("Alex Morgan"). Every run produces the same images unless the UI changed.
|
||||
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
|
||||
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
|
||||
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
|
||||
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
|
||||
- **Device frames in CSS** — `website/css/device-frame.css` draws a laptop and a phone around the
|
||||
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
|
||||
the visitor's theme. No generated hero image.
|
||||
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
|
||||
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
|
||||
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
|
||||
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
|
||||
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
|
||||
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
|
||||
the Gitea repository, and its install commands are the README's at release, whatever registry
|
||||
2.0.0 ships with.
|
||||
- **Comparison facts are researched, dated and sourced** — from each product's own site,
|
||||
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
|
||||
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
|
||||
open source, single-purpose, installable with Docker, with a release in the 12 months before the
|
||||
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
|
||||
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
|
||||
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
|
||||
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
|
||||
value that cannot be sourced is "—", never guessed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A terms page, a documentation section, a German version.
|
||||
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
|
||||
Plausible site — those are yours in the hosting panel and the Plausible account.
|
||||
- Comparing with Nextcloud, ownCloud or other cloud suites.
|
||||
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
|
||||
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
|
||||
- The image registry move and the install commands' final form (settled with 2.0.0).
|
||||
- Implementing end-to-end encryption.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
|
||||
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
|
||||
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
|
||||
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
|
||||
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
|
||||
directories created). It throws when the capture is missing, so a failed shot fails the run.
|
||||
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
|
||||
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
|
||||
download counts and expiries, one password-protected, one expired), created through factories
|
||||
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
|
||||
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
|
||||
`->in('Feature', 'Browser', 'Screenshots')`:
|
||||
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
|
||||
data; a `ready()` wait as in `SealShareTest`.
|
||||
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
|
||||
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
|
||||
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
|
||||
- The upload shot with files selected: create Livewire temporary uploads on the fake
|
||||
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
|
||||
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
|
||||
the drop zone with the options filled instead, and the plan's risk note is updated.
|
||||
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
|
||||
phone; admin pages as the admin.
|
||||
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
|
||||
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
|
||||
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
|
||||
as for the browser tests.
|
||||
6. **Website scaffold.** `website/`:
|
||||
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
|
||||
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
|
||||
sideways on a phone), FAQ (`<details>`), footer; light and dark through
|
||||
`prefers-color-scheme`.
|
||||
- `css/device-frame.css` — laptop and phone frames.
|
||||
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
|
||||
`img/icon.png` (favicon, from `public/`).
|
||||
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
|
||||
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
|
||||
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
|
||||
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
|
||||
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
|
||||
picks the dark captures;
|
||||
- "Why run your own": your server, your domain and branding, customers upload and download
|
||||
without accounts, encrypted at rest, no per-seat pricing;
|
||||
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
|
||||
limit and password;
|
||||
- features (from the README, accurate encryption wording);
|
||||
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
|
||||
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
|
||||
- compare: the two dated tables with sources (step 8);
|
||||
- install: the README's Docker quick start and a link to the full instructions on Gitea;
|
||||
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
|
||||
send files to us?" (yes, through the upload page, optionally behind the system password),
|
||||
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
|
||||
hosting), "Who runs it?" (you);
|
||||
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
|
||||
8. **Comparison research.** For each product, record every criterion with its source URL and the
|
||||
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
|
||||
commit message, not in the repository.
|
||||
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
|
||||
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
|
||||
third parties, fonts served locally, contact for questions; dated.
|
||||
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
|
||||
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
|
||||
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
|
||||
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
|
||||
get no changelog entry — they do not change the application.
|
||||
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
|
||||
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
|
||||
when the scheme is regenerated; the comparison is dated and every value sourced. And for
|
||||
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
|
||||
a release; the demo data is fixed so runs are reproducible.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
|
||||
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
|
||||
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
|
||||
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
|
||||
- every screenshot the gallery or README references exists for both widths and both themes;
|
||||
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
|
||||
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
|
||||
- the README has no "End-to-End Encryption" line, the site's features section does not say
|
||||
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
|
||||
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
|
||||
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
|
||||
breaks. It is run by hand before a release, not in CI.
|
||||
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
|
||||
uploading.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Selecting files in the upload shot** works: the files are stored with Livewire's own
|
||||
`FileUploadConfiguration::storeTemporaryFile()` and handed to `_finishUpload` by their signed
|
||||
names; Livewire's temporary-upload cleanup is turned off for the run, because under the frozen
|
||||
clock it deletes them.
|
||||
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
|
||||
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
|
||||
accurate and not misleading — values that cannot be sourced stay "—".
|
||||
- **Install commands depend on the registry move.** Until it is settled, the install section copies
|
||||
the current README; it is updated before the site goes live with 2.0.0.
|
||||
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
|
||||
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
|
||||
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
|
||||
capture.
|
||||
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
|
||||
(`urbanus.ch-meta.net`); confirm it before the page goes live.
|
||||
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
|
||||
step 7 if SealShare should have its own.
|
||||
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
|
||||
not, lower the quality or drop the larger phone width.
|
||||
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
|
||||
`theme.css` are the guard.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Share by QR code and share sheet
|
||||
|
||||
## Goal
|
||||
|
||||
After an upload, the share created page offers two more ways to hand a share over besides
|
||||
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
|
||||
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
|
||||
carry only the share's link — never a password.
|
||||
|
||||
## Context
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
|
||||
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
|
||||
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
|
||||
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
|
||||
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
|
||||
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
|
||||
a server-side PNG would.
|
||||
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
|
||||
`resources/views/livewire/share-created.blade.php`: the link as
|
||||
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
|
||||
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
|
||||
`share/{share:token}/created` sits behind `system.password`, like the upload page.
|
||||
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
|
||||
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
|
||||
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
|
||||
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
|
||||
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
|
||||
are hashed and turned into a key; the plain password is never stored.
|
||||
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
|
||||
on that page.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Only on the share created page** — that is where a share is handed over; the admin
|
||||
dashboard and the download page stay as they are.
|
||||
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
|
||||
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
|
||||
QR on a white panel.
|
||||
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
|
||||
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
|
||||
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
|
||||
Fortify, declared so SealShare does not depend on Fortify keeping it.
|
||||
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
|
||||
The QR holds the link only.
|
||||
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
|
||||
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
|
||||
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
|
||||
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
|
||||
there; one PR, one changelog entry.
|
||||
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
|
||||
to scan from a screen or a print; the site's theme does not tint it.
|
||||
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
|
||||
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
|
||||
intrinsic size).
|
||||
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
|
||||
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
|
||||
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
|
||||
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
|
||||
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
|
||||
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
|
||||
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
|
||||
by `app.js`, instead of long inline Alpine in the view.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- QR codes on the admin dashboard or the download page.
|
||||
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
|
||||
- An SVG download, a server-rendered PNG, or a print layout.
|
||||
- A logo in the middle of the QR, or colours from the theme.
|
||||
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
|
||||
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
|
||||
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
|
||||
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
|
||||
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
|
||||
drops the XML declaration as Fortify does.
|
||||
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
|
||||
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
|
||||
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
|
||||
`$shareUrl`.
|
||||
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
|
||||
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
|
||||
the translated `shareFailed` and `downloadFailed`:
|
||||
- `open: false` for the dialog;
|
||||
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
|
||||
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
|
||||
`materialToast(messages.shareFailed, { type: 'error' })`;
|
||||
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
|
||||
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
|
||||
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
|
||||
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
|
||||
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
|
||||
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
|
||||
`materialToast(messages.downloadFailed, { type: 'error' })`.
|
||||
`resources/js/app.js` imports it after the package.
|
||||
5. **View.** In `share-created.blade.php`, wrap the link and actions in
|
||||
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
|
||||
(a plain element, so `@js` compiles there):
|
||||
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
|
||||
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
|
||||
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
|
||||
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
|
||||
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
|
||||
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
|
||||
(the SVG is generated from the app's own URL — no user input), then, for a
|
||||
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
|
||||
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
|
||||
and `<x-button :label="__('Close')" x-on:click="close()" />`.
|
||||
"Upload More" and the stats stay where they are.
|
||||
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
|
||||
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
|
||||
with an entry for both.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
|
||||
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
|
||||
same markup for the same contents and different markup for different contents.
|
||||
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
|
||||
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
|
||||
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
|
||||
password note appears for a protected share and not for an open one.
|
||||
- `tests/Browser/SealShareTest.php`:
|
||||
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
|
||||
`image/png` blob named `share-<token>.png` (recorded by stubbing
|
||||
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
|
||||
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
|
||||
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
|
||||
on any other rejection.
|
||||
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
|
||||
`bg-white` is a token).
|
||||
- Narrow runs per step, then the full suite.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
|
||||
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
|
||||
review is the check.
|
||||
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
|
||||
trusted-proxy setting, both point at the wrong host — unchanged from today.
|
||||
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
|
||||
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
|
||||
locally and in CI, and the three-engine check is manual.
|
||||
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
|
||||
Firefox; the button's absence there is by design.
|
||||
Reference in New Issue
Block a user