diff --git a/CLAUDE.md b/CLAUDE.md index b3d344e..e75ca46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,9 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, ``) is lo in sublayers of `utilities`, so these overrides beat **every** daisyUI rule. Each override must exclude the daisyUI variants it shouldn't touch, e.g. `:not(:where(.btn-ghost, .btn-link, ...))`, and call sites drop a neo shadow with `shadow-none!`. + - Selectors are an inset well; a checked checkbox is filled and raised like a primary button (daisyUI fills one only + for a colour variant, and without one drew a tick on nothing), and a checked radio's dot is the primary colour. A + file input's own button (Choose File, Browse…) is raised like the app's buttons. - Never write `@utility collapse`. It brings back Tailwind's own `collapse` utility (`visibility: collapse`), and every collapse disappears. @@ -168,7 +171,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized - A window at most 30rem tall (a phone held sideways) is the `short:` variant. - **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets daisyUI's own size variables: - - buttons, tabs, inputs and selects are 44px tall; + - buttons, tabs, inputs, selects and file inputs are 44px tall; - extra small buttons and the badges that act are 36px, with a transparent `::after` that reaches 44 (5px around the padding box); - checkboxes and radios are 24px, toggles and range thumbs 28; diff --git a/Styles/neo.css b/Styles/neo.css index 10ccdcd..9e6e7b0 100644 --- a/Styles/neo.css +++ b/Styles/neo.css @@ -210,21 +210,54 @@ &:not(:where(.file-input-ghost, .file-input-neutral, .file-input-primary, .file-input-secondary, .file-input-accent, .file-input-info, .file-input-success, .file-input-warning, .file-input-error, .validator:user-invalid, [aria-invalid="true"])) { border-color: transparent; + + /* its button (Choose File, Browse…) is a raised button like every other, not daisyUI's flat neutral block; a + small gap keeps its shadow inside the well */ + &::file-selector-button { + margin-block: .25rem; + margin-inline-start: .25rem; + height: calc(100% - .5rem); + border: none; + border-radius: calc(var(--radius-box) - .25rem); + background-color: var(--color-base-100); + background-image: linear-gradient(145deg, color-mix(in oklab, var(--color-base-100), white var(--neo-lift)), color-mix(in oklab, var(--color-base-100), black var(--neo-sink))); + box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark); + color: var(--color-base-content); + font-weight: 600; + } } } -/* Selectors: an inset well while unchecked; once checked they keep daisyUI's filled look. */ +/* Selectors: an inset well while unchecked. A checked box is filled like a button of the primary colour, raised, its + tick in the colour's content (daisyUI fills one only for a colour variant: without, a tick on nothing); a checked + radio keeps the well, its dot the primary colour. A colour variant keeps its own colour. */ @utility checkbox { + &:not(:where(.checkbox-neutral, .checkbox-primary, .checkbox-secondary, .checkbox-accent, .checkbox-info, .checkbox-success, .checkbox-warning, .checkbox-error)) { + --input-color: var(--color-primary); + color: var(--color-primary-content); + } + &:not(:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"], :disabled)) { border-color: transparent; background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light)); background-origin: border-box; box-shadow: inset 2px 2px 4px var(--neo-dark), inset -2px -2px 4px var(--neo-light); } + + &:where(:checked, :indeterminate, [aria-checked="true"], [aria-checked="mixed"]):not(:disabled) { + border-color: transparent; + background-image: linear-gradient(145deg, color-mix(in oklab, var(--input-color), white var(--neo-lift)), color-mix(in oklab, var(--input-color), black var(--neo-sink))); + background-origin: border-box; + box-shadow: -2px -2px 4px var(--neo-light), 2px 2px 4px var(--neo-dark); + } } @utility radio { - &:not(:where(:checked, [aria-checked="true"], :disabled)) { + &:not(:where(.radio-neutral, .radio-primary, .radio-secondary, .radio-accent, .radio-info, .radio-success, .radio-warning, .radio-error)) { + --input-color: var(--color-primary); + } + + &:not(:where(:disabled)) { border-color: transparent; background-image: linear-gradient(145deg, var(--neo-grad-dark), var(--neo-grad-light)); background-origin: border-box; diff --git a/Styles/touch.css b/Styles/touch.css index 16bc5cf..4b170c3 100644 --- a/Styles/touch.css +++ b/Styles/touch.css @@ -89,7 +89,8 @@ } .input:not(.input-lg, .input-xl), - .select:not(.select-lg, .select-xl) { + .select:not(.select-lg, .select-xl), + .file-input:not(.file-input-lg, .file-input-xl) { --size: 2.75rem; } diff --git a/tests/e2e/Mobile/ControlsTests.cs b/tests/e2e/Mobile/ControlsTests.cs new file mode 100644 index 0000000..964f8a9 --- /dev/null +++ b/tests/e2e/Mobile/ControlsTests.cs @@ -0,0 +1,56 @@ +using decePubClient.E2E.Support; + +using Microsoft.Playwright; + +namespace decePubClient.E2E.Mobile +{ + // Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export + // opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a + // ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/. + [Trait("Category", "Mobile")] + public sealed class ControlsTests(Browser browser) + { + static readonly Phone Phone = Phone.Narrow360; + static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls"); + + [Fact] + public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest() + { + await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone); + var page = session.Page; + await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () => + { + await page.GotoAsync($"{Pasture.Site}/settings"); + var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First; + await header.TapAsync(); + var archive = page.Locator("[data-archive]"); + await archive.WaitForAsync(new() { Timeout = 30000 }); + + Fit.AssertTouchable(await Fit.Now(page, "[data-archive]")); + Fit.AssertTouchable(await Fit.Now(page, "[data-import]")); + + // a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise + var ticked = archive.Locator("input[type=checkbox]:checked").First; + var fill = await ticked.EvaluateAsync("e => getComputedStyle(e).backgroundColor"); + Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})"); + // a file input is as tall as the other fields on a touch screen + foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync()) + Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: " + + await input.EvaluateAsync("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }")); + // photos the size of the screen: one of an element taller than the screen makes Playwright resize the page, + // which drops the touch emulation for what comes after + Directory.CreateDirectory(OutDir); + foreach (var theme in new[] { "neo-dark", "neo-light" }) + { + await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme); + foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") }) + { + await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })"); + await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled }); + } + } + await session.AssertGuards(); + }, page); + } + } +}