Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The format follows [Keep a Changelog] (https://keepachangelog.com/en/1.1.0/).
- **Quiz questions are scored per field** (#1730). Each field can be scored or left out; choices by correct option(s), a checkbox only on the exact set, or by points per option; text when answered; number and date by a correct range. "Show which answers were correct" now works. New tokens `{{correct_count}}` and `{{question_count}}`; existing forms score as before.
- **A quiz can clear its answers when the participant leaves the window** (#1731). Off by default; the form warns first, switching tabs clears the scored answers and a dialog says why. It is not an attempt; the count is stored and shown on the submission. A deterrent, not a lock.
- **Open quiz answers can be graded by a person** (#1732). A manually graded text question holds the attempt as "Awaiting Grading"; grading it on the edit screen issues the certificate or records Retry/Failed and emails the result. New capability `ffc_grade_certificates`, seeded from `ffc_edit_certificates`.
- **A quiz can also reset when its window loses the focus** (#1745). Leaving the Window is Off / Moderate / Strict; Strict catches two windows side by side, after a grace set on Settings → Certificates. Start again reloads, reshuffling options. Built on a reusable steps control (`ffc-control-*`) with autosave.

### Changed

Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,25 @@ Every icon the plugin draws comes from `Core\Icons`: a 24×24 stroke drawing in
- **An area wears one icon and one name everywhere, the admin menu included** (#1640, #1641). `Core\PluginAreas` maps each plugin area to its menu item, its registry class and its translated name, and the WordPress menu, the documentation tree and Settings → Modules all read it; `PluginAreasAgreementTest` holds them together. It is its own class rather than a map inside `Icons` because it now carries names too — a name that encodes a neighbour's scope (see the opening of this file). The canonical names are the menu's: Certificates, Scheduling (its two modules Personal Calendars and Audience Calendars), Reregistration, Recruitment, Short URLs, Date Messages, FFC Settings. A module name is Title Case where it names the area (menu, headings, tabs, groups); a label describing a kind of message or option stays sentence case like its neighbours. **A path a reader is sent to — "Settings → *Tab*" — must name a tab that exists**: `SettingsPathReferenceTest` reads every translatable string, so renaming a tab fails CI until each path to it is rewritten. The menus register with the icon `'none'` and `Icons::menu_stylesheet()`, attached to core's `admin-menu` sheet on every screen, draws the registry mask in `currentColor`, so the icon takes the colour WordPress gives the item for the scheme, hover, current page and folded menu, and `LinkText` in forced colours. **Not a base64 SVG `icon_url`**: core's svg-painter recolours `fill`, and the registry draws strokes, so the icon would keep one colour whatever the scheme. A new top-level menu joins `AREAS` and registers with `'none'`.
- **Dashicons stay only where they are WordPress chrome the registry does not draw** — the separator labels printed into `#adminmenu` — and nowhere else. `DashiconsChromeOnlyTest` freezes the allowed files with an exact count and a reason each. Tab lists that a filter can extend (Scheduling settings, Recruitment, the form editor) name a registry class and go through `Icons::tab_class()`, which still draws a bare dashicon slug a third-party tab passes, so opening the list to plugins did not break when the plugin's own tabs moved.

### Controls (one family — #1745)

A setting that picks among a few values is drawn by a control from the `ffc-control-*` family, chosen by what the values ARE, never by how they look:

| Values | Control | Helper |
| --- | --- | --- |
| two (on/off) | toggle | `AdminUI::render_toggle()` |
| an ORDERED scale of 3 to 11 | steps | `AdminUI::render_steps()` |
| 2 to 6 with no order | segmented | `AdminUI::render_segmented()` (planned) |
| anything else | a `<select>` | — |

- **The toggle still wears its pre-family class**, `.ffc-toggle`; moving it and every screen that prints it to `ffc-control-toggle` is planned, in the PR that gives it the shared states.
- **Order is the test for steps.** A rail and a fill say "more of the same thing"; three unrelated choices on a rail lie about their relation. Off → Moderate → Strict is a scale; Dark / Light / Auto is not.
- **Out of range is handled, not refused.** `render_steps()` given fewer or more options than it draws prints a select, so the field still works, and calls `_doing_it_wrong()` naming the control that fits — which surfaces under `WP_DEBUG`, where the developer is.
- **Every control is native inputs plus CSS.** Radios or a checkbox carry the value, so the form posts without a script, the arrow keys and screen readers work, and autosave (`data-ffc-autosave-key` / `-form-key` on the inputs) saves the group as one field with no change to the widget.
- **Parameters for what varies, custom properties for fine tuning.** Palette, size, shape, width and which labels show are parameters that become classes; anything finer is a `--ffc-control-*` property set by a module class or inline. A palette or tone colours through tokens only and stays inside `:where()`, so a state such as disabled always wins over the colour of the chosen value.
- **One state language.** Hover and pressed draw a halo (`--ffc-control-halo`), keyboard focus the 2px primary ring, and disabled takes the colour away instead of fading — `opacity` would multiply down the contrast `DarkModeCssTest` measures.
- **The name is the family's, not the screen's or the count's** (the rule at the top of this file). The steps control began as a "three-position switch" and was renamed before it shipped because "three" stopped being true the day the limit became eleven.

### Theme (one palette, one root class — #1126)

Every colour the plugin paints comes from a `var(--ffc-*)` token declared in `assets/css/ffc-common.css`. The dark theme is that same sheet's `:root.ffc-dark-mode` block redefining part of the tokens — **the dark theme is the light theme overridden, never a second set**, which is why `DarkModeCssTest::palette('dark')` merges the two before measuring. `AssetHelper::enqueue_dark_mode()` puts the class on `<html>`, reading `ffc_settings['dark_mode']` (`off` / `on` / `auto`).
Expand Down
313 changes: 313 additions & 0 deletions assets/css/ffc-common.css
Original file line number Diff line number Diff line change
Expand Up @@ -2097,6 +2097,319 @@
}
}

/* ==========================================================================
CONTROLS (#1745) — the .ffc-control-* family, drawn by AdminUI.
One state language for all of them: a halo (--ffc-control-halo) for hover
and pressed, the 2px primary ring for keyboard focus, and a disabled
state that takes the colour away instead of fading (OpacityContrastTest).
Fine tuning goes through the --ffc-control-* custom properties.

STEPS — AdminUI::render_steps(): an ORDERED scale of 3 to 11 values.
One native radio per stop, so it posts and works without a script; the
rail fills up to the chosen stop and that step's name is set in bold, so
colour is never the only signal. Palettes colour the fill and the thumb's
ring after the chosen step:
.ffc-control-steps-primary the accent (the default)
.ffc-control-steps-severity grey → amber → red
.ffc-control-steps-traffic red → amber → green
A stop's data-tone overrides the palette for that step.
========================================================================== */
.ffc-control-steps {
--ffc-control-halo: color-mix(in srgb, var(--ffc-primary) 18%, transparent);
--ffc-control-steps-rail: 6px;
--ffc-control-steps-dot: 8px;
--ffc-control-steps-thumb: 20px;
--ffc-control-steps-shape: var(--ffc-radius-full);
--ffc-control-steps-gap: 76px;
--ffc-control-steps-idle: var(--ffc-border);
--ffc-control-steps-rail-bg: var(--ffc-gray-300);
--ffc-control-steps-fill: var(--ffc-primary);

display: inline-block;
vertical-align: top;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}

.ffc-control-steps-track {
display: flex;
}

.ffc-control-steps-stop {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ffc-spacing-xs);
width: var(--ffc-control-steps-gap);
text-align: center;
cursor: pointer;
}

.ffc-control-steps-stop input {
/* Visually hidden but reachable by keyboard + assistive tech. */
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}

/* The rail segment leading to each stop from the one before it. */
.ffc-control-steps-stop:not(:first-child)::before {
content: "";
position: absolute;
top: calc(var(--ffc-control-steps-thumb) / 2 - var(--ffc-control-steps-rail) / 2);
right: 50%;
width: 100%;
height: var(--ffc-control-steps-rail);
border-radius: var(--ffc-control-steps-rail);
background: var(--ffc-control-steps-rail-bg);
}

.ffc-control-steps-mark {
position: relative;
z-index: var(--ffc-z-raised);
display: grid;
place-items: center;
width: var(--ffc-control-steps-thumb);
height: var(--ffc-control-steps-thumb);
}

.ffc-control-steps-mark::after {
content: "";
width: var(--ffc-control-steps-dot);
height: var(--ffc-control-steps-dot);
border-radius: var(--ffc-control-steps-shape);
background: var(--ffc-bg-card);
box-shadow: inset 0 0 0 2px var(--ffc-control-steps-idle);
transition: width 0.15s ease, height 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}

.ffc-control-steps-label {
padding: 0 var(--ffc-spacing-3xs);
color: var(--ffc-text-muted);
font-size: var(--ffc-font-size-xs);
line-height: 1.25;
}

/* Filled: every stop up to the chosen one, and the rail leading to it. */
.ffc-control-steps-stop:has(~ .ffc-control-steps-stop input:checked)::before,
.ffc-control-steps-stop:has(input:checked)::before {
background: var(--ffc-control-steps-fill);
}

.ffc-control-steps-stop:has(~ .ffc-control-steps-stop input:checked) .ffc-control-steps-mark::after {
background: var(--ffc-control-steps-fill);
box-shadow: none;
}

/* Chosen: the thumb, the card's colour inside a ring of the step's tone. */
.ffc-control-steps-stop:has(input:checked) .ffc-control-steps-mark::after {
width: var(--ffc-control-steps-thumb);
height: var(--ffc-control-steps-thumb);
background: var(--ffc-bg-card);
box-shadow: inset 0 0 0 3px var(--ffc-control-steps-fill), var(--ffc-shadow-sm);
}

.ffc-control-steps-stop:has(input:checked) .ffc-control-steps-label {
color: var(--ffc-text);
font-weight: 600;
}

.ffc-control-steps-stop:hover:not(:has(input:checked)) .ffc-control-steps-mark::after {
box-shadow: inset 0 0 0 2px var(--ffc-control-steps-fill);
}

.ffc-control-steps-stop:has(input:checked):hover .ffc-control-steps-mark::after {
box-shadow: inset 0 0 0 3px var(--ffc-control-steps-fill), 0 0 0 8px var(--ffc-control-halo);
}

.ffc-control-steps-stop:has(input:focus-visible) .ffc-control-steps-mark::after {
box-shadow: inset 0 0 0 3px var(--ffc-control-steps-fill), 0 0 0 2px var(--ffc-bg-card), 0 0 0 4px var(--ffc-primary);
}

.ffc-control-steps-stop:has(input:checked):active .ffc-control-steps-mark::after {
box-shadow: inset 0 0 0 3px var(--ffc-control-steps-fill), 0 0 0 12px var(--ffc-control-halo);
}

/* Palettes and tones. :where() keeps each at one class, so a state such as
disabled always outranks the colour of the chosen step. */
.ffc-control-steps-severity:where(:has(.ffc-control-steps-stop:first-child input:checked)) {
--ffc-control-steps-fill: var(--ffc-gray-600);
}

.ffc-control-steps-severity:where(:has(.ffc-control-steps-stop:not(:first-child):not(:last-child) input:checked)),
.ffc-control-steps-traffic:where(:has(.ffc-control-steps-stop:not(:first-child):not(:last-child) input:checked)) {
--ffc-control-steps-fill: var(--ffc-warning);
}

.ffc-control-steps-severity:where(:has(.ffc-control-steps-stop:last-child input:checked)),
.ffc-control-steps-traffic:where(:has(.ffc-control-steps-stop:first-child input:checked)) {
--ffc-control-steps-fill: var(--ffc-danger);
}

.ffc-control-steps-traffic:where(:has(.ffc-control-steps-stop:last-child input:checked)) {
--ffc-control-steps-fill: var(--ffc-success);
}

.ffc-control-steps:where(:has([data-tone="neutral"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-gray-600);
}

.ffc-control-steps:where(:has([data-tone="primary"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-primary);
}

.ffc-control-steps:where(:has([data-tone="info"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-info);
}

.ffc-control-steps:where(:has([data-tone="success"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-success);
}

.ffc-control-steps:where(:has([data-tone="warning"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-warning);
}

.ffc-control-steps:where(:has([data-tone="danger"] input:checked)) {
--ffc-control-steps-fill: var(--ffc-danger);
}

/* Sizes, shape, width. */
.ffc-control-steps-sm {
--ffc-control-steps-rail: 4px;
--ffc-control-steps-dot: 6px;
--ffc-control-steps-thumb: 16px;
--ffc-control-steps-gap: 60px;
}

.ffc-control-steps-lg {
--ffc-control-steps-rail: 8px;
--ffc-control-steps-dot: 10px;
--ffc-control-steps-thumb: 26px;
--ffc-control-steps-gap: 96px;
}

.ffc-control-steps-many {
--ffc-control-steps-gap: 56px;
}

.ffc-control-steps-square {
--ffc-control-steps-shape: var(--ffc-radius-sm);
}

.ffc-control-steps-fluid {
display: block;
}

.ffc-control-steps-fluid .ffc-control-steps-stop {
flex: 1 1 0;
width: auto;
}

/* labels: selected — the other names stay for screen readers only. */
.ffc-control-steps-labels-selected .ffc-control-steps-stop:not(:has(input:checked)) .ffc-control-steps-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

.ffc-control-steps-labels-selected .ffc-control-steps-stop:has(input:checked) .ffc-control-steps-label {
white-space: nowrap;
}

/* Disabled: the colour leaves, nothing fades. */
.ffc-control-steps:disabled {
--ffc-control-steps-fill: var(--ffc-gray-400);
--ffc-control-steps-idle: var(--ffc-gray-300);
}

.ffc-control-steps:disabled .ffc-control-steps-stop {
cursor: not-allowed;
}

.ffc-control-steps:disabled .ffc-control-steps-label {
color: var(--ffc-text-muted);
}

/* Only the chosen step's description shows. */
.ffc-control-steps-desc {
display: none;
margin-top: var(--ffc-spacing-xs);
}

.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(1) input:checked) .ffc-control-steps-desc[data-step="1"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(2) input:checked) .ffc-control-steps-desc[data-step="2"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(3) input:checked) .ffc-control-steps-desc[data-step="3"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(4) input:checked) .ffc-control-steps-desc[data-step="4"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(5) input:checked) .ffc-control-steps-desc[data-step="5"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(6) input:checked) .ffc-control-steps-desc[data-step="6"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(7) input:checked) .ffc-control-steps-desc[data-step="7"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(8) input:checked) .ffc-control-steps-desc[data-step="8"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(9) input:checked) .ffc-control-steps-desc[data-step="9"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(10) input:checked) .ffc-control-steps-desc[data-step="10"],
.ffc-control-steps:has(.ffc-control-steps-stop:nth-child(11) input:checked) .ffc-control-steps-desc[data-step="11"] {
display: block;
}

/* A phone: the stops share the width, and a long scale names only the
chosen step. */
@media (max-width: 480px) {
.ffc-control-steps {
display: block;
}

.ffc-control-steps .ffc-control-steps-stop {
flex: 1 1 0;
width: auto;
}

.ffc-control-steps-many .ffc-control-steps-stop:not(:has(input:checked)) .ffc-control-steps-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

.ffc-control-steps-many .ffc-control-steps-stop:has(input:checked) .ffc-control-steps-label {
white-space: nowrap;
}
}

@media (prefers-reduced-motion: reduce) {
.ffc-control-steps-mark::after {
transition: none;
}
}

@media (forced-colors: active) {
.ffc-control-steps-mark::after {
forced-color-adjust: none;
background: Canvas;
box-shadow: inset 0 0 0 2px CanvasText;
}

.ffc-control-steps-stop:has(~ .ffc-control-steps-stop input:checked) .ffc-control-steps-mark::after,
.ffc-control-steps-stop:has(input:checked) .ffc-control-steps-mark::after {
background: Highlight;
box-shadow: none;
}

.ffc-control-steps-stop:not(:first-child)::before {
forced-color-adjust: none;
background: CanvasText;
}
}

/* ==========================================================================
AUTO-SAVE BADGE — companion to FFC.Admin.autoSaveField.
Renders next to a field showing the live save state.
Expand Down
2 changes: 1 addition & 1 deletion assets/css/ffc-common.min.css

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions assets/js/ffc-admin-autosave.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
// the track on toggle-on — so the toggle visually stays "off"
// even after the save succeeds. Anchor the badge AFTER the
// wrapping label instead so the track stays adjacent.
var $anchor = $field.closest('.ffc-toggle');
// A steps control (#1745) is one field made of one radio per step:
// its badge goes after the whole control, once.
var $anchor = $field.closest('.ffc-toggle, .ffc-control-steps');
if (!$anchor.length) {
$anchor = $field;
}
Expand Down Expand Up @@ -346,6 +348,10 @@
}
var key = $input.attr(attr);
var isMulti = $input.attr('data-ffc-autosave-multi') !== undefined;
// A radio group saves as one field (#1745): the first radio is the
// anchor, the group feeds its change handler, and the value read is
// the checked member's.
var isRadio = $input.is(':radio');
if (!key) {
return;
}
Expand All @@ -355,7 +361,7 @@
// call). Siblings still get marked bound so this loop skips
// them on the next pass, but their changes are funneled
// through the anchor's change handler via config.$group.
if (isMulti) {
if (isMulti || isRadio) {
if (multiBound[key]) {
$input.data('ffcAutoSaveBound', true);
return;
Expand All @@ -373,6 +379,11 @@
if (confirmOff) {
config.confirmOff = confirmOff;
}
if (isRadio) {
var $radios = $('[' + attr + '="' + key + '"]:radio');
$radios.not($input).data('ffcAutoSaveBound', true);
config.$group = $radios;
}
if (isMulti) {
var $group = $('[' + attr + '="' + key + '"][data-ffc-autosave-multi]');
$group.not($input).data('ffcAutoSaveBound', true);
Expand Down
Loading
Loading