Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions ACCESSIBILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Accessibility

Stackyard targets [WCAG 2.1](https://www.w3.org/TR/WCAG21/) and [WCAG 2.2](https://www.w3.org/TR/WCAG22/) at level AA.

## Conformance status

Stackyard is **partially conformant** with WCAG 2.2 level AA. Partially conformant means most of the standard is met, and the parts that are not are listed below.

This is a self-assessment. No external audit has been carried out.

## What is supported

- Every control has a name, a role, and a visible focus indicator.
- The interface is operable by keyboard alone. Reordering, paging, search, and dialogs each have a keyboard route.
- Dialogs trap focus, close on Escape, and return focus to the control that opened them.
- Text in the dashboard and in Settings meets the 4.5:1 contrast minimum in both the light and the dark theme. The ratios are computed from the stylesheets by a test, so a colour change that drops a pair below the minimum fails the build.
- The interface honours `prefers-reduced-motion` and `prefers-contrast`.
- Content reflows to a 320 CSS pixel viewport with no horizontal scrolling, and survives text resizing to 200%.
- The interface is translated into six languages. Accessible names are translated with it, apart from those listed below.
- Page changes are announced. Polled figures are not announced, so a screen reader is not interrupted by data that changes on a timer.

## Known limitations

- **Widgets need an extra step to read.** Each widget is an embedded document. A screen reader does not enter one during linear navigation, so its contents are read only after the reader is asked to enter it. Widgets containing a button or a link are also reachable with Tab.
- **A phone shortcut that responds only to touch.** On a phone, a widget that has a link set opens it when tapped away from the widget's own controls. There is no keyboard equivalent for that shortcut. Widgets that carry their own link also expose it as a control, and that one is reachable by keyboard. The shortcut does not exist on a desktop.
- **Widget colours are not covered by the contrast test.** Each widget is a separate document with its own stylesheet. Those colours are checked by hand rather than by the build, and a widget rendered on a transparent background cannot be measured automatically.

## How this was assessed

- Automated testing with [axe-core](https://github.com/dequelabs/axe-core) in Chromium and WebKit, against the released container.
- Manual browser testing for reflow, text resize, text spacing, target size, and keyboard operation.
- Manual screen reader testing with VoiceOver, on macOS with Safari and on iOS.

Automated testing has covered every widget. Screen reader testing has covered the dashboard, Settings, and the widgets on a running dashboard, but not every widget in every configuration. The Windows screen readers NVDA and JAWS have not been used.

## Reporting a problem

Open an issue at [github.com/SandObserver/stackyard/issues](https://github.com/SandObserver/stackyard/issues). Include the page, the assistive technology and browser, and what you expected to happen.

Last reviewed 2026-08-23, against Stackyard 1.8.0 and later.
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- An accessibility statement, in `ACCESSIBILITY.md`.

### Changed

- Settings content is now a main landmark, and the dashboard pager is a
navigation landmark.

- Repeated widgets of one type now get distinct frame names.

### Fixed

- Toggles and inline-edit fields in Settings now carry an accessible name.

- Type pills, filter chips and the digital clock's date line now meet the WCAG
contrast minimum in both themes.

- The password field's placeholder now renders its translation.

- Accessible names in the GitHub, Disk Health, Now Playing, Connections and
Dashboard Switch widgets are now translated.

- Tab no longer moves to tiles on a dashboard page that is off screen.

- Pagination dots now meet the minimum target size.

- Widgets no longer read their figures aloud on every poll. System Summary,
Weather, DNS and Now Playing announced themselves on a timer.

- The Move up and Move down buttons in Settings, and the pull request filter
names in the GitHub widget, are now translated.

## [1.8.0] - 2026-08-23

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ Full instructions, including building from source, are at [Installation](https:/

Stackyard never returns stored secrets to the browser, guards the URLs you test in the admin UI against SSRF and pins the resolved IP, and bounds every upstream call so one slow service cannot hang the dashboard. Some features trade safety for convenience and are opt-in with warnings. Read [docs/security.md](docs/security.md) before exposing Stackyard beyond your LAN.

## Accessibility

Stackyard targets WCAG 2.2 level AA and is partially conformant. Every control is named and reachable by keyboard, colours meet the contrast minimum in both themes, and the interface honours the reduced-motion and increased-contrast settings. Known limitations and how this was assessed are in [ACCESSIBILITY.md](ACCESSIBILITY.md).

## Contributing

Contributions are welcome, within the constraints that keep Stackyard small and auditable (one container, no backend dependencies, vanilla frontend). See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/frontend.md](docs/frontend.md). Participation is covered by the [Code of Conduct](CODE_OF_CONDUCT.md).
Expand Down
20 changes: 10 additions & 10 deletions ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<script src="/js/mobile-class.js?v=fe95da0d"></script>
<script src="/js/admin-theme.js?v=66565254"></script>
<link rel="stylesheet" href="/css/tokens.css?v=26b1b862">
<link rel="stylesheet" href="/css/admin.css?v=35ed0598">
<link rel="stylesheet" href="/css/admin.css?v=526eaf5a">
<link rel="stylesheet" href="/css/widget-config-form.css?v=53b91d5f">
</head>
<body>
Expand Down Expand Up @@ -48,7 +48,7 @@
</nav>

<!-- Content -->
<div class="cp">
<main class="cp">

<!-- General -->
<section class="sec" id="sec-general" hidden>
Expand Down Expand Up @@ -91,16 +91,16 @@
</div>
<input type="hidden" id="log-level" value="info">
</div>
<div class="row"><span class="rl" data-i18n="general.dockerHealth">Docker Container Health Checks</span><label class="tog" aria-label="Docker Container Health Checks"><input type="checkbox" id="srv-docker-en"><div class="tr"></div></label></div>
<div class="row d-none" id="srv-hide-healthy-row"><span class="rl" data-i18n="general.hideHealthy">Hide Healthy Badge</span><label class="tog" aria-label="Hide Healthy Badge"><input type="checkbox" id="srv-hide-healthy"><div class="tr"></div></label></div>
<div class="row"><span class="rl" id="rl-srv-docker-en" data-i18n="general.dockerHealth">Docker Container Health Checks</span><label class="tog"><input type="checkbox" id="srv-docker-en" aria-labelledby="rl-srv-docker-en"><div class="tr"></div></label></div>
<div class="row d-none" id="srv-hide-healthy-row"><span class="rl" id="rl-srv-hide-healthy" data-i18n="general.hideHealthy">Hide Healthy Badge</span><label class="tog"><input type="checkbox" id="srv-hide-healthy" aria-labelledby="rl-srv-hide-healthy"><div class="tr"></div></label></div>
<div class="row ie-row d-none" id="ie-socket"><span class="rl" data-i18n="general.socketUrl">Socket URL</span><span class="rv is-ph" id="ie-socket-v">http://socket-proxy:2375</span><button class="pe" type="button" aria-label="Edit socket URL"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
</div>
<p class="grp-tip" data-i18n="general.loggingTip">Errors shows warnings and errors only. Security events are always logged.</p>
<p class="grp-tip" id="socket-hint" style="display:none" data-i18n-html="general.socketTip">Use the URL of a <strong>socket proxy</strong> container, never the Docker socket itself. A name such as <code>http://socket-proxy:2375</code> works only when this container and the proxy share a Docker network; an IP address works only when the proxy publishes its port beyond the host's own loopback.</p>

<div class="grp-hdr" data-i18n="general.security">Security</div>
<div class="grp">
<div class="row"><span class="rl" data-i18n="general.passwordProtection">Password Protection</span><label class="tog" aria-label="Password Protection"><input type="checkbox" id="sec-en"><div class="tr"></div></label></div>
<div class="row"><span class="rl" id="rl-sec-en" data-i18n="general.passwordProtection">Password Protection</span><label class="tog"><input type="checkbox" id="sec-en" aria-labelledby="rl-sec-en"><div class="tr"></div></label></div>
<div class="row ie-row d-none" id="ie-pw"><span class="rl" data-i18n="general.password">Password</span><span class="rv is-ph" id="ie-pw-v">Not set</span><button class="pe" type="button" aria-label="Edit password"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5"/><path d="M18.4 2.6a1.85 1.85 0 0 1 2.6 2.6l-9.1 9.1-3.4 1 1-3.4z"/></svg></button></div>
<div class="row d-none" id="sec-revoke-row"><span class="rl" data-i18n="general.signOutEverywhere">Sign out all devices</span><div class="row-btns"><button class="btn bg sm" id="sec-revoke" type="button" data-i18n="general.signOutEverywhereBtn">Sign out all</button></div></div>
</div>
Expand All @@ -124,8 +124,8 @@
</div>
<div class="grp-hdr" data-i18n="appearance.appTitle">App Title</div>
<div class="grp">
<div class="row"><span class="rl" data-i18n="appearance.showTitleDesktop">Show App Title On Desktop</span><label class="tog" aria-label="Show App Title On Desktop"><input type="checkbox" id="set-lbl-d"><div class="tr"></div></label></div>
<div class="row"><span class="rl" data-i18n="appearance.showTitleMobile">Show App Title On Mobile</span><label class="tog" aria-label="Show App Title On Mobile"><input type="checkbox" id="set-lbl-m"><div class="tr"></div></label></div>
<div class="row"><span class="rl" id="rl-set-lbl-d" data-i18n="appearance.showTitleDesktop">Show App Title On Desktop</span><label class="tog"><input type="checkbox" id="set-lbl-d" aria-labelledby="rl-set-lbl-d"><div class="tr"></div></label></div>
<div class="row"><span class="rl" id="rl-set-lbl-m" data-i18n="appearance.showTitleMobile">Show App Title On Mobile</span><label class="tog"><input type="checkbox" id="set-lbl-m" aria-labelledby="rl-set-lbl-m"><div class="tr"></div></label></div>
</div>
<div class="grp-hdr" data-i18n="appearance.display">Display</div>
<div class="grp">
Expand All @@ -141,7 +141,7 @@
</div>
<input type="hidden" id="theme-sel" value="system">
</div>
<div class="row"><span class="rl" data-i18n="appearance.keepAwake">Keep Screen Awake</span><label class="tog" aria-label="Keep Screen Awake"><input type="checkbox" id="set-awake"><div class="tr"></div></label></div>
<div class="row"><span class="rl" id="rl-set-awake" data-i18n="appearance.keepAwake">Keep Screen Awake</span><label class="tog"><input type="checkbox" id="set-awake" aria-labelledby="rl-set-awake"><div class="tr"></div></label></div>
</div>
<p class="grp-tip" data-i18n="appearance.displayModeTip">Sets the appearance of the Settings pages. It is stored in this browser, applies to this device only, and takes effect at once.</p>
<p class="grp-tip" data-i18n="appearance.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.</p>
Expand Down Expand Up @@ -260,7 +260,7 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div>
</section>

</div><!-- .cp -->
</main><!-- .cp -->
</div><!-- .adm -->
</div><!-- .adm-outer -->

Expand Down Expand Up @@ -321,6 +321,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=1a323317"></script>
<script type="module" src="/js/admin.js?v=dc8df8fc"></script>
</body>
</html>
26 changes: 18 additions & 8 deletions ui/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,26 @@
--slider-dir:90deg;

/* Derived from the hue, never written out, so a palette change carries
through. */
through. The hue's readable variant is drawn for a surface, not for a pill
tinted with the same hue, where it reaches only 3.6 to 4.4 against the 4.5
of 1.4.3. Lifting it toward the theme's extreme clears every pill on both
the pane and a card. */
--chip-ink-boost:#FFFFFF;
/* An unselected chip sits on the field fill, not on the pane, and --dm is
measured against the pane. */
--chip-text:#C7C7CC;
--chip-dk-bg:color-mix(in srgb, var(--sy-indigo) 20%, transparent);
--chip-dk-fg:var(--sy-indigo-hi);
--chip-dk-fg:color-mix(in srgb, var(--sy-indigo-hi) 82%, var(--chip-ink-boost));
--chip-wg-bg:color-mix(in srgb, var(--sy-green) 20%, transparent);
--chip-wg-fg:var(--sy-green-hi);
--chip-wg-fg:color-mix(in srgb, var(--sy-green-hi) 82%, var(--chip-ink-boost));
--chip-hl-bg:color-mix(in srgb, var(--sy-pink) 20%, transparent);
--chip-hl-fg:var(--sy-pink-hi);
--chip-hl-fg:color-mix(in srgb, var(--sy-pink-hi) 82%, var(--chip-ink-boost));
--chip-bg-bg:color-mix(in srgb, var(--sy-blue) 20%, transparent);
--chip-bg-fg:var(--sy-blue-hi);
--chip-bg-fg:color-mix(in srgb, var(--sy-blue-hi) 82%, var(--chip-ink-boost));
--chip-fl-bg:color-mix(in srgb, var(--sy-brown) 20%, transparent);
--chip-fl-fg:var(--sy-brown-hi);
--chip-fl-fg:color-mix(in srgb, var(--sy-brown-hi) 82%, var(--chip-ink-boost));
--chip-sy-bg:color-mix(in srgb, var(--sy-gray) 22%, transparent);
--chip-sy-fg:var(--sy-gray-hi);
--chip-sy-fg:color-mix(in srgb, var(--sy-gray-hi) 82%, var(--chip-ink-boost));
/* Page neutrals particular to this screen. */
--tile-placeholder:var(--sy-gray2-hi); /* an app tile with no icon yet */
--surface-sunken:var(--sy-gray5); /* recessed well behind the About icon */
Expand Down Expand Up @@ -102,6 +109,9 @@ html[data-theme="light"] {
--tx:#1C1C1E;
--dm:var(--sy-gray-hi);

--chip-ink-boost:#000000;
--chip-text:#5A5A5E;

--tile-placeholder:var(--sy-gray2-hi);
--surface-sunken:var(--sy-gray5);
--login-badge:#D3E4FF;
Expand Down Expand Up @@ -247,7 +257,7 @@ html{background:var(--bg-color,var(--bg-outer))}
.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: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{padding:6px 13px;border-radius:20px;border:none;background:var(--field-fill);color:var(--chip-text);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-fill)}
.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}
Expand Down
4 changes: 3 additions & 1 deletion ui/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@
#dots {
position:fixed; bottom:160px; left:50%; /* desktop only — mobile overrides bottom inline */
transform:translateX(-50%);
display:flex; gap:10px; z-index:500;
/* 24px between centres: an interior dot's hit area is bounded by its
neighbours', so the pitch is its width. */
display:flex; gap:16px; z-index:500;
background:rgba(255,255,255,.28);
-webkit-backdrop-filter:blur(12px) saturate(140%); backdrop-filter:blur(12px) saturate(140%);
border-radius:24px; padding:10px 18px;
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "Eingerichtet",
"notSet": "Nicht gesetzt",
"show": "Anzeigen",
"hide": "Ausblenden"
"hide": "Ausblenden",
"moveUp": "Nach oben",
"moveDown": "Nach unten"
},
"general": {
"title": "Titel",
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "Configured",
"notSet": "Not set",
"show": "Show",
"hide": "Hide"
"hide": "Hide",
"moveUp": "Move up",
"moveDown": "Move down"
},
"general": {
"title": "Title",
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "Configurado",
"notSet": "Sin definir",
"show": "Mostrar",
"hide": "Ocultar"
"hide": "Ocultar",
"moveUp": "Subir",
"moveDown": "Bajar"
},
"general": {
"title": "Título",
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "تنظیم‌شده",
"notSet": "تنظیم نشده",
"show": "نمایش",
"hide": "پنهان کردن"
"hide": "پنهان کردن",
"moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین"
},
"general": {
"title": "عنوان",
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "Définie",
"notSet": "Non définie",
"show": "Afficher",
"hide": "Masquer"
"hide": "Masquer",
"moveUp": "Monter",
"moveDown": "Descendre"
},
"general": {
"title": "Titre",
Expand Down
4 changes: 3 additions & 1 deletion ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"configured": "已设置",
"notSet": "未设置",
"show": "显示",
"hide": "隐藏"
"hide": "隐藏",
"moveUp": "上移",
"moveDown": "下移"
},
"general": {
"title": "标题",
Expand Down
6 changes: 3 additions & 3 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@

<title>Stackyard</title>
<link rel="stylesheet" href="/css/tokens.css?v=26b1b862">
<link rel="stylesheet" href="/css/dashboard.css?v=ea092f4d">
<link rel="stylesheet" href="/css/dashboard.css?v=645f3cff">
</head>
<body>
<h1 class="visually-hidden">Stackyard</h1>
<div id="ctrls"></div>
<main id="pages" aria-label="Home screen" data-i18n-al="home.homeScreen"></main>
<div id="dots" role="group" aria-label="Dashboard pages" data-i18n-al="home.pagination"></div>
<nav id="dots" aria-label="Dashboard pages" data-i18n-al="home.pagination"></nav>
<!-- Announces the page after a change. The dots are reachable by keyboard. -->
<div id="page-live" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
<div id="mob-search-pill">
Expand All @@ -53,5 +53,5 @@ <h1 class="visually-hidden">Stackyard</h1>
</div>
</div>
</div>
<script type="module" src="/js/dashboard.js?v=7301de6b"></script>
<script type="module" src="/js/dashboard.js?v=c08f3c95"></script>
</body></html>
4 changes: 2 additions & 2 deletions ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
initInlineEdit,
setTogDisabled,
wireChecklist,
} from '/js/admin-shared.js?v=d96fc091';
import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=89eee5e8';
} from '/js/admin-shared.js?v=8f69dad6';
import { renderColorControl, BADGE_SWATCHES } from '/js/admin-color-control.js?v=984a0b69';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=10f3cdb1';

export function buildFolderForm(body, item) {
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ag, ap } from '/js/admin-shared.js?v=d96fc091';
import { ag, ap } from '/js/admin-shared.js?v=8f69dad6';
import { t } from '/js/i18n.js?v=d056c9c5';
import { pwStrength } from '/js/password-strength.js?v=42f45ac7';
import { el, inp as inpById, qa } from '/js/utils.js?v=b18c93ed';
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-color-control.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=d96fc091';
import { PE_SVG, initInlineEdit, toast } from '/js/admin-shared.js?v=8f69dad6';
import { t } from '/js/i18n.js?v=d056c9c5';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { qa, q } from '/js/utils.js?v=b18c93ed';
Expand Down
Loading