diff --git a/CHANGELOG.md b/CHANGELOG.md index 2398ca2c..1c9a3332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Light theme for the Settings pages, with Settings Display Mode in Settings > + Appearance: System Default, Light or Dark. Stored per device. + - Keep Screen Awake in Settings > Appearance, off by default. Needs HTTPS. - Show From on a live activity badge, to badge only counts at or above a number. diff --git a/README.md b/README.md index a45efcb5..dcbe5306 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Then run `stackyard:local` the same way as above. For working on the code withou Everything is configured in the admin UI (`/admin`), split into a few sections: - **General**: title, description, and your server's **Host IP** (used to allow your own server in badge URLs and SSRF checks). Also **Monitoring** (logging level, Docker container health checks, and the socket URL used to reach Docker), optional **password protection**, and config **import / export**. Backup also imports a gethomepage or Dashy YAML config: links and folders are added to the dashboard, existing items are left alone, and anything that cannot be carried across is listed before you confirm. -- **Appearance**: wallpaper and the overall look. Also **Keep Screen Awake**, which stops the screen dimming while the dashboard is open. Browsers allow this over HTTPS only. +- **Appearance**: wallpaper and the overall look. **Settings Display Mode** picks the appearance of the Settings pages: System Default, Light or Dark. It is stored in the browser and applies to that device only. Also **Keep Screen Awake**, which stops the screen dimming while the dashboard is open. Browsers allow this over HTTPS only. - **Dashboard**: add and arrange your apps, folders, and widgets, and configure each one (including live activity badges). - **About**: version and links. diff --git a/docs/design-system.md b/docs/design-system.md index 2a87ce48..b0ef4019 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -4,7 +4,7 @@ The visual system both pages share. It lives in `ui/css/tokens.css`. Values are measured from a reference design kit, not chosen. -Dark values only. No stylesheet declares a `prefers-color-scheme` rule. +Two themes, dark and light. No stylesheet declares a `prefers-color-scheme` rule; see Themes below. ## The three colour layers @@ -16,6 +16,8 @@ Do not name a palette entry in a rule. The layers above point at it. **Roles.** What a colour is for: `--accent`, `--danger`, `--warning`, `--success`. Each points at a hue. +`--on-accent` is the ink on a filled control: an accent button, a selected row, a tile carrying a user-chosen colour. It is white in both themes. It is not a label level, because the surface under it is a colour rather than a background. + **Semantic.** What a colour is applied to. | Set | Tokens | Use | @@ -28,6 +30,22 @@ Do not name a palette entry in a rule. The layers above point at it. Two singletons sit beside them. `--bg-primary-light` is the opt-in light widget card. `--control-knob` is the white of a slider or switch knob, full white in both themes. +## Themes + +The dark theme is the default and is declared on `:root`. The light theme redeclares the palette, the roles and the semantic sets under `html[data-theme="light"]`. + +A page opts in by carrying the attribute. Only the Settings page does. The dashboard never sets it and has one appearance. + +`ui/js/admin-theme.js` writes the attribute before the first paint. It reads the mode from `localStorage` under `sy-theme`: `system`, `light` or `dark`. `system` is resolved in script, not in a media query, so there is one light block rather than two. `ui/js/theme.js` holds the same logic for the running page, and `ui/test/theme.test.mjs` holds the two together. + +The choice is per device. It is never written to the config. + +Light hues are drawn for a fill. Green at `#34C759` behind text is a badge, not a sentence. A role that ends up as text or as the edge of a control points at the `-hi` entry, which is the accessible variant of the same hue. `--accent` is one of them. + +`prefers-contrast: more` raises whichever theme is in force. A light hue needs its `-hi` partner for the same reason a dark one does. + +`ui/test/contrast.test.mjs` measures every required pair in four combinations: each theme, raised and not. + ## Two limits on the semantic layer **Label levels below primary do not clear WCAG on this project's cards.** Use `--sy-a11y-dim` for text that has to be read on a card. Use a label level for hierarchy. `ui/test/contrast.test.mjs` fails on a regression. @@ -172,8 +190,17 @@ It cannot name a token. Most widget colours sit in canvas fills, SVG attributes A surface particular to one page is declared in that page's own `:root`. `--pane` and `--cp` on the admin page, `--glass-bg` and `--dock-bg` on the dashboard. These are not part of the system. +A themed page declares its light values in the same file, under `html[data-theme="light"]`. + +The admin page also scopes two families there rather than writing values into rules. + +| Family | Tokens | Use | +| --- | --- | --- | +| Overlays | `--ov-soft`, `--ov`, `--ov-strong`, `--field-fill`, `--track-off` | A tint over whatever surface a control sits on. White over dark, black over light | +| Elevation | `--shadow-pop`, `--shadow-dlg`, `--shadow-login`, `--shadow-ghost`, `--shadow-bar`, `--shadow-knob`, `--shadow-tog`, `--shadow-sunken` | A shadow over a dark surface is depth; the same shadow over a light one is dirt | + ## Rules A colour written as a literal outside `tokens.css` fails `ui/test/css-tokens.test.mjs`. The same test checks that every `var()` names a token that exists. `ui/test/palette-values.test.mjs` pins each palette entry to its reference value. -`#fff` and `#000` are allowed as ink on an arbitrary coloured fill, where the colour underneath is user-chosen and no token can describe it. +`#fff` and `#000` are allowed as ink on an arbitrary coloured fill, where the colour underneath is user-chosen and no token can describe it. On the admin page `--on-accent` names that ink. diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index f6502ef3..4c26d695 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -76,6 +76,12 @@ "/js/admin-settings.js?v=*": [ "./ui/js/admin-settings.js" ], + "/js/admin-theme.js": [ + "./ui/js/admin-theme.js" + ], + "/js/admin-theme.js?v=*": [ + "./ui/js/admin-theme.js" + ], "/js/admin-shared.js": [ "./ui/js/admin-shared.js" ], @@ -208,6 +214,12 @@ "/js/spotlight.js?v=*": [ "./ui/js/spotlight.js" ], + "/js/theme.js": [ + "./ui/js/theme.js" + ], + "/js/theme.js?v=*": [ + "./ui/js/theme.js" + ], "/js/ui.js": [ "./ui/js/ui.js" ], diff --git a/ui/admin/index.html b/ui/admin/index.html index 5bec2927..5814434b 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -4,12 +4,14 @@ + not change colour when moving between them. admin-theme.js rewrites it + when this page renders in the light theme. --> Stackyard Settings - - + + + @@ -127,8 +129,21 @@
Display
+
+ Settings Display Mode +
+ + +
+ +
Keep Screen Awake
+

Sets the appearance of the Settings pages. It is stored in this browser, applies to this device only, and takes effect at once.

Stops the screen dimming or locking while the dashboard is open. Browsers only allow this over HTTPS, so it has no effect on a plain HTTP address.

Wallpaper
@@ -288,6 +303,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index b894ff16..7a991fa1 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -40,6 +40,91 @@ --tab-pill:rgba(255,255,255,.07); /* the selection behind the active tab */ --segment-on:rgba(255,255,255,.27); /* the selected segment of a segmented control */ + /* Overlays. A tint laid over whatever surface a control sits on, rather than + a colour of its own, so one rule covers the pane and the card. White over + the dark theme, black over the light one. Three weights, in the order a + control uses them: a row's hover, a chip or a ghost button, a pressed or + open state. */ + --ov-soft:rgba(255,255,255,.04); + --ov:rgba(255,255,255,.06); + --ov-strong:rgba(255,255,255,.08); + --field-fill:rgba(255,255,255,.07); /* a text input that draws its own box */ + --track-off:rgba(255,255,255,.15); /* an unfilled switch or meter track */ + --tip-strong:rgba(242,242,247,.65); /* emphasis inside a footnote */ + --swatch-ring:#FFFFFF; /* the ring around the chosen colour */ + --row-selected:rgba(2,122,255,.15); /* the current folder, a picked backup */ + --row-drop:rgba(0,168,107,.12); /* a drag hovering over a folder */ + --scrim:rgba(0,0,0,.55); + --scrim-strong:rgba(0,0,0,.70); + --mtabbar-bg:rgba(30,30,38,.72); + --mtabbar-bd:rgba(255,255,255,.08); + + /* Elevation. A shadow over a dark surface is depth; the same shadow over a + light one is dirt, so the light theme redraws every one of these. */ + --shadow-pop:0 8px 24px rgba(0,0,0,.5); + --shadow-dlg:0 16px 48px rgba(0,0,0,.6); + --shadow-login:0 24px 64px rgba(0,0,0,.6); + --shadow-ghost:0 10px 30px rgba(0,0,0,.55); + --shadow-bar:0 8px 30px rgba(0,0,0,.4); + --shadow-knob:0 1px 4px rgba(0,0,0,.5); + --shadow-tog:0 1px 3px rgba(0,0,0,.3); + --shadow-sunken:inset 1px 1px 0 rgba(255,255,255,.06),inset -1px -1px 0 rgba(0,0,0,.2); +} + +/* ── Light theme ───────────────────────────────────────────────────────────── + This page's own surfaces, borders and overlays at their light values. The + palette and the semantic sets come from tokens.css, which re-declares them + under the same attribute. + + The surfaces do not mirror the dark set through the palette. Dark stacks + gray6, gray5, gray4 and gets lighter as it rises; light puts a white card on + a grey pane, which is the inverse order and not a value swap. + + --bg-outer #FFFFFF the menu, dialog, toast and login surfaces + --sb/--pane gray6 the panel the cards sit on + --cp #FFFFFF a grouped card + + --dm and --bd are both gray-hi, the accessible grey. The palette grey clears + 3.0 on white but reaches only 2.92 on the grey pane, so the border takes the + same step as the text. ui/test/contrast.test.mjs holds both. */ +html[data-theme="light"] { + --bg-outer:#FFFFFF; + --sb:var(--sy-gray6); + --pane:var(--sy-gray6); + --cp:#FFFFFF; + --bd:var(--sy-gray-hi); + --bd-inner:var(--sy-gray4); + --tx:#1C1C1E; + --dm:var(--sy-gray-hi); + + --tile-placeholder:var(--sy-gray2); + --surface-sunken:var(--sy-gray5); + --login-badge:#D3E4FF; + --tab-pill:rgba(0,0,0,.06); + --segment-on:#FFFFFF; + + --ov-soft:rgba(0,0,0,.03); + --ov:rgba(0,0,0,.05); + --ov-strong:rgba(0,0,0,.08); + --field-fill:rgba(120,120,128,.12); + --track-off:rgba(120,120,128,.32); + --tip-strong:rgba(60,60,67,.75); + --swatch-ring:#000000; + --row-selected:rgba(0,122,255,.12); + --row-drop:rgba(0,168,107,.14); + --scrim:rgba(0,0,0,.35); + --scrim-strong:rgba(0,0,0,.45); + --mtabbar-bg:rgba(255,255,255,.82); + --mtabbar-bd:rgba(0,0,0,.08); + + --shadow-pop:0 8px 24px rgba(0,0,0,.14); + --shadow-dlg:0 16px 48px rgba(0,0,0,.18); + --shadow-login:0 24px 64px rgba(0,0,0,.20); + --shadow-ghost:0 10px 30px rgba(0,0,0,.18); + --shadow-bar:0 8px 30px rgba(0,0,0,.14); + --shadow-knob:0 1px 4px rgba(0,0,0,.28); + --shadow-tog:0 1px 3px rgba(0,0,0,.20); + --shadow-sunken:inset 1px 1px 0 rgba(0,0,0,.06),inset -1px -1px 0 rgba(255,255,255,.7); } html,body{min-height:100vh;color:var(--tx);font-family:var(--font-ui);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;text-size-adjust:100%} html{background:var(--bg-color,var(--bg-outer))} @@ -58,7 +143,7 @@ html{background:var(--bg-color,var(--bg-outer))} .sb-nav{list-style:none;display:flex;flex-direction:column;gap:2px} .nl{display:flex;align-items:center;gap:9px;width:100%;padding:9px 12px;border-radius:22px;border:none;background:transparent;color:var(--dm);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);font-weight:500;cursor:pointer;text-align:start;transition:background var(--t),color var(--t);min-height:44px;font-family:inherit} .nl:hover{color:var(--tx)} -.nl.active{background:var(--ac);color:var(--label-primary)} +.nl.active{background:var(--ac);color:var(--on-accent)} .nl.active .ni{opacity:1} .ni{flex-shrink:0;opacity:.6} @@ -83,7 +168,7 @@ html{background:var(--bg-color,var(--bg-outer))} /* Why a widget was refused, one validator message per line. Indented as a list rather than run together, since a manifest can fail several rules at once. */ .cfg-reject-list{list-style:disc;padding-inline-start:20px;margin-block-start:-8px} .cfg-reject-list li{margin-block-end:4px} -.grp-tip strong{color:rgba(242,242,247,.65)} +.grp-tip strong{color:var(--tip-strong)} .grp-tip code{font-family:monospace;font-size:var(--fs-caption-2);line-height:var(--lh-caption-2);letter-spacing:var(--tr-caption-2)} /* ── Rows ── */ @@ -103,7 +188,7 @@ html{background:var(--bg-color,var(--bg-outer))} .grp > .row:first-child{border-start-start-radius:var(--sy-radius-group);border-start-end-radius:var(--sy-radius-group)} .grp > .row:not(:has(~ .row:not(.d-none))){border-end-start-radius:var(--sy-radius-group);border-end-end-radius:var(--sy-radius-group)} .row-link{text-decoration:none;color:inherit;transition:background var(--t)} -.row-link:hover{background:rgba(255,255,255,.04)} +.row-link:hover{background:var(--ov-soft)} /* The label holds its line and the value gives way, which is the order a settings row reads in. Without this the label wrapped and the value stayed whole, so a long label made the row two lines tall. */ @@ -114,10 +199,10 @@ html{background:var(--bg-color,var(--bg-outer))} .ie-row .rv{cursor:pointer} .ie-row .row-inp{display:none;flex:2;background:transparent;border:none;color:var(--tx);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);font-family:inherit;text-align:end;padding:0;min-width:0;outline:none} .ie-row.editing .row-inp{display:block} -.ie-row.editing{background:rgba(255,255,255,.03)} +.ie-row.editing{background:var(--ov-soft)} .pe{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;color:var(--dm);cursor:pointer;flex-shrink:0;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);transition:color var(--t),background var(--t);position:relative} .pe::after{content:'';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px} -.pe:hover{color:var(--tx);background:rgba(255,255,255,.08)} +.pe:hover{color:var(--tx);background:var(--ov-strong)} .row-btns{display:flex;gap:6px;flex-shrink:0} .signout-row{margin-top:12px;display:flex;justify-content:flex-start} @@ -126,12 +211,12 @@ html{background:var(--bg-color,var(--bg-outer))} .row-dd-btn{display:flex;align-items:center;gap:6px;background:transparent;border:none;color:var(--dm);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);font-family:inherit;cursor:pointer;padding:4px 8px;border-radius:6px;transition:color var(--t)} .row-dd-btn:hover{color:var(--tx)} .dd-chev{flex-shrink:0;width:22px;height:22px;padding:3px;border-radius:6px;background:rgba(120,120,128,.22);color:var(--tx)} -.row-dd-list{position:absolute;top:calc(100% + 4px);inset-inline-end:0;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--rs);list-style:none;min-width:140px;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,.5);overflow:hidden;padding:4px 0} +.row-dd-list{position:absolute;top:calc(100% + 4px);inset-inline-end:0;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--rs);list-style:none;min-width:140px;z-index:200;box-shadow:var(--shadow-pop);overflow:hidden;padding:4px 0} .row-dd-list[hidden]{display:none} .row-dd-list.checklist{max-height:240px;overflow-y:auto;min-width:200px} .row-dd-empty{padding:10px 16px;font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);color:var(--dm);cursor:default} .row-dd-list li{padding:10px 16px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--tx);cursor:pointer;transition:background var(--t);display:flex;align-items:center;gap:10px} -.row-dd-list li:hover,.row-dd-list li.kb-active{background:rgba(255,255,255,.08)} +.row-dd-list li:hover,.row-dd-list li.kb-active{background:var(--ov-strong)} .row-dd-list li:focus-visible{outline:2px solid var(--ac);outline-offset:-2px} .row-dd-list li[aria-selected="true"]::before{content:'✓';color:var(--ac);font-weight:700;width:14px;flex-shrink:0;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)} .row-dd-list li[aria-selected="false"]::before{content:'';width:14px;flex-shrink:0} @@ -148,27 +233,27 @@ html{background:var(--bg-color,var(--bg-outer))} stays the knob colour, so a fill matching the track under it is still visible. Unset, the knob is solid as before. */ .adm-range::-webkit-slider-thumb, -.hsb-range::-webkit-slider-thumb{-webkit-appearance:none;box-sizing:border-box;width:20px;height:20px;border-radius:50%;background:var(--knob-fill,var(--control-knob));border:2px solid var(--control-knob);box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer} +.hsb-range::-webkit-slider-thumb{-webkit-appearance:none;box-sizing:border-box;width:20px;height:20px;border-radius:50%;background:var(--knob-fill,var(--control-knob));border:2px solid var(--control-knob);box-shadow:var(--shadow-knob);cursor:pointer} .adm-range::-moz-range-thumb, -.hsb-range::-moz-range-thumb{box-sizing:border-box;width:20px;height:20px;border-radius:50%;background:var(--knob-fill,var(--control-knob));border:2px solid var(--control-knob);box-shadow:0 1px 4px rgba(0,0,0,.5);cursor:pointer} +.hsb-range::-moz-range-thumb{box-sizing:border-box;width:20px;height:20px;border-radius:50%;background:var(--knob-fill,var(--control-knob));border:2px solid var(--control-knob);box-shadow:var(--shadow-knob);cursor:pointer} /* ── Dashboard list rows — no delete button ── */ .al-filter{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap} -.al-search{background:rgba(255,255,255,.06);border:1px solid var(--bd-inner);border-radius:20px;color:var(--tx);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);padding:7px 14px;width:180px;font-family:inherit} +.al-search{background:var(--ov);border:1px solid var(--bd-inner);border-radius:20px;color:var(--tx);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);padding:7px 14px;width:180px;font-family:inherit} .al-search:focus{outline:none;border-color:var(--ac)} .al-chips{display:flex;gap:5px} -.chip{padding:6px 13px;border-radius:20px;border:none;background:rgba(255,255,255,.07);color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;cursor:pointer;transition:background var(--t),color var(--t);font-family:inherit} -.chip.on{background:var(--ac);color:var(--label-primary)} +.chip{padding:6px 13px;border-radius:20px;border:none;background:var(--field-fill);color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;cursor:pointer;transition:background var(--t),color var(--t);font-family:inherit} +.chip.on{background:var(--ac);color:var(--on-accent)} .chip:hover:not(.on){color:var(--tx)} .row.drow{display:grid;grid-template-columns:20px 48px 1fr auto auto;gap:12px;padding:10px 16px;min-height:68px;cursor:grab;align-items:center} -.row.drow:hover{background:rgba(255,255,255,.025)} +.row.drow:hover{background:var(--ov-soft)} .row.drow.dragging{opacity:.4;cursor:grabbing} .row.drow.drag-above{box-shadow:0 -2px 0 0 var(--ac)} .row.drow.drag-below{box-shadow:0 2px 0 0 var(--ac)} -.row.drow.drag-into{background:rgba(0,168,107,.12);box-shadow:inset 0 0 0 2px var(--gn)} +.row.drow.drag-into{background:var(--row-drop);box-shadow:inset 0 0 0 2px var(--gn)} /* touch-action:none, or a drag starting on the handle scrolls the list instead. */ .rord{display:flex;align-items:center;justify-content:center;color:var(--dm);font-size:var(--fs-callout);line-height:var(--lh-callout);letter-spacing:var(--tr-callout);opacity:.5;touch-action:none} -.rico{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--label-primary);flex-shrink:0;overflow:hidden} +.rico{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--on-accent);flex-shrink:0;overflow:hidden} .rico img{width:30px;height:30px;object-fit:contain} .rinf{min-width:0;display:flex;flex-direction:column;justify-content:center;gap:3px} /* dir="auto" on the name makes it align by its own direction, away from the @@ -190,14 +275,14 @@ html{background:var(--bg-color,var(--bg-outer))} .ract{display:flex;gap:4px;align-items:center} .empty{padding:48px 24px;text-align:center;color:var(--dm)} .fp-add{width:100%;background:none;border:none;border-radius:var(--rs);color:var(--ac2);padding:8px 14px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-weight:500;cursor:pointer;text-align:start;font-family:inherit} -.fp-add:hover{background:rgba(255,255,255,.04)} +.fp-add:hover{background:var(--ov-soft)} /* ── Tile-group row (Add New selector, Widget Size selector) ── */ .tile-row{align-items:flex-start;padding-top:16px;padding-bottom:16px} .tile-row .rl{padding-top:6px} .tile-grp{display:flex;gap:18px;margin-inline-start:auto} .tile-opt{display:flex;flex-direction:column;align-items:center;gap:7px;background:none;border:none;padding:0;cursor:pointer;font-family:inherit} -.tile-ico{width:54px;height:54px;border-radius:12px;background:var(--tile-placeholder);display:flex;align-items:center;justify-content:center;color:var(--label-primary);outline:2px solid transparent;outline-offset:2px;transition:outline-color var(--t)} +.tile-ico{width:54px;height:54px;border-radius:12px;background:var(--tile-placeholder);display:flex;align-items:center;justify-content:center;color:var(--on-accent);outline:2px solid transparent;outline-offset:2px;transition:outline-color var(--t)} .tile-opt.on .tile-ico{outline-color:var(--ac)} .tile-cap{font-size:var(--fs-caption-1);line-height:var(--lh-caption-1);letter-spacing:var(--tr-caption-1);font-weight:500;color:var(--dm);transition:color var(--t)} .tile-opt.on .tile-cap{color:var(--tx)} @@ -209,7 +294,7 @@ html{background:var(--bg-color,var(--bg-outer))} far edges. */ .cc-sw{display:grid;grid-template-columns:repeat(auto-fit,26px);gap:10px;margin-inline-start:auto;flex:2;max-width:none;justify-content:space-between} .cc-swatch{width:26px;height:26px;border-radius:50%;cursor:pointer;border:none;padding:0;outline:2px solid transparent;outline-offset:2px;transition:outline-color var(--t);flex-shrink:0} -.cc-swatch.on{outline-color:#fff} +.cc-swatch.on{outline-color:var(--swatch-ring)} .cc-rainbow{background:conic-gradient(from 90deg,#ff3b30,#ffcd00,#35c759,#0289ff,#cb30df,#ff3b30)} .hsb-row{min-height:46px} .hsb-track{display:flex;align-items:center;gap:10px;margin-inline-start:auto;flex:2;max-width:none} @@ -250,7 +335,7 @@ html{background:var(--bg-color,var(--bg-outer))} .kv-cred{display:flex;align-items:center;gap:6px;cursor:pointer;flex-shrink:0;user-select:none} .kv-cred input{position:absolute;opacity:0;width:0;height:0} .kv-box{width:18px;height:18px;border-radius:5px;border:1.5px solid var(--bd);flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:background var(--t),border-color var(--t)} -.kv-box::after{content:'✓';color:var(--label-primary);font-size:12px;font-weight:700;opacity:0;transform:scale(.6);transition:opacity var(--t),transform var(--t)} +.kv-box::after{content:'✓';color:var(--on-accent);font-size:12px;font-weight:700;opacity:0;transform:scale(.6);transition:opacity var(--t),transform var(--t)} .kv-cred input:checked+.kv-box{background:var(--ac);border-color:var(--ac)} .kv-cred input:checked+.kv-box::after{opacity:1;transform:scale(1)} .kv-cred input:focus-visible+.kv-box{outline:2px solid var(--ac);outline-offset:2px} @@ -262,7 +347,7 @@ html{background:var(--bg-color,var(--bg-outer))} .cc-sem{position:relative} /* App form: icon source row, poll, value list */ .icon-src-row{gap:12px} -.icon-prev{width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--label-primary);font-size:13px;font-weight:600;flex-shrink:0;overflow:hidden} +.icon-prev{width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--on-accent);font-size:13px;font-weight:600;flex-shrink:0;overflow:hidden} /* Inset to match the list icon, which draws a 30px image in a 48px tile. */ .icon-prev img{width:62%;height:62%;object-fit:contain} .icon-srch{flex:1;min-width:0;background:none;border:none;color:var(--tx);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);text-align:end;outline:none;font-family:inherit} @@ -293,8 +378,8 @@ html{background:var(--bg-color,var(--bg-outer))} /* About */ .about-hero{display:flex;flex-direction:column;align-items:center;padding:16px 0 24px} -.about-ic-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;width:76px;height:76px;border-radius:19px;background:var(--surface-sunken);margin-bottom:12px;box-shadow:inset 1px 1px 0 rgba(255,255,255,.06),inset -1px -1px 0 rgba(0,0,0,.2)} -.about-dot{position:absolute;top:-3px;right:-5px;width:20px;height:20px;background:var(--gn);border-radius:50%;border:2px solid var(--cp);display:flex;align-items:center;justify-content:center;color:var(--label-primary)} +.about-ic-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;width:76px;height:76px;border-radius:19px;background:var(--surface-sunken);margin-bottom:12px;box-shadow:var(--shadow-sunken)} +.about-dot{position:absolute;top:-3px;right:-5px;width:20px;height:20px;background:var(--gn);border-radius:50%;border:2px solid var(--cp);display:flex;align-items:center;justify-content:center;color:var(--on-accent)} .about-name{font-size:var(--fs-callout);line-height:var(--lh-callout);letter-spacing:var(--tr-callout);font-weight:700;} .about-ver{font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);color:var(--dm);margin-top:3px} .about-ri{flex-shrink:0;color:var(--dm)} @@ -303,9 +388,9 @@ html{background:var(--bg-color,var(--bg-outer))} /* ── Toggle ── */ .tog{display:inline-flex;align-items:center;flex-shrink:0;cursor:pointer} .tog input{position:absolute;opacity:0;width:0;height:0} -.tr{width:64px;height:28px;border-radius:14px;background:rgba(255,255,255,.15);position:relative;transition:background .2s} +.tr{width:64px;height:28px;border-radius:14px;background:var(--track-off);position:relative;transition:background .2s} .tog input:checked+.tr{background:var(--gn)} -.tr::after{content:'';position:absolute;width:38px;height:24px;border-radius:12px;background:var(--control-knob);top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)} +.tr::after{content:'';position:absolute;width:38px;height:24px;border-radius:12px;background:var(--control-knob);top:2px;left:2px;transition:transform .2s;box-shadow:var(--shadow-tog)} .tog input:checked+.tr::after{transform:translateX(22px)} .tog input:focus-visible+.tr{outline:2px solid var(--ac);outline-offset:3px} /* Still focusable and announced, so it needs a real focus ring rather than @@ -317,13 +402,13 @@ html{background:var(--bg-color,var(--bg-outer))} .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:9px 18px;border-radius:var(--rs);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:600;min-height:36px;cursor:pointer;border:none;text-decoration:none;transition:background var(--t),opacity var(--t);white-space:nowrap;font-family:inherit} .btn:active:not(:disabled){opacity:.8} .btn:disabled{opacity:.45;cursor:not-allowed} -.bp{background:var(--ac);color:var(--label-primary)} +.bp{background:var(--ac);color:var(--on-accent)} .bp:hover:not(:disabled){background:var(--ac2)} .bg{background:transparent;color:var(--dm);border:1px solid var(--bd-inner)} -.bg:hover:not(:disabled){background:rgba(255,255,255,.06);color:var(--tx)} +.bg:hover:not(:disabled){background:var(--ov);color:var(--tx)} .bd-btn{background:transparent;color:var(--rd);border:1px solid var(--rd)} -.bd-btn:hover:not(:disabled){background:var(--rd);color:var(--label-primary)} -.save-btn-green{background:var(--gn);color:var(--label-primary)} +.bd-btn:hover:not(:disabled){background:var(--rd);color:var(--on-accent)} +.save-btn-green{background:var(--gn);color:var(--on-accent)} .save-btn-green:hover:not(:disabled){background:var(--sy-green-hi)} .sm{padding:6px 14px;font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);min-height:30px} .ic{padding:6px;min-height:0} @@ -333,11 +418,11 @@ html{background:var(--bg-color,var(--bg-outer))} /* ── Password bars ── */ .pw-bars{display:flex;gap:4px;margin-top:6px} -.pwbar{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.1);transition:background .2s} +.pwbar{flex:1;height:3px;border-radius:2px;background:var(--ov-strong);transition:background .2s} .pw-hint{font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);color:var(--dm);margin-top:4px;min-height:16px} /* ── Toast ── */ -#toast{position:fixed;bottom:24px;inset-inline-end:24px;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:12px 16px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--tx);box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;pointer-events:none;z-index:9999;max-width:320px} +#toast{position:fixed;bottom:24px;inset-inline-end:24px;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:12px 16px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--tx);box-shadow:var(--shadow-pop);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;pointer-events:none;z-index:9999;max-width:320px} #toast.show{opacity:1;transform:translateY(0)} /* Tinted fill, not the raw accent. Near-white text on solid --gn/--rd measures 2.02 and 3.43, under the 4.5 WCAG AA needs; mixed 30% into the page colour it @@ -358,8 +443,8 @@ html{background:var(--bg-color,var(--bg-outer))} .sub.open{max-height:2000px} /* ── Modal dialog (folder picker, import preview, prompts) ── */ -.dlg-ov{position:fixed;inset:0;z-index:600;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px} -.dlg-box{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);width:100%;max-width:380px;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,.6)} +.dlg-ov{position:fixed;inset:0;z-index:600;background:var(--scrim);display:flex;align-items:center;justify-content:center;padding:24px} +.dlg-box{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);width:100%;max-width:380px;max-height:70vh;display:flex;flex-direction:column;box-shadow:var(--shadow-dlg)} .dlg-ov.wide .dlg-box{max-width:560px;max-height:80vh} .dlg-hdr{padding:16px 20px;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:700;border-bottom:1px solid var(--bd-inner)} .dlg-body{overflow-y:auto;flex:1;padding:8px} @@ -378,22 +463,22 @@ html{background:var(--bg-color,var(--bg-outer))} .dlg-choice input{margin-block-start:2px;flex:none} .dlg-empty{padding:24px;text-align:center;color:var(--dm);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote)} .fp-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--rs);background:none;border:none;color:var(--tx);font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);cursor:pointer;width:100%;text-align:start;font-family:inherit;transition:background var(--t)} -.fp-row:hover{background:rgba(255,255,255,.06)} -.fp-row.cur{background:rgba(2,122,255,.12)} +.fp-row:hover{background:var(--ov)} +.fp-row.cur{background:var(--row-selected)} .fp-row.muted{color:var(--dm)} .fp-row.accent{color:var(--ac2)} -.fp-ic{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:var(--label-primary);flex-shrink:0} +.fp-ic{width:28px;height:28px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:var(--on-accent);flex-shrink:0} .fp-ic img{width:18px;height:18px;object-fit:contain} .fp-nm{flex:1} .fp-chk{color:var(--gn);font-weight:700} /* ── Icon picker ── */ .ipw{position:relative} -.ipv{width:52px;height:52px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;color:var(--label-primary);flex-shrink:0} -.iprs{position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--rs);max-height:180px;overflow-y:auto;display:none;z-index:50;box-shadow:0 8px 24px rgba(0,0,0,.5)} +.ipv{width:52px;height:52px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;color:var(--on-accent);flex-shrink:0} +.iprs{position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--rs);max-height:180px;overflow-y:auto;display:none;z-index:50;box-shadow:var(--shadow-pop)} .iprs.open{display:block} .ipr{display:flex;align-items:center;gap:10px;padding:8px 12px;background:none;border:none;width:100%;text-align:start;cursor:pointer;color:var(--tx);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);font-family:inherit;transition:background var(--t)} -.ipr:hover{background:rgba(255,255,255,.06)} +.ipr:hover{background:var(--ov)} .ipr img{width:24px;height:24px;object-fit:contain} /* ── Color picker ── */ @@ -402,11 +487,11 @@ html{background:var(--bg-color,var(--bg-outer))} .hc-con-row,.hc-ping-row{padding:8px 0} .hc-con-row.hidden,.hc-ping-row:not(.active){display:none} .hc-ping-status{min-height:16px} -.blist{max-height:200px;overflow-y:auto;border:1px solid var(--bd-inner);border-radius:var(--rs);margin-top:6px;background:rgba(255,255,255,.04)} -.bi{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background var(--t);border-bottom:1px solid rgba(255,255,255,.04)} +.blist{max-height:200px;overflow-y:auto;border:1px solid var(--bd-inner);border-radius:var(--rs);margin-top:6px;background:var(--ov-soft)} +.bi{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;transition:background var(--t);border-bottom:1px solid var(--ov-soft)} .bi:last-child{border-bottom:none} -.bi:hover{background:rgba(255,255,255,.05)} -.bi.on{background:rgba(2,122,255,.15)} +.bi:hover{background:var(--ov)} +.bi.on{background:var(--row-selected)} .bck{width:14px;height:14px;border-radius:3px;border:2px solid var(--bd-inner);flex-shrink:0} .bi.on .bck{background:var(--ac);border-color:var(--ac)} .binfo{flex:1;min-width:0} @@ -417,30 +502,30 @@ html{background:var(--bg-color,var(--bg-outer))} .bprow-hidden{display:none!important} /* ── Auth overlays ── */ -.login-screen{position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.7);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center} -.login-wrap{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:32px 28px;width:100%;max-width:360px;box-shadow:0 24px 64px rgba(0,0,0,.6);display:flex;flex-direction:column;gap:14px} +.login-screen{position:fixed;inset:0;z-index:9000;background:var(--scrim-strong);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center} +.login-wrap{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:32px 28px;width:100%;max-width:360px;box-shadow:var(--shadow-login);display:flex;flex-direction:column;gap:14px} .login-header{display:flex;flex-direction:column;align-items:center;gap:12px;margin-bottom:4px} .login-icon{width:64px;height:64px;border-radius:16px;background:var(--login-badge);display:flex;align-items:center;justify-content:center} .login-title{font-size:var(--fs-title-3);line-height:var(--lh-title-3);letter-spacing:var(--tr-title-3);font-weight:700} .login-sub{font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);color:var(--dm)} .login-pw,.login-btn{width:100%;font-family:inherit} -.login-pw{background:rgba(255,255,255,.07);border:1px solid var(--bd-inner);border-radius:var(--rs);color:var(--tx);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);padding:12px 14px} +.login-pw{background:var(--field-fill);border:1px solid var(--bd-inner);border-radius:var(--rs);color:var(--tx);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);padding:12px 14px} .login-pw:focus{outline:none;border-color:var(--ac)} -.login-btn{background:var(--ac);color:var(--label-primary);border:none;border-radius:var(--rs);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:600;padding:13px;cursor:pointer;transition:background var(--t)} +.login-btn{background:var(--ac);color:var(--on-accent);border:none;border-radius:var(--rs);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:600;padding:13px;cursor:pointer;transition:background var(--t)} .login-btn:hover{background:var(--ac2)} .login-err{color:var(--rd);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);text-align:center;display:none} -.setpw-screen{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,.7);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center} +.setpw-screen{position:fixed;inset:0;z-index:8000;background:var(--scrim-strong);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center} .setpw-wrap{width:100%;max-width:360px;padding:24px} -.setpw-card{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:28px;display:flex;flex-direction:column;gap:12px;box-shadow:0 16px 48px rgba(0,0,0,.5)} +.setpw-card{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);padding:28px;display:flex;flex-direction:column;gap:12px;box-shadow:var(--shadow-dlg)} .setpw-title{font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body);font-weight:700} .setpw-desc{font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);color:var(--dm)} .setpw-input,.setpw-btn{width:100%;font-family:inherit} -.setpw-input{background:rgba(255,255,255,.07);border:1px solid var(--bd-inner);border-radius:var(--rs);color:var(--tx);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);padding:12px 14px} +.setpw-input{background:var(--field-fill);border:1px solid var(--bd-inner);border-radius:var(--rs);color:var(--tx);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);padding:12px 14px} .setpw-input:focus{outline:none;border-color:var(--ac)} .setpw-bars{display:flex;gap:4px} .setpw-hint{font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);min-height:16px} .setpw-err{color:var(--rd);font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote);display:none} -.setpw-btn{background:var(--ac);color:var(--label-primary);border:none;border-radius:var(--rs);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:600;padding:13px;cursor:pointer;transition:background var(--t)} +.setpw-btn{background:var(--ac);color:var(--on-accent);border:none;border-radius:var(--rs);font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);font-weight:600;padding:13px;cursor:pointer;transition:background var(--t)} .setpw-btn:hover{background:var(--ac2)} /* ── Form inside push edit view (#ev-body) — restyle to match PSD ── */ @@ -521,9 +606,9 @@ html.is-mobile .m-back:active{opacity:.7} html.is-mobile body.authed .mtabbar{ display:flex;position:fixed;left:14px;right:14px;bottom:14px; z-index:8000;justify-content:space-around;align-items:center;gap:2px; - background:rgba(30,30,38,.72);backdrop-filter:saturate(180%) blur(24px);-webkit-backdrop-filter:saturate(180%) blur(24px); - border:1px solid rgba(255,255,255,.08);border-radius:28px; - padding:7px 8px;box-shadow:0 8px 30px rgba(0,0,0,.4); + background:var(--mtabbar-bg);backdrop-filter:saturate(180%) blur(24px);-webkit-backdrop-filter:saturate(180%) blur(24px); + border:1px solid var(--mtabbar-bd);border-radius:28px; + padding:7px 8px;box-shadow:var(--shadow-bar); } html.is-mobile .mtab{flex:1;display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:none;border-radius:16px;padding-block:6px;padding-inline:8px;white-space:nowrap;transition:background var(--t),color var(--t);color:var(--dm);font-size:var(--fs-caption-1);line-height:var(--lh-caption-1);letter-spacing:var(--tr-caption-1);font-weight:600;cursor:pointer;padding:6px 2px;font-family:inherit;-webkit-tap-highlight-color:transparent} html.is-mobile .mtab .ni{width:31px;height:31px;flex-shrink:0} @@ -534,7 +619,7 @@ html.is-mobile .mtab .ni{width:31px;height:31px;flex-shrink:0} The 6 above and below comes off the bar's own padding, so the bar keeps its height. */ -html.is-mobile .mtab.active{background:var(--tab-pill);color:var(--label-primary)} +html.is-mobile .mtab.active{background:var(--tab-pill);color:var(--tx)} /* The handle is the drag zone. It needs touch-action:none, or starting a drag on it scrolls the list. */ @@ -555,7 +640,7 @@ html.is-mobile .rmt{display:none} html.is-mobile .rpills{grid-area:pills;align-self:center} html.is-mobile .ract{grid-area:actions} html.is-mobile .rord{width:28px;align-self:stretch;font-size:var(--fs-subheadline);line-height:var(--lh-subheadline);letter-spacing:var(--tr-subheadline);opacity:.55} -.drag-ghost{position:fixed;z-index:1000;pointer-events:none;opacity:.92;border:1px solid var(--bd-inner);background:var(--cp);box-shadow:0 10px 30px rgba(0,0,0,.55)} +.drag-ghost{position:fixed;z-index:1000;pointer-events:none;opacity:.92;border:1px solid var(--bd-inner);background:var(--cp);box-shadow:var(--shadow-ghost)} /* ─────────────── RTL ─────────────── The layout follows the text direction on its own now. Flexbox rows reverse by @@ -585,7 +670,14 @@ html.is-mobile .rord{width:28px;align-self:stretch;font-size:var(--fs-subheadlin --dm #B4B4B8 4.70 on the raised card, 5.83 on the raised pane --bd #B4B4B8 4.70 and 5.83, well past the 3.0 a border needs - --bd-inner gray2-hi 2.90, decorative only, see the note on the defaults */ + --bd-inner gray2-hi 2.90, decorative only, see the note on the defaults + + The light theme raises its own pair against white. gray-hi already clears + 4.5 there, so the step is to the next grey down rather than to a value + invented here. + + --dm #4A4A4E 8.66 on the card, 8.03 on the pane + --bd #4A4A4E the same grey; gray-hi is already the default here */ @media (prefers-contrast: more) { :root { --dm:#B4B4B8; @@ -593,6 +685,12 @@ html.is-mobile .rord{width:28px;align-self:stretch;font-size:var(--fs-subheadlin --bd-inner:var(--sy-gray2); --tx:var(--label-primary); } + html[data-theme="light"] { + --dm:#4A4A4E; + --bd:#4A4A4E; + --bd-inner:var(--sy-gray3); + --tx:var(--label-primary); + } /* The custom controls draw their own focus ring, so each is named here. A generic rule misses them. */ a:focus-visible, button:focus-visible, input:focus-visible, diff --git a/ui/css/tokens.css b/ui/css/tokens.css index 1fed3aea..e5353289 100644 --- a/ui/css/tokens.css +++ b/ui/css/tokens.css @@ -31,8 +31,13 @@ outside it fails rather than accumulating quietly. */ /* ── Palette ───────────────────────────────────────────────────────────────── - System colours from the reference kit, dark values. Both pages render on a dark panel or a - wallpaper, and no stylesheet declares a prefers-color-scheme rule. + System colours from the reference kit, dark values. The dashboard renders on + a dark panel or a wallpaper and has one appearance. The Settings page has two, + and the light one re-declares this palette further down. + + No stylesheet declares a prefers-color-scheme rule. The Settings page resolves + the system preference in script and writes data-theme on , so the light + values are selected by attribute and never by media query. Each hue also has a -hi entry, the increased-contrast value for the same colour in dark mode. The prefers-contrast block below swaps the whole palette @@ -91,6 +96,12 @@ --warning: var(--sy-orange); --success: var(--sy-green); + /* Ink on a filled control: an accent button, the selected navigation row, a + tile carrying a user-chosen colour. White in both themes. It is not a label + level, because the surface under it is a colour rather than a background, + and following the text ramp would turn it black in the light theme. */ + --on-accent: #FFFFFF; + --sy-radius-lg:14px; --sy-radius-md:10px; /* A grouped list is rounded much harder than a panel or a dialog. Its own @@ -148,6 +159,80 @@ --control-knob: #FFFFFF; } +/* ── Light theme ───────────────────────────────────────────────────────────── + The same palette, roles and semantic sets at their light values. Only the + Settings page opts in, by carrying data-theme="light" on . A page that + never sets the attribute is unaffected, which is why the dashboard keeps one + appearance. + + The -hi entries are the increased-contrast light values, so the + prefers-contrast block below raises the light theme the same way it raises + the dark one. + + The light hues are tuned for a fill, not for text: green at #34C759 behind + black is a badge, not a sentence. Every role here that ends up as text or as + the edge of a control therefore points at the -hi entry, which is the + accessible variant of the same hue. ui/test/contrast.test.mjs measures the + pairs that carry a WCAG requirement in both themes. */ +html[data-theme="light"] { + --sy-red:#FF3B30; --sy-red-hi:#D70015; + --sy-orange:#FF9500; --sy-orange-hi:#C93400; + --sy-yellow:#FFCC00; --sy-yellow-hi:#B25000; + --sy-green:#34C759; --sy-green-hi:#248A3D; + --sy-mint:#00C7BE; --sy-mint-hi:#0C817B; + --sy-teal:#30B0C7; --sy-teal-hi:#0071A4; + --sy-cyan:#32ADE6; --sy-cyan-hi:#007AA3; + --sy-blue:#007AFF; --sy-blue-hi:#0040DD; + --sy-indigo:#5856D6; --sy-indigo-hi:#3634A3; + --sy-purple:#AF52DE; --sy-purple-hi:#8944AB; + --sy-pink:#FF2D55; --sy-pink-hi:#C9155A; + --sy-brown:#A2845E; --sy-brown-hi:#7F6545; + + /* gray is the darkest here and gray6 the lightest, the reverse of the dark + set. A role that points at a grey follows without being re-declared. */ + --sy-gray:#8E8E93; --sy-gray-hi:#6C6C70; + --sy-gray2:#AEAEB2; --sy-gray2-hi:#8E8E93; + --sy-gray3:#C7C7CC; --sy-gray3-hi:#AEAEB2; + --sy-gray4:#D1D1D6; --sy-gray4-hi:#BCBCC0; + --sy-gray5:#E5E5EA; --sy-gray5-hi:#D8D8DC; + --sy-gray6:#F2F2F7; --sy-gray6-hi:#EBEBF0; + + /* The dark theme lifts two greys off the palette to clear WCAG on its worst + surface. The light theme needs no equivalent: the accessible greys above + already clear it on white, so each points at its palette neighbour. */ + --sy-a11y-dim:var(--sy-gray-hi); + --sy-a11y-border:var(--sy-gray); + + /* The accent is the accessible teal, not the light one. It is a button fill + under white ink and a link colour on white, and the light teal carries + neither. --accent-strong is the hover state, one step deeper again. */ + --accent: var(--sy-teal-hi); + --accent-strong: #00587E; + --danger: var(--sy-red-hi); + --warning: var(--sy-orange-hi); + --success: var(--sy-green-hi); + + --label-primary: #000000; + --label-secondary: rgba(60,60,67,.60); + --label-tertiary: rgba(60,60,67,.30); + --label-quaternary: rgba(60,60,67,.18); + + --fill-primary: rgba(120,120,128,.20); + --fill-secondary: rgba(120,120,128,.16); + --fill-tertiary: rgba(118,118,128,.12); + --fill-quaternary: rgba(116,116,128,.08); + + --separator-opaque: #C6C6C8; + --separator: rgba(60,60,67,.29); + + --bg-primary: #FFFFFF; + --bg-secondary: var(--sy-gray6); + --bg-tertiary: #FFFFFF; + --bg-elevated-primary: #FFFFFF; + --bg-elevated-secondary: var(--sy-gray6); + --bg-elevated-tertiary: #FFFFFF; +} + /* ── Type scale ────────────────────────────────────────────────────────────── The eleven text styles, at the default Dynamic Type step. Three tokens each: size, line height, letter spacing. A rule that sets a size should set all @@ -243,11 +328,15 @@ html.type-small:not(.is-mobile) { * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; } } -/* Increased contrast: the raised dark values. Every role points - at a hue, so raising the hues raises the roles. Each page raises its own - surface and border tokens in its own file. */ +/* Increased contrast: the raised values of whichever theme is in force. Every + role points at a hue, so raising the hues raises the roles. Each page raises + its own surface and border tokens in its own file. + + The light theme needs its own copy of this list. It sets the palette on + html[data-theme="light"], which outranks a :root rule, so a shared block here + would leave the light hues unraised. */ @media (prefers-contrast: more) { - :root { + :root, html[data-theme="light"] { --sy-red:var(--sy-red-hi); --sy-orange:var(--sy-orange-hi); --sy-yellow:var(--sy-yellow-hi); diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 2addbdc7..72b0e6b5 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -59,6 +59,11 @@ "display": "Anzeige", "keepAwake": "Bildschirm anlassen", "keepAwakeTip": "Verhindert, dass sich der Bildschirm abdunkelt oder sperrt, solange das Dashboard geöffnet ist. Browser erlauben das nur über HTTPS, bei einer einfachen HTTP-Adresse bleibt es wirkungslos.", + "displayMode": "Anzeigemodus der Einstellungen", + "displayModeTip": "Legt das Erscheinungsbild der Einstellungsseiten fest. Die Auswahl wird in diesem Browser gespeichert, gilt nur für dieses Gerät und wirkt sofort.", + "displaySystem": "Systemstandard", + "displayLight": "Hell", + "displayDark": "Dunkel", "wallpaper": "Hintergrundbild", "source": "Quelle", "unsplashKey": "API key", diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 206142d0..ed1ec834 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -59,6 +59,11 @@ "display": "Display", "keepAwake": "Keep Screen Awake", "keepAwakeTip": "Stops the screen dimming or locking while the dashboard is open. Browsers only allow this over HTTPS, so it has no effect on a plain HTTP address.", + "displayMode": "Settings Display Mode", + "displayModeTip": "Sets the appearance of the Settings pages. It is stored in this browser, applies to this device only, and takes effect at once.", + "displaySystem": "System Default", + "displayLight": "Light", + "displayDark": "Dark", "wallpaper": "Wallpaper", "source": "Source", "unsplashKey": "API Key", diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 40fddeec..087613b7 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -59,6 +59,11 @@ "display": "Pantalla", "keepAwake": "Mantener la pantalla encendida", "keepAwakeTip": "Evita que la pantalla se atenúe o se bloquee mientras el panel está abierto. Los navegadores solo lo permiten mediante HTTPS, así que no tiene efecto en una dirección HTTP simple.", + "displayMode": "Modo de visualización de Ajustes", + "displayModeTip": "Define la apariencia de las páginas de Ajustes. Se guarda en este navegador, se aplica solo a este dispositivo y surte efecto de inmediato.", + "displaySystem": "Predeterminado del sistema", + "displayLight": "Claro", + "displayDark": "Oscuro", "wallpaper": "Fondo de pantalla", "source": "Origen", "unsplashKey": "API key", diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 9982ffc7..60a20a5a 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -59,6 +59,11 @@ "display": "نمایش", "keepAwake": "روشن نگه داشتن صفحه", "keepAwakeTip": "تا زمانی که داشبورد باز است، صفحه کم‌نور یا قفل نمی‌شود. مرورگرها این قابلیت را فقط روی HTTPS اجازه می‌دهند، بنابراین روی نشانی ساده HTTP اثری ندارد.", + "displayMode": "حالت نمایش تنظیمات", + "displayModeTip": "ظاهر صفحه‌های تنظیمات را تعیین می‌کند. در همین مرورگر ذخیره می‌شود، فقط روی همین دستگاه اعمال می‌شود و بی‌درنگ اثر می‌گذارد.", + "displaySystem": "پیش‌فرض سیستم", + "displayLight": "روشن", + "displayDark": "تیره", "wallpaper": "تصویر زمینه", "source": "منبع", "unsplashKey": "API key", diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index 91c6a661..6baa223e 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -59,6 +59,11 @@ "display": "Affichage", "keepAwake": "Garder l’écran allumé", "keepAwakeTip": "Empêche l’écran de s’assombrir ou de se verrouiller tant que le tableau de bord est ouvert. Les navigateurs ne l’autorisent qu’en HTTPS, cela reste donc sans effet sur une adresse HTTP simple.", + "displayMode": "Mode d'affichage des Réglages", + "displayModeTip": "Définit l'apparence des pages Réglages. Le choix est enregistré dans ce navigateur, s'applique à cet appareil uniquement et prend effet immédiatement.", + "displaySystem": "Par défaut du système", + "displayLight": "Clair", + "displayDark": "Sombre", "wallpaper": "Fond d'écran", "source": "Source", "unsplashKey": "API key", diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 10299ceb..ec87da91 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -59,6 +59,11 @@ "display": "显示", "keepAwake": "保持屏幕常亮", "keepAwakeTip": "打开仪表板期间屏幕不会变暗或锁定。浏览器仅在 HTTPS 下允许该功能,因此在普通 HTTP 地址上无效。", + "displayMode": "设置显示模式", + "displayModeTip": "设定设置页面的外观。选择保存在此浏览器中,仅适用于此设备,并立即生效。", + "displaySystem": "跟随系统", + "displayLight": "浅色", + "displayDark": "深色", "wallpaper": "壁纸", "source": "来源", "unsplashKey": "API key", diff --git a/ui/index.html b/ui/index.html index 44bb7b38..3e156e2d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -23,7 +23,7 @@ Stackyard - + diff --git a/ui/js/admin-theme.js b/ui/js/admin-theme.js new file mode 100644 index 00000000..0a126972 --- /dev/null +++ b/ui/js/admin-theme.js @@ -0,0 +1,17 @@ +/* Sets the theme before the first paint. Load it as a blocking classic script + in . A module runs after the page has painted, and the light theme + arrives as a flash of the dark one. + + The storage key, the three modes and the two chrome colours must stay + identical to the ones in ui/js/theme.js. A parity test asserts it. */ +(function () { + try { + const stored = localStorage.getItem('sy-theme'); + const mode = ['system', 'light', 'dark'].indexOf(stored) < 0 ? 'system' : stored; + const theme = + mode === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : mode; + document.documentElement.setAttribute('data-theme', theme); + const meta = document.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute('content', theme === 'light' ? '#FFFFFF' : '#0d1117'); + } catch {} +})(); diff --git a/ui/js/admin.js b/ui/js/admin.js index 4bbb006d..ea195828 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,27 +1,28 @@ -import { loadLocalIcons, resolveIcon, iconChain } from '/js/icons.js?v=69c2b9bd'; -import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; -import { clr as rc, sanitizeCssUrl, el, inp, q, qa, tgt, setUserText } from '/js/utils.js?v=b81f6875'; -import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; +import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=c2c81c20'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=e76fbf24'; +import { applyDrop, canJoinFolder } from '/js/admin-drag-logic.js?v=8fcf583a'; import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=4f8a0761'; import { - newItemId, buildAppItem, - upsertItem, claimFolderChildren, - snapshotItems, + newItemId, saveWithRevert, + snapshotItems, + upsertItem, } from '/js/admin-save-logic.js?v=52a970d3'; -import { toast, ag, ap, initInlineEdit, setReauthHandler } from '/js/admin-shared.js?v=9a601113'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=e76fbf24'; +import { loadSettings, showBgFields } from '/js/admin-settings.js?v=482854de'; +import { ag, ap, initInlineEdit, setReauthHandler, toast } from '/js/admin-shared.js?v=9a601113'; import { state } from '/js/admin-state.js?v=42393ee1'; import { buildWidgetForm } from '/js/admin-widget-form.js?v=4b093a55'; -import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=c2c81c20'; -import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=d056c9c5'; -import { loadSettings, showBgFields } from '/js/admin-settings.js?v=482854de'; -import { canJoinFolder, applyDrop } from '/js/admin-drag-logic.js?v=8fcf583a'; -import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=ff76dc56'; +import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; +import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=d056c9c5'; +import { iconChain, loadLocalIcons, resolveIcon } from '/js/icons.js?v=69c2b9bd'; +import { clearSkipTls, convert, detectSource, insecureApps, NOTE, SKIP } from '/js/import-foreign.js?v=07b2664f'; +import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=28416a75'; +import { confirmModal, openModal as openDialog, promptModal } from '/js/modal.js?v=ff76dc56'; +import { readMode, watchSystemTheme, writeMode } from '/js/theme.js?v=fbd2d2ef'; +import { el, inp, q, qa, clr as rc, sanitizeCssUrl, setUserText, tgt } from '/js/utils.js?v=b81f6875'; import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=c69d0ef5'; -import { detectSource, convert, insecureApps, clearSkipTls, SKIP, NOTE } from '/js/import-foreign.js?v=07b2664f'; /* A class rather than a bare media query. Some phones report a wider CSS viewport than they have. The rule lives in layout.js, shared with the @@ -42,6 +43,7 @@ async function load() { state.items = c.items || []; state._settings = c.settings || {}; await initI18n(c.settings?.language || 'en'); + syncThemeLabel(); try { const wr = await ag('/api/widgets'); state._widgetReg = Object.create(null); @@ -1204,6 +1206,45 @@ function initLanguage() { setVal(hidden.value || 'en'); } +/* The row label follows the catalog, so it is written once the catalog is + loaded and again on every language change. */ +let syncThemeLabel = () => {}; + +const THEME_LABEL_KEYS = { + system: 'appearance.displaySystem', + light: 'appearance.displayLight', + dark: 'appearance.displayDark', +}; + +function initTheme() { + const btn = el('theme-btn'); + const list = el('theme-list'); + const hidden = inp('theme-sel'); + if (!btn || !list || !hidden) return; + syncThemeLabel = () => { + const tn = btn.childNodes[0]; + if (tn && tn.nodeType === 3) tn.textContent = t(THEME_LABEL_KEYS[hidden.value] || THEME_LABEL_KEYS.system); + }; + function setVal(val) { + hidden.value = writeMode(val); + syncThemeLabel(); + list + .querySelectorAll('li') + .forEach(li => li.setAttribute('aria-selected', String(li.dataset.val === hidden.value))); + list.hidden = true; + } + btn.addEventListener('click', e => { + e.stopPropagation(); + list.hidden = !list.hidden; + }); + list.querySelectorAll('li').forEach(li => li.addEventListener('click', () => setVal(li.dataset.val))); + document.addEventListener('click', () => { + list.hidden = true; + }); + watchSystemTheme(() => hidden.value); + setVal(readMode()); +} + const dashSaveEl = el('dash-save'); if (dashSaveEl) dashSaveEl.onclick = () => save(); @@ -1459,6 +1500,7 @@ initDockerToggle(); initBgType(); initLogLevel(); initLanguage(); +initTheme(); setReauthHandler(requireLogin); diff --git a/ui/js/theme.js b/ui/js/theme.js new file mode 100644 index 00000000..01fbcd55 --- /dev/null +++ b/ui/js/theme.js @@ -0,0 +1,91 @@ +/* The Settings page appearance: dark, light, or whichever the device asks for. + + The choice is per device, not per dashboard. It is read from localStorage and + never written to the config, so two people opening Settings on two machines + each get their own, and a machine keeps its own through a config import. + + "system" is resolved here rather than in a media query. The stylesheets carry + one light block, selected by data-theme, and a prefers-color-scheme copy of it + would be a second place to change every colour. + + ui/js/admin-theme.js repeats what this file decides so the attribute is on + before the first paint. A parity test holds the two together. */ + +export const THEME_KEY = 'sy-theme'; + +/** @type {readonly ['system','light','dark']} */ +export const THEME_MODES = /** @type {const} */ (['system', 'light', 'dark']); + +/** The stored value, or "system" for anything this version does not know. + @param {string|null|undefined} value */ +export function normaliseMode(value) { + return THEME_MODES.includes(/** @type {any} */ (value)) ? String(value) : 'system'; +} + +/** The theme a mode resolves to. + @param {string} mode + @param {boolean} prefersDark whether the device asks for a dark appearance */ +export function resolveTheme(mode, prefersDark) { + const m = normaliseMode(mode); + if (m === 'system') return prefersDark ? 'dark' : 'light'; + return m; +} + +/** The chrome colour for a theme: the surface the page draws behind its panel. */ +export function themeColor(theme) { + return theme === 'light' ? '#FFFFFF' : '#0d1117'; +} + +/** Writes the theme onto the document. + @param {string} theme + @param {Document} doc */ +export function applyTheme(theme, doc = document) { + doc.documentElement.setAttribute('data-theme', theme); + const meta = doc.querySelector('meta[name="theme-color"]'); + if (meta) meta.setAttribute('content', themeColor(theme)); +} + +/** The mode this device is set to. + @param {Storage} [store] */ +export function readMode(store) { + try { + return normaliseMode((store || localStorage).getItem(THEME_KEY)); + } catch { + return 'system'; + } +} + +/** Stores a mode and applies it. + @param {string} mode + @param {Storage} [store] */ +export function writeMode(mode, store) { + const m = normaliseMode(mode); + try { + (store || localStorage).setItem(THEME_KEY, m); + } catch {} + applyTheme(resolveTheme(m, prefersDark())); + return m; +} + +export function prefersDark() { + try { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } catch { + return true; + } +} + +/** Follows the device while the mode is "system". Returns a teardown. + @param {() => string} currentMode */ +export function watchSystemTheme(currentMode) { + try { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = e => { + if (currentMode() === 'system') applyTheme(resolveTheme('system', e.matches)); + }; + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + } catch { + return () => {}; + } +} diff --git a/ui/test/contrast.test.mjs b/ui/test/contrast.test.mjs index 97c72f7f..0a49527d 100644 --- a/ui/test/contrast.test.mjs +++ b/ui/test/contrast.test.mjs @@ -1,7 +1,7 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; /* WCAG contrast for the admin greys, computed rather than recorded. @@ -28,28 +28,76 @@ const admin = read('admin.css'); /* ── resolving a token to a hex value ─────────────────────────────────────── */ -/* Declarations from a block, last one winning, which is how the cascade reads a - single :root. */ -function declarations(src, from = 0, to = src.length) { +/* The page has two themes and each has a raised variant, so a declaration has to + be read with the selector and the media query it sits under. Splitting the + file at the contrast block was enough while there was one theme; it would now + fold the light values into the dark map. + + Every rule in these two files, with the at-rule it is nested in. One level of + nesting is all either file has. */ +function rules(src) { + const s = src.replace(/\/\*[\s\S]*?\*\//g, ''); + const out = []; + let i = 0; + let media = null; + while (i < s.length) { + const open = s.indexOf('{', i); + if (open < 0) break; + const prelude = s.slice(i, open).trim(); + if (prelude.startsWith('@')) { + media = prelude; + i = open + 1; + continue; + } + let depth = 1; + let j = open + 1; + while (j < s.length && depth > 0) { + if (s[j] === '{') depth++; + else if (s[j] === '}') depth--; + j++; + } + out.push({ media, selectors: prelude.split(',').map(x => x.trim()), body: s.slice(open + 1, j - 1) }); + while (/\s/.test(s[j] || '')) j++; + /* The next brace to close is the at-rule's own. */ + if (s[j] === '}') { + media = null; + j++; + } + i = j; + } + return out; +} + +/* Declarations from the rules a theme selects, in file order, last one + winning. */ +function declarations(src, wanted) { const out = new Map(); - /* Not anchored to the line start: tokens.css writes two declarations per - line, a grey and its -hi partner. */ - for (const m of src.slice(from, to).matchAll(/(--[\w-]+)\s*:\s*([^;{}]+);/g)) { - out.set(m[1], m[2].trim()); + for (const rule of rules(src)) { + if (!wanted(rule)) continue; + /* Not anchored to the line start: tokens.css writes two declarations per + line, a grey and its -hi partner. */ + for (const m of rule.body.matchAll(/(--[\w-]+)\s*:\s*([^;{}]+);/g)) out.set(m[1], m[2].trim()); } return out; } -const raisedAt = s => s.indexOf('@media (prefers-contrast: more)'); +const RAISED = '@media (prefers-contrast: more)'; +const LIGHT = 'html[data-theme="light"]'; -/* Two resolvers: the default theme, and the one someone gets after asking their - system for more contrast. The raised block is layered on top of the base. */ -function resolver({ raised }) { - const base = new Map([...declarations(tokens, 0, raisedAt(tokens)), ...declarations(admin, 0, raisedAt(admin))]); - if (raised) { - for (const [k, v] of declarations(tokens, raisedAt(tokens))) base.set(k, v); - for (const [k, v] of declarations(admin, raisedAt(admin))) base.set(k, v); - } +/* Four resolvers: each theme, and what each becomes after someone asks their + system for more contrast. A theme layers its own rules over the defaults, and + the raised block layers over both. */ +function resolver({ raised = false, light = false } = {}) { + const wants = rule => { + if (rule.media && rule.media !== RAISED) return false; + if (rule.media === RAISED && !raised) return false; + const isLight = rule.selectors.includes(LIGHT); + const isBase = rule.selectors.includes(':root'); + /* The light theme inherits every :root default and overrides some of them. + The dark theme never reads a light-only rule. */ + return light ? isBase || isLight : isBase; + }; + const base = new Map([...declarations(tokens, wants), ...declarations(admin, wants)]); return function resolve(name, seen = new Set()) { assert.ok(!seen.has(name), `${name} resolves in a cycle`); seen.add(name); @@ -88,34 +136,51 @@ const REQUIRED = [ ]; test('the resolver reads both files', () => { - const resolve = resolver({ raised: false }); + const resolve = resolver(); assert.equal(resolve('--dm'), '#A3A3A8'); assert.equal(resolve('--pane'), '#2C2C2E'); assert.equal(resolve('--cp'), '#3A3A3C'); }); -test('every required pair clears its threshold in the default theme', () => { - const resolve = resolver({ raised: false }); - const failures = []; - for (const [fg, min, what] of REQUIRED) { - for (const bg of SURFACES) { - const r = ratio(resolve(fg), resolve(bg)); - if (r < min) failures.push(`${fg} on ${bg}: ${r.toFixed(2)}, needs ${min} (${what})`); - } - } - assert.deepEqual(failures, [], `Below the WCAG minimum:\n ${failures.join('\n ')}`); +test('the resolver reads the light theme', () => { + const resolve = resolver({ light: true }); + assert.equal(resolve('--dm'), '#6C6C70'); + assert.equal(resolve('--pane'), '#F2F2F7'); + assert.equal(resolve('--cp'), '#FFFFFF'); }); -test('increased contrast clears every threshold too', () => { - const high = resolver({ raised: true }); +/* Four combinations, one measurement. The light theme is not exempt from any + threshold the dark one carries. */ +for (const light of [false, true]) { + for (const raised of [false, true]) { + const name = `${light ? 'light' : 'dark'}${raised ? ', increased contrast' : ''}`; + test(`every required pair clears its threshold: ${name}`, () => { + const resolve = resolver({ raised, light }); + const failures = []; + for (const [fg, min, what] of REQUIRED) { + for (const bg of SURFACES) { + const r = ratio(resolve(fg), resolve(bg)); + if (r < min) failures.push(`${fg} on ${bg}: ${r.toFixed(2)}, needs ${min} (${what})`); + } + } + assert.deepEqual(failures, [], `Below the WCAG minimum (${name}):\n ${failures.join('\n ')}`); + }); + } +} + +/* The accent is a link colour and a button fill in both themes, and the light + hues are drawn for a fill. --accent-strong is what a rule names when the + accent has to be read as text. */ +test('the accent reads as text on every surface it is used on', () => { const failures = []; - for (const [fg, min, what] of REQUIRED) { + for (const light of [false, true]) { + const resolve = resolver({ light }); for (const bg of SURFACES) { - const r = ratio(high(fg), high(bg)); - if (r < min) failures.push(`${fg} on ${bg}: ${r.toFixed(2)}, needs ${min} (${what})`); + const r = ratio(resolve('--ac2'), resolve(bg)); + if (r < 4.5) failures.push(`${light ? 'light' : 'dark'}: --ac2 on ${bg}: ${r.toFixed(2)}, needs 4.5`); } } - assert.deepEqual(failures, [], `Below the WCAG minimum in the raised mode:\n ${failures.join('\n ')}`); + assert.deepEqual(failures, [], `Below the WCAG minimum:\n ${failures.join('\n ')}`); }); /* The mode exists to improve the pairs that are close to their limit. It did diff --git a/ui/test/palette-values.test.mjs b/ui/test/palette-values.test.mjs index 4562ac80..976d74c1 100644 --- a/ui/test/palette-values.test.mjs +++ b/ui/test/palette-values.test.mjs @@ -1,12 +1,12 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; -/* The palette is the system colours, dark values. Every entry below is - transcribed from the Accents and Grays variables of the design kits, which - agree on all eighteen. +/* The palette is the system colours. Every entry below is transcribed from the + Accents and Grays variables of the design kits, which agree on all eighteen in + both themes. The values had been copied by hand, and one had gone in wrong: Cyan was #3CCFFE against the kit's #3CD3FE. No rule referenced Cyan, so nothing rendered @@ -42,18 +42,61 @@ const APPLE_DARK = { '--sy-gray6': '#1C1C1E', }; -/* Only the base :root, not the increased-contrast block, which redeclares every - hue as a var() reference to its -hi partner. */ -function baseDeclarations() { - const end = tokens.indexOf('@media (prefers-contrast: more)'); - assert.ok(end > 0, 'the increased-contrast block should exist'); +/* The light values of the same eighteen, from the Any and Light column of the + same variables. */ +const APPLE_LIGHT = { + '--sy-red': '#FF3B30', + '--sy-orange': '#FF9500', + '--sy-yellow': '#FFCC00', + '--sy-green': '#34C759', + '--sy-mint': '#00C7BE', + '--sy-teal': '#30B0C7', + '--sy-cyan': '#32ADE6', + '--sy-blue': '#007AFF', + '--sy-indigo': '#5856D6', + '--sy-purple': '#AF52DE', + '--sy-pink': '#FF2D55', + '--sy-brown': '#A2845E', + '--sy-gray': '#8E8E93', + '--sy-gray2': '#AEAEB2', + '--sy-gray3': '#C7C7CC', + '--sy-gray4': '#D1D1D6', + '--sy-gray5': '#E5E5EA', + '--sy-gray6': '#F2F2F7', +}; + +/* Every top-level block a selector opens: the palette, the roles and the + semantic sets are three of them. Anchored to the start of a line, which is + what leaves out the copies nested in the increased-contrast block, where the + hues are var() references to their -hi partners rather than values. */ +function blockOf(selector) { + const opener = `\n${selector} {`; + const found = []; + for (let at = tokens.indexOf(opener); at >= 0; at = tokens.indexOf(opener, at + 1)) { + const start = at + opener.length; + const end = tokens.indexOf('\n}', start); + assert.ok(end > start, `${selector} is not closed`); + found.push(tokens.slice(start, end)); + } + assert.ok(found.length > 0, `${selector} should open a block in tokens.css`); + return found.join('\n'); +} + +/* Both themes declare the palette, and the file carries one block for each. */ +function paletteOf(selector) { const out = new Map(); - for (const m of tokens.slice(0, end).matchAll(/(--sy-[\w-]+)\s*:\s*(#[0-9A-Fa-f]{6})/g)) { + for (const m of blockOf(selector).matchAll(/(--sy-[\w-]+)\s*:\s*(#[0-9A-Fa-f]{6})/g)) { out.set(m[1], m[2].toUpperCase()); } return out; } +function declarationsOf(selector) { + const out = new Map(); + for (const m of blockOf(selector).matchAll(/(--[\w-]+)\s*:\s*([^;{}]+);/g)) out.set(m[1], m[2].trim()); + return out; +} + /* The semantic sets, dark. Labels and fills are transcribed at the alpha the kit publishes; the surfaces point at palette greys and are checked through the reference rather than by value. */ @@ -78,27 +121,51 @@ const APPLE_SEMANTIC = { '--control-knob': '#FFFFFF', }; -test('every semantic token is its reference value', () => { - const end = tokens.indexOf('@media (prefers-contrast: more)'); - const declared = new Map(); - for (const m of tokens.slice(0, end).matchAll(/(--[\w-]+)\s*:\s*([^;{}]+);/g)) { - declared.set(m[1], m[2].trim()); - } - const wrong = []; - for (const [name, expected] of Object.entries(APPLE_SEMANTIC)) { - const actual = declared.get(name); - const same = actual && actual.replace(/\s+/g, '').toUpperCase() === expected.replace(/\s+/g, '').toUpperCase(); - if (!same) wrong.push(`${name}: ${actual ?? 'not declared'}, expected ${expected}`); - } - assert.deepEqual(wrong, [], `The semantic layer has drifted:\n ${wrong.join('\n ')}`); -}); +/* The semantic sets, light. The surfaces are white or a palette grey, and the + labels and fills are transcribed at the alpha the kit publishes. */ +const APPLE_SEMANTIC_LIGHT = { + '--label-primary': '#000000', + '--label-secondary': 'rgba(60,60,67,.60)', + '--label-tertiary': 'rgba(60,60,67,.30)', + '--label-quaternary': 'rgba(60,60,67,.18)', + '--fill-primary': 'rgba(120,120,128,.20)', + '--fill-secondary': 'rgba(120,120,128,.16)', + '--fill-tertiary': 'rgba(118,118,128,.12)', + '--fill-quaternary': 'rgba(116,116,128,.08)', + '--separator-opaque': '#C6C6C8', + '--separator': 'rgba(60,60,67,.29)', + '--bg-primary': '#FFFFFF', + '--bg-secondary': 'var(--sy-gray6)', + '--bg-tertiary': '#FFFFFF', + '--bg-elevated-primary': '#FFFFFF', + '--bg-elevated-secondary': 'var(--sy-gray6)', + '--bg-elevated-tertiary': '#FFFFFF', +}; -test('every palette entry is its reference value', () => { - const declared = baseDeclarations(); - const wrong = []; - for (const [name, expected] of Object.entries(APPLE_DARK)) { - const actual = declared.get(name); - if (actual !== expected) wrong.push(`${name}: ${actual ?? 'not declared'}, the kit has ${expected}`); - } - assert.deepEqual(wrong, [], `The palette has drifted from the system colours:\n ${wrong.join('\n ')}`); -}); +const THEMES = [ + [':root', APPLE_DARK, APPLE_SEMANTIC, 'dark'], + ['html[data-theme="light"]', APPLE_LIGHT, APPLE_SEMANTIC_LIGHT, 'light'], +]; + +for (const [selector, palette, semantic, name] of THEMES) { + test(`every semantic token is its reference value: ${name}`, () => { + const declared = declarationsOf(selector); + const wrong = []; + for (const [token, expected] of Object.entries(semantic)) { + const actual = declared.get(token); + const same = actual && actual.replace(/\s+/g, '').toUpperCase() === expected.replace(/\s+/g, '').toUpperCase(); + if (!same) wrong.push(`${token}: ${actual ?? 'not declared'}, expected ${expected}`); + } + assert.deepEqual(wrong, [], `The ${name} semantic layer has drifted:\n ${wrong.join('\n ')}`); + }); + + test(`every palette entry is its reference value: ${name}`, () => { + const declared = paletteOf(selector); + const wrong = []; + for (const [token, expected] of Object.entries(palette)) { + const actual = declared.get(token); + if (actual !== expected) wrong.push(`${token}: ${actual ?? 'not declared'}, the kit has ${expected}`); + } + assert.deepEqual(wrong, [], `The ${name} palette has drifted from the system colours:\n ${wrong.join('\n ')}`); + }); +} diff --git a/ui/test/theme.test.mjs b/ui/test/theme.test.mjs new file mode 100644 index 00000000..64d19761 --- /dev/null +++ b/ui/test/theme.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { applyTheme, normaliseMode, resolveTheme, THEME_KEY, THEME_MODES, themeColor } from '../js/theme.js'; + +const read = rel => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); + +const preload = read('../js/admin-theme.js'); +const adminHtml = read('../admin/index.html'); +const adminCss = read('../css/admin.css'); +const tokensCss = read('../css/tokens.css'); + +test('an unknown or missing mode falls back to the system', () => { + for (const value of [null, undefined, '', 'sepia', 'System']) assert.equal(normaliseMode(value), 'system'); + for (const mode of THEME_MODES) assert.equal(normaliseMode(mode), mode); +}); + +test('an explicit mode ignores the device', () => { + for (const prefersDark of [true, false]) { + assert.equal(resolveTheme('light', prefersDark), 'light'); + assert.equal(resolveTheme('dark', prefersDark), 'dark'); + } +}); + +test('the system mode follows the device', () => { + assert.equal(resolveTheme('system', true), 'dark'); + assert.equal(resolveTheme('system', false), 'light'); +}); + +/* A value written by a later version, or by hand. Anything unrecognised has to + land on a theme rather than leave the attribute unset, which paints the light + markup with no light tokens. */ +test('an unrecognised mode still resolves to a theme', () => { + for (const prefersDark of [true, false]) { + assert.ok(['light', 'dark'].includes(resolveTheme('sepia', prefersDark))); + } +}); + +test('applying a theme writes the attribute and the chrome colour', () => { + const meta = { + content: '#0d1117', + setAttribute(k, v) { + this[k] = v; + }, + }; + const root = { + setAttribute(k, v) { + this[k] = v; + }, + }; + const doc = { documentElement: root, querySelector: () => meta }; + applyTheme('light', /** @type {any} */ (doc)); + assert.equal(root['data-theme'], 'light'); + assert.equal(meta.content, themeColor('light')); + applyTheme('dark', /** @type {any} */ (doc)); + assert.equal(root['data-theme'], 'dark'); + assert.equal(meta.content, themeColor('dark')); +}); + +/* The preload script cannot import the module, so it repeats what the module + decides. The two drifting apart shows as a flash of the wrong theme, or as a + stored choice the preload does not recognise. */ +test('the preload script repeats the module exactly', () => { + assert.ok(preload.includes(`'${THEME_KEY}'`), `admin-theme.js no longer uses the ${THEME_KEY} key`); + assert.ok(preload.includes(`['${THEME_MODES.join("', '")}']`), 'admin-theme.js no longer lists the same modes'); + assert.ok(preload.includes("'(prefers-color-scheme: dark)'")); + for (const theme of ['light', 'dark']) assert.ok(preload.includes(`'${themeColor(theme)}'`)); +}); + +test('the preload script is not a module', () => { + assert.ok(!/\b(import|export)\b/.test(preload)); +}); + +test('admin loads the preload script before its stylesheets', () => { + const script = adminHtml.indexOf('/js/admin-theme.js'); + const stylesheet = adminHtml.indexOf('rel="stylesheet"'); + assert.ok(script > -1, 'admin/index.html does not load admin-theme.js'); + assert.ok(script < stylesheet); + assert.ok(!/admin-theme\.js[^>]*\b(defer|async|type="module")/.test(adminHtml)); +}); + +/* The script sets the chrome colour by finding the meta tag, so the tag has to + be parsed already. */ +test('the theme colour meta precedes the preload script', () => { + assert.ok(adminHtml.indexOf('name="theme-color"') < adminHtml.indexOf('/js/admin-theme.js')); +}); + +/* The dashboard has one appearance. It never sets the attribute, so a light + rule reaching its stylesheet would be dead weight nobody can trigger. */ +test('only the settings page is themed', () => { + assert.ok(!read('../index.html').includes('admin-theme.js')); + assert.ok(!read('../css/dashboard.css').includes('data-theme')); +}); + +test('both stylesheets select the light theme the same way', () => { + for (const [name, src] of [ + ['admin.css', adminCss], + ['tokens.css', tokensCss], + ]) { + assert.ok(src.includes('html[data-theme="light"]'), `${name} declares no light values`); + } +}); + +/* The palette moves wholesale under increased contrast. A light hue without its + partner would resolve to the dark theme's raised value. */ +test('the light palette carries an increased-contrast partner for every hue', () => { + const block = tokensCss.slice(tokensCss.indexOf('html[data-theme="light"] {')); + const light = block.slice(0, block.indexOf('\n}')); + const hues = [...light.matchAll(/--sy-([a-z]+\d?):\s*#/g)].map(m => m[1]); + assert.ok(hues.length >= 18, `expected the twelve hues and six greys, found ${hues.length}`); + for (const h of hues) assert.match(light, new RegExp(`--sy-${h}-hi:\\s*#`), `--sy-${h} has no -hi partner`); +});