@oksigenia/access-panel 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @oksigenia/access-panel
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Two refinements to scoped panels (`scope=`):
8
+
9
+ **Window-level "global-offerable" controls.** Big cursor and the reading guide / mask can't be confined to a container, but they're benign window-level aids — so a scoped panel now offers them and applies them to the whole window, with a single shared state across every scoped instance and detached window. Toggle from any pane's pill and the cursor/guide/mask turns on window-wide while every pane's button reflects it; it persists as a window preference (synced same-document via an event and cross-window via the `storage` event), not under any one pane's `storage-key`. Grayscale and the colour-blind filter stay out of scoped panels (they destroy colour-coded data) — a scoped instance now drops only those two.
10
+
11
+ **Flat layout in scoped mode.** Curated/scoped panels looked sparse because each control category was its own grid, so controls from different categories couldn't share a row. Scoped panels now use a single flat grid; the category headings are kept screen-reader-only (out of the visual flow) so assistive tech keeps the structure, and the trailing button widens to fill any odd gap. The classic (non-scoped) panel is unchanged — it keeps its visible categories.
12
+
13
+ Both are scoped-mode only; they don't touch the classic panel or the WordPress/Moodle plugins.
14
+
15
+ ## 0.6.1
16
+
17
+ ### Patch Changes
18
+
19
+ - Make the `focus` highlight colour themeable via `--oks-focus-color` (default `#005fcc`), with an optional `--oks-focus-glow` for the ring around the current element. The hardcoded blue could vanish on a dark theme; set the variable on `:root` for the global panel, or on the scope container for a scoped one (`scope=`), to keep the highlight visible. Custom properties inherit, so it composes with `scope=`. Default behaviour is unchanged, and high-contrast mode keeps its own cyan.
20
+
3
21
  ## 0.6.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -102,7 +102,9 @@ By default the panel adapts the whole page — which is what a normal content si
102
102
 
103
103
  The instance applies its effect classes to `#map-pane` instead of `body` and injects a stylesheet anchored to that selector. Mount one per pane (each with its own `storage-key`) and they coexist without clobbering each other. The dialog opens **over its own pane** (anchored to the scope's box, not a shared viewport corner), so each pane's panel reads as belonging to that pane.
104
104
 
105
- A scoped instance **auto-excludes** the controls that can't be confined to a container: the full-screen overlays (grayscale, reading guide, reading mask), the colour-blind root filter, and the window-level big cursor. If you need one of those globally, mount a separate non-scoped instance.
105
+ A scoped instance **drops grayscale and the colour-blind filter** — they destroy colour-coded data, which is exactly what scoping protects. **Big cursor and the reading guide / mask** can't be confined to a container either, but they're benign window-level aids, so a scoped panel still offers them and applies them to the **whole window**, with a single shared state across every pane and detached window: toggle from any pane's button and the cursor/guide/mask turns on window-wide while every pane's button reflects it (persisted as a window preference, not per-pane).
106
+
107
+ A scoped panel uses a **flat layout** — one grid, no visible category headings (they're kept screen-reader-only) — because curated per-pane panels look sparse when each category is its own grid. The classic (non-scoped) panel keeps its categories.
106
108
 
107
109
  Text size in scoped mode scales the container's `font-size`, so it scales `em`/inherited text inside the pane but **not `rem`** — `rem` is always relative to `<html>` and there is no CSS way to anchor it to a container. Size that pane's text in `em` if you want per-pane text scaling.
108
110
 
@@ -135,6 +137,17 @@ If you need to brand the panel itself, fork the package and customize
135
137
  `PANEL_CSS` — the building blocks are exported from
136
138
  `@oksigenia/access-panel`.
137
139
 
140
+ ### Focus highlight colour (for dark themes)
141
+
142
+ The `focus` control highlights interactive elements with a blue that can vanish on a dark background. Override it with `--oks-focus-color` (and optionally `--oks-focus-glow` for the ring around the current element). Custom properties inherit, so set it where the effect applies — on `:root` for the global panel, or on the scope container for a scoped one:
143
+
144
+ ```css
145
+ :root { --oks-focus-color: #5ee0ff; } /* dark theme, global */
146
+ #map-pane { --oks-focus-color: #5ee0ff; } /* dark theme, scope= */
147
+ ```
148
+
149
+ Default is `#005fcc`. The persistent "all interactive" outline derives a 45% tint from this colour; high-contrast mode overrides both to cyan on its own.
150
+
138
151
  ## Use it in Astro
139
152
 
140
153
  ```astro
package/dist/index.d.ts CHANGED
@@ -90,13 +90,21 @@ declare const PRESET_IDS: readonly PresetId[];
90
90
  */
91
91
  declare function resolveEnabledControls(controlsAttr?: string | null, excludeAttr?: string | null): Set<ControlId>;
92
92
  /**
93
- * Controls that cannot be confined to a container (`scope=`): the full-screen
94
- * overlays (grayscale, reading guide, reading mask), the colour-blind root
95
- * `filter` on `<html>`, and the window-level big cursor. A scoped instance
96
- * auto-excludes these — a per-pane panel only offers what it can regionalise.
93
+ * Controls that can't be confined to a container AND shouldn't even be offered
94
+ * in a scoped panel: grayscale (a full-screen overlay that destroys colour
95
+ * data) and the colour-blind root `filter` (destroys data + the open
96
+ * simulation-vs-correction question). A scoped instance drops these entirely.
97
97
  */
98
98
  declare const NON_SCOPABLE_CONTROLS: readonly ControlId[];
99
- /** Remove the non-scopable controls from an enabled set (scoped mode). */
99
+ /**
100
+ * Controls that can't be *scoped* but ARE benign window-level aids, so a scoped
101
+ * panel still offers them and applies them globally (to the whole window) with
102
+ * a shared, cross-instance state: big cursor and the reading guide / mask.
103
+ * Toggling from any pane affects the whole window and every pane's button
104
+ * reflects it. (Unlike grayscale/colour-blind, these don't falsify data.)
105
+ */
106
+ declare const GLOBAL_OFFERABLE_CONTROLS: readonly ControlId[];
107
+ /** Remove the controls a scoped instance doesn't offer at all (grayscale, colour-blind). */
100
108
  declare function scopedControls(enabled: Set<ControlId>): Set<ControlId>;
101
109
 
102
110
  type Position = 'top-left' | 'top-center' | 'top-right' | 'mid-left' | 'mid-center' | 'mid-right' | 'bottom-left' | 'bottom-center' | 'bottom-right';
@@ -114,6 +122,10 @@ interface RenderOptions {
114
122
  * entirely. Defaults to true; individual presets still self-hide under the
115
123
  * ≥2-control rule. */
116
124
  showPresets?: boolean;
125
+ /** Flat layout: one grid with screen-reader-only category headings instead of
126
+ * visible per-category sections. Used in scoped mode, where heavily-curated
127
+ * panels look sparse with category breaks. Defaults to false (classic). */
128
+ flatLayout?: boolean;
117
129
  }
118
130
  /**
119
131
  * HTML del panel completo (trigger + dialog). Pensado para inyectarse
@@ -155,8 +167,8 @@ interface PanelController {
155
167
  */
156
168
  declare function bindPanelBehavior(root: ShadowRoot, opts?: BehaviorOptions): PanelController;
157
169
 
158
- declare const PANEL_CSS = "\n:host {\n --oks-btn-size: 55px;\n --oks-bg: #000;\n --oks-icon: #fff;\n --oks-h-bg: #fff;\n --oks-h-icon: #000;\n --oks-z: 9999999;\n}\n.oks-access-wrapper {\n position: fixed;\n z-index: var(--oks-z);\n line-height: 1;\n}\n.oks-access-btn {\n width: var(--oks-btn-size);\n height: var(--oks-btn-size);\n border-radius: 50%;\n background: var(--oks-bg);\n color: var(--oks-icon);\n border: 2px solid #fff;\n box-shadow: 0 4px 15px rgba(0,0,0,0.2);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n transition: 0.2s;\n padding: 0;\n}\n.oks-access-btn:hover {\n background: var(--oks-h-bg);\n color: var(--oks-h-icon);\n transform: scale(1.1);\n}\n.oks-access-btn svg {\n fill: currentColor;\n display: block;\n margin: 0 auto;\n width: 60%;\n height: 60%;\n}\n/* Porthole preset: a brushed-metal ring framing the standard glyph. The glyph\n itself is untouched (discoverability), only the button gets the frame.\n Hosts wanting a bespoke frame can target ::part(trigger) from light DOM. */\n.oks-access-btn[data-trigger-icon=\"porthole\"] {\n box-shadow: 0 0 0 4px #b0bec5, 0 0 0 7px #546e7a, 0 6px 18px rgba(0,0,0,0.3);\n}\n.oks-active-badge {\n position: absolute;\n top: -2px;\n right: -2px;\n width: 20px;\n height: 20px;\n background: #25D366;\n border-radius: 50%;\n border: 2px solid #fff;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n opacity: 0;\n pointer-events: none;\n}\n.oks-active-badge svg { width: 12px; height: 12px; }\n.oks-access-wrapper.has-active .oks-active-badge { opacity: 1; }\n\n.oks-access-panel {\n position: fixed;\n width: 340px;\n max-height: 90vh;\n background: #fff;\n border-radius: 20px;\n box-shadow: 0 20px 60px rgba(0,0,0,0.3);\n z-index: var(--oks-z);\n display: flex;\n flex-direction: column;\n opacity: 0;\n pointer-events: none;\n transition: 0.2s;\n border: 1px solid rgba(0,0,0,0.1);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n color: #333;\n}\n.oks-access-panel.is-open { opacity: 1; pointer-events: all; }\n.oks-access-header {\n padding: 15px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n.oks-access-header h3 { margin: 0; font-size: 18px; color: #000; }\n.oks-access-close {\n background: #f0f0f0;\n color: #333;\n border: 1px solid #ddd;\n width: 44px;\n height: 44px;\n border-radius: 50%;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: 0.2s;\n padding: 0;\n}\n.oks-access-close:hover { background: #e0e0e0; border-color: #ccc; }\n.oks-access-close svg { width: 24px; height: 24px; stroke-width: 2.5px; }\n.oks-access-content { padding: 0 20px 20px; overflow-y: auto; }\n.oks-access-title {\n margin: 10px 0 5px;\n font-size: 11px;\n font-weight: 800;\n text-transform: uppercase;\n color: #888;\n}\n.oks-access-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }\n.oks-access-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }\n.oks-preset {\n background: #f0f4f8;\n border: 2px solid transparent;\n border-radius: 8px;\n padding: 8px 4px;\n display: flex;\n flex-direction: column;\n align-items: center;\n cursor: pointer;\n font: inherit;\n color: #333;\n min-width: 0;\n transition: 0.15s;\n}\n.oks-preset:hover { background: #000; color: #fff; }\n.oks-preset:focus-visible { outline: 2px solid #000; outline-offset: 2px; }\n/* Click feedback: a 250 ms flip + slight squeeze. Pure transient \u2014 the\n button does not carry persistent active state because a preset is a\n trigger, not a mode. */\n.oks-preset.is-flashing {\n background: #000;\n color: #fff;\n transform: scale(0.96);\n}\n.oks-preset .oks-icon { font-size: 22px; margin-bottom: 4px; }\n.oks-preset .oks-icon svg { width: 20px; height: 20px; }\n.oks-preset .oks-label {\n font-size: 10px; font-weight: 700; text-transform: uppercase;\n text-align: center; line-height: 1.15;\n overflow-wrap: anywhere; word-break: break-word;\n /* Reserve room for 2 lines so 1-line labels (DISLEXIA, MOTOR) and\n 2-line labels (BAJA VISI\u00D3N, SIN DISTRAC.) end up the same height,\n centred. Without this the row looks ragged. */\n min-height: 2.3em;\n display: flex; align-items: center; justify-content: center;\n}\n.oks-access-opt {\n background: #f9f9f9;\n border: 2px solid #eee;\n border-radius: 10px;\n padding: 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n min-width: 0;\n min-height: 70px;\n color: #333;\n transition: 0.2s;\n font: inherit;\n}\n.oks-access-opt.full-width { grid-column: span 2; }\n.oks-access-opt.is-active {\n border-color: #000;\n background: #fff;\n box-shadow: 0 0 0 1px #000;\n}\n.oks-access-opt:hover { background: #000; color: #fff; border-color: #000; }\n.oks-icon { font-size: 28px; margin-bottom: 3px; display: block; line-height: 1; }\n.oks-icon svg { width: 24px; height: 24px; fill: currentColor; }\n.oks-label {\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n text-align: center;\n line-height: 1.2;\n overflow-wrap: anywhere;\n word-break: break-word;\n}\n.oks-levels { display: flex; gap: 3px; height: 5px; width: 50%; margin-top: 5px; }\n.oks-levels span { flex: 1; background: #ddd; border-radius: 3px; }\n.oks-access-opt[data-level=\"1\"] .oks-levels span:nth-child(1),\n.oks-access-opt[data-level=\"2\"] .oks-levels span:nth-child(-n+2),\n.oks-access-opt[data-level=\"3\"] .oks-levels span:nth-child(-n+3),\n.oks-access-opt[data-level=\"4\"] .oks-levels span:nth-child(-n+4) { background: #000; }\n.oks-access-opt:hover .oks-levels span { background: #555; }\n.oks-access-footer {\n padding: 12px 20px;\n border-top: 1px solid #eee;\n text-align: center;\n}\n.oks-access-reset {\n width: 100%;\n padding: 8px;\n border: 2px solid #000;\n color: #000;\n background: transparent;\n font-weight: 700;\n cursor: pointer;\n border-radius: 6px;\n font-size: 12px;\n}\n.oks-access-reset:hover { background: #000; color: #fff; }\n.oks-access-branding { margin-top: 12px; font-size: 12px; color: #000; font-weight: 700; }\n.oks-access-branding a { color: #000; text-decoration: none; border-bottom: 1px dotted #000; }\n\n@media (prefers-reduced-motion: reduce) {\n .oks-access-btn, .oks-access-panel, .oks-access-close,\n .oks-preset, .oks-access-opt { transition: none; }\n .oks-access-btn:hover { transform: scale(1); }\n .oks-preset.is-flashing { transform: none; }\n}\n@media (max-width: 768px) {\n .oks-access-panel {\n width: 100%; height: 100%; max-height: 100%;\n top: 0; left: 0; right: 0; bottom: 0;\n border-radius: 0;\n }\n .oks-access-opt { min-height: 72px; padding: 10px 8px; }\n .oks-icon svg { width: 26px; height: 26px; }\n .oks-label { font-size: 12px; line-height: 1.25; }\n .oks-access-grid { gap: 8px; }\n .oks-access-content { padding: 0 14px 20px; }\n .oks-access-title { font-size: 11px; margin: 10px 0 4px; }\n /* Cursor grande no aplica en t\u00E1ctil: oculto y dejo el \u00FAltimo bot\u00F3n\n de la secci\u00F3n ocupando 2 columnas para no romper la grilla par. */\n .oks-access-opt[data-class=\"oks-big-cursor\"] { display: none; }\n .oks-access-opt[data-class=\"oks-a11y-focus\"] { grid-column: span 2; }\n .oks-access-reset { padding: 14px; font-size: 14px; }\n}\n";
159
- declare const EFFECT_CSS = "\nhtml.oks-colorblind-1 { filter: url('#oks-filter-protanopia'); }\nhtml.oks-colorblind-2 { filter: url('#oks-filter-deuteranopia'); }\nhtml.oks-colorblind-3 { filter: url('#oks-filter-tritanopia'); }\n\n/* Text-size levels.\n Applied to <html> via :has(), not to <body>. rem is anchored to the root\n element, so a site whose CSS sizes things in rem (most modern Astro / Tailwind\n builds) needs the root font-size to change for the scale to take effect.\n A previous version applied this to body with %, which only moved descendants\n that inherited font-size from body \u2014 anything sized in rem stayed locked to\n the 16px default of <html>. An even earlier version used the universal\n selector with em and compounded the factor at every nesting level.\n :has(body.oks-zoom-N) is the right anchor: one change at the root, rem\n descendants scale exactly once. Hard-coded px is intentionally left alone;\n browser zoom covers that case. */\nhtml:has(body.oks-zoom-1) { font-size: 110% !important; }\nhtml:has(body.oks-zoom-2) { font-size: 120% !important; }\nhtml:has(body.oks-zoom-3) { font-size: 135% !important; }\nhtml:has(body.oks-zoom-4) { font-size: 150% !important; }\n\nbody.oks-lh-1 * { line-height: 1.6 !important; }\nbody.oks-lh-2 * { line-height: 1.9 !important; }\nbody.oks-lh-3 * { line-height: 2.2 !important; }\n\nbody.oks-a11y-font { font-family: Arial, sans-serif !important; }\n\nbody.oks-dyslexia * {\n font-family: 'Comic Sans MS', 'Verdana', sans-serif !important;\n letter-spacing: 0.05em !important;\n word-spacing: 0.1em !important;\n line-height: 1.6 !important;\n}\n\nbody.oks-a11y-hide img { opacity: 0 !important; visibility: hidden !important; }\nbody.oks-a11y-links a { text-decoration: underline !important; background: #ff0 !important; color: #000 !important; }\n\nbody.oks-align-1 * { text-align: left !important; }\nbody.oks-align-2 * { text-align: center !important; }\nbody.oks-align-3 * { text-align: right !important; }\n\nbody.oks-a11y-pause * { animation: none !important; transition: none !important; }\n\nbody.oks-ls-1 * { letter-spacing: 0.05em !important; }\nbody.oks-ls-2 * { letter-spacing: 0.10em !important; }\nbody.oks-ls-3 * { letter-spacing: 0.16em !important; }\n\nbody.oks-a11y-focus a:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-focus button:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-focus input,\nbody.oks-a11y-focus select,\nbody.oks-a11y-focus textarea {\n outline: 2px dashed rgba(0, 95, 204, 0.45) !important;\n outline-offset: 2px !important;\n}\nbody.oks-a11y-focus *:focus-visible {\n outline: 3px solid #005fcc !important;\n outline-offset: 3px !important;\n box-shadow: 0 0 0 6px rgba(0, 95, 204, 0.25) !important;\n}\n\nbody.oks-a11y-contrast.oks-a11y-focus *:focus,\nbody.oks-a11y-contrast.oks-a11y-focus *:focus-visible {\n outline-color: #0ff !important;\n box-shadow: 0 0 0 6px rgba(0, 255, 255, 0.3) !important;\n}\n\n@media (pointer: fine) {\n body.oks-big-cursor, body.oks-big-cursor a, body.oks-big-cursor button {\n cursor: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24'%3E%3Cpath d='M4 2L4 18L8 14L11 21L14 19.5L11 13L15.5 13Z' stroke='white' stroke-width='4' stroke-linejoin='round' fill='white'/%3E%3Cpath d='M4 2L4 18L8 14L11 21L14 19.5L11 13L15.5 13Z' fill='black'/%3E%3C/svg%3E\") 8 4, auto !important;\n }\n}\n\nbody.oks-a11y-contrast,\nbody.oks-a11y-contrast *:not(oksigenia-access-panel):not(oksigenia-access-panel *) {\n background-color: #000 !important;\n color: #ff0 !important;\n border-color: #ff0 !important;\n text-shadow: none !important;\n box-shadow: none !important;\n}\nbody.oks-a11y-contrast img { filter: grayscale(100%) contrast(120%) !important; }\nbody.oks-a11y-contrast a:not(oksigenia-access-panel *) { color: #0ff !important; text-decoration: underline !important; }\n\n/* High-contrast applies background:#000 to every descendant of body to flip\n the page to inverted colours. That selector also catches our own overlays\n (.oks-reading-guide, .oks-overlay-effect), which would then paint a solid\n black band on top of the text and defeat their purpose. Restore the\n overlay-specific values here so they keep working in high-contrast mode. */\nbody.oks-a11y-contrast .oks-reading-guide {\n background-color: rgba(255, 255, 0, 0.25) !important;\n border-top-color: #ff0 !important;\n border-bottom-color: #ff0 !important;\n}\nbody.oks-a11y-contrast .oks-overlay-effect {\n background-color: transparent !important;\n}\n\n.oks-overlay-effect {\n position: fixed; top: 0; left: 0;\n width: 100%; height: 100%;\n pointer-events: none;\n z-index: 999990;\n display: none;\n backdrop-filter: grayscale(100%);\n}\n.oks-overlay-effect.is-active { display: block; }\n\n.oks-reading-guide {\n position: fixed; left: 0;\n width: 100%; height: 50px;\n background: rgba(255, 255, 0, 0.2);\n border-top: 3px solid red;\n border-bottom: 3px solid red;\n pointer-events: none;\n z-index: 2147483647;\n display: none;\n transform: translateY(-50%);\n}\nbody.oks-a11y-guide .oks-reading-guide { display: block; }\n\n/* Reading mask: dark overlay leaving a horizontal band lit around the cursor.\n --oks-mask-y is updated from JS on mousemove; band is \u00B190px around it. */\n.oks-reading-mask {\n position: fixed; top: 0; left: 0;\n width: 100vw; height: 100vh;\n pointer-events: none;\n z-index: 2147483646;\n background: rgba(0, 0, 0, 0.75);\n display: none;\n clip-path: polygon(\n 0 0, 100% 0,\n 100% calc(var(--oks-mask-y, 50vh) - 90px),\n 0 calc(var(--oks-mask-y, 50vh) - 90px),\n 0 calc(var(--oks-mask-y, 50vh) + 90px),\n 100% calc(var(--oks-mask-y, 50vh) + 90px),\n 100% 100%, 0 100%\n );\n}\nbody.oks-a11y-mask .oks-reading-mask { display: block; }\nbody.oks-a11y-contrast .oks-reading-mask { background-color: rgba(0, 0, 0, 0.85) !important; }\n\n/* Big targets: bump interactive hit-areas to WCAG 2.5.5 (44\u00D744 minimum).\n Only adjusts padding + min-* \u2014 never display, so layouts that rely on\n inline flow or grid placement survive. Exempts our own shadow-DOM host. */\nbody.oks-a11y-bigtargets a:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets button:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets [role=\"button\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets input[type=\"checkbox\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets input[type=\"radio\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets summary:not(oksigenia-access-panel):not(oksigenia-access-panel *) {\n min-height: 44px !important;\n min-width: 44px !important;\n padding: 8px 12px !important;\n box-sizing: border-box !important;\n}\n";
170
+ declare const PANEL_CSS = "\n:host {\n --oks-btn-size: 55px;\n --oks-bg: #000;\n --oks-icon: #fff;\n --oks-h-bg: #fff;\n --oks-h-icon: #000;\n --oks-z: 9999999;\n}\n.oks-access-wrapper {\n position: fixed;\n z-index: var(--oks-z);\n line-height: 1;\n}\n.oks-access-btn {\n width: var(--oks-btn-size);\n height: var(--oks-btn-size);\n border-radius: 50%;\n background: var(--oks-bg);\n color: var(--oks-icon);\n border: 2px solid #fff;\n box-shadow: 0 4px 15px rgba(0,0,0,0.2);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n transition: 0.2s;\n padding: 0;\n}\n.oks-access-btn:hover {\n background: var(--oks-h-bg);\n color: var(--oks-h-icon);\n transform: scale(1.1);\n}\n.oks-access-btn svg {\n fill: currentColor;\n display: block;\n margin: 0 auto;\n width: 60%;\n height: 60%;\n}\n/* Porthole preset: a brushed-metal ring framing the standard glyph. The glyph\n itself is untouched (discoverability), only the button gets the frame.\n Hosts wanting a bespoke frame can target ::part(trigger) from light DOM. */\n.oks-access-btn[data-trigger-icon=\"porthole\"] {\n box-shadow: 0 0 0 4px #b0bec5, 0 0 0 7px #546e7a, 0 6px 18px rgba(0,0,0,0.3);\n}\n.oks-active-badge {\n position: absolute;\n top: -2px;\n right: -2px;\n width: 20px;\n height: 20px;\n background: #25D366;\n border-radius: 50%;\n border: 2px solid #fff;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n opacity: 0;\n pointer-events: none;\n}\n.oks-active-badge svg { width: 12px; height: 12px; }\n.oks-access-wrapper.has-active .oks-active-badge { opacity: 1; }\n\n.oks-access-panel {\n position: fixed;\n width: 340px;\n max-height: 90vh;\n background: #fff;\n border-radius: 20px;\n box-shadow: 0 20px 60px rgba(0,0,0,0.3);\n z-index: var(--oks-z);\n display: flex;\n flex-direction: column;\n opacity: 0;\n pointer-events: none;\n transition: 0.2s;\n border: 1px solid rgba(0,0,0,0.1);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n color: #333;\n}\n.oks-access-panel.is-open { opacity: 1; pointer-events: all; }\n.oks-access-header {\n padding: 15px 20px;\n border-bottom: 1px solid #eee;\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n.oks-access-header h3 { margin: 0; font-size: 18px; color: #000; }\n.oks-access-close {\n background: #f0f0f0;\n color: #333;\n border: 1px solid #ddd;\n width: 44px;\n height: 44px;\n border-radius: 50%;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: 0.2s;\n padding: 0;\n}\n.oks-access-close:hover { background: #e0e0e0; border-color: #ccc; }\n.oks-access-close svg { width: 24px; height: 24px; stroke-width: 2.5px; }\n.oks-access-content { padding: 0 20px 20px; overflow-y: auto; }\n.oks-access-title {\n margin: 10px 0 5px;\n font-size: 11px;\n font-weight: 800;\n text-transform: uppercase;\n color: #888;\n}\n/* Screen-reader-only: keeps the category headings in the accessibility tree\n (flat scoped layout) while taking them out of the grid flow so they don't\n force a visual row break or occupy a cell. */\n.oks-sr-only {\n position: absolute !important;\n width: 1px; height: 1px;\n margin: 0; padding: 0;\n overflow: hidden;\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n}\n.oks-access-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }\n.oks-access-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }\n.oks-preset {\n background: #f0f4f8;\n border: 2px solid transparent;\n border-radius: 8px;\n padding: 8px 4px;\n display: flex;\n flex-direction: column;\n align-items: center;\n cursor: pointer;\n font: inherit;\n color: #333;\n min-width: 0;\n transition: 0.15s;\n}\n.oks-preset:hover { background: #000; color: #fff; }\n.oks-preset:focus-visible { outline: 2px solid #000; outline-offset: 2px; }\n/* Click feedback: a 250 ms flip + slight squeeze. Pure transient \u2014 the\n button does not carry persistent active state because a preset is a\n trigger, not a mode. */\n.oks-preset.is-flashing {\n background: #000;\n color: #fff;\n transform: scale(0.96);\n}\n.oks-preset .oks-icon { font-size: 22px; margin-bottom: 4px; }\n.oks-preset .oks-icon svg { width: 20px; height: 20px; }\n.oks-preset .oks-label {\n font-size: 10px; font-weight: 700; text-transform: uppercase;\n text-align: center; line-height: 1.15;\n overflow-wrap: anywhere; word-break: break-word;\n /* Reserve room for 2 lines so 1-line labels (DISLEXIA, MOTOR) and\n 2-line labels (BAJA VISI\u00D3N, SIN DISTRAC.) end up the same height,\n centred. Without this the row looks ragged. */\n min-height: 2.3em;\n display: flex; align-items: center; justify-content: center;\n}\n.oks-access-opt {\n background: #f9f9f9;\n border: 2px solid #eee;\n border-radius: 10px;\n padding: 10px;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n min-width: 0;\n min-height: 70px;\n color: #333;\n transition: 0.2s;\n font: inherit;\n}\n.oks-access-opt.full-width { grid-column: span 2; }\n.oks-access-opt.is-active {\n border-color: #000;\n background: #fff;\n box-shadow: 0 0 0 1px #000;\n}\n.oks-access-opt:hover { background: #000; color: #fff; border-color: #000; }\n.oks-icon { font-size: 28px; margin-bottom: 3px; display: block; line-height: 1; }\n.oks-icon svg { width: 24px; height: 24px; fill: currentColor; }\n.oks-label {\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n text-align: center;\n line-height: 1.2;\n overflow-wrap: anywhere;\n word-break: break-word;\n}\n.oks-levels { display: flex; gap: 3px; height: 5px; width: 50%; margin-top: 5px; }\n.oks-levels span { flex: 1; background: #ddd; border-radius: 3px; }\n.oks-access-opt[data-level=\"1\"] .oks-levels span:nth-child(1),\n.oks-access-opt[data-level=\"2\"] .oks-levels span:nth-child(-n+2),\n.oks-access-opt[data-level=\"3\"] .oks-levels span:nth-child(-n+3),\n.oks-access-opt[data-level=\"4\"] .oks-levels span:nth-child(-n+4) { background: #000; }\n.oks-access-opt:hover .oks-levels span { background: #555; }\n.oks-access-footer {\n padding: 12px 20px;\n border-top: 1px solid #eee;\n text-align: center;\n}\n.oks-access-reset {\n width: 100%;\n padding: 8px;\n border: 2px solid #000;\n color: #000;\n background: transparent;\n font-weight: 700;\n cursor: pointer;\n border-radius: 6px;\n font-size: 12px;\n}\n.oks-access-reset:hover { background: #000; color: #fff; }\n.oks-access-branding { margin-top: 12px; font-size: 12px; color: #000; font-weight: 700; }\n.oks-access-branding a { color: #000; text-decoration: none; border-bottom: 1px dotted #000; }\n\n@media (prefers-reduced-motion: reduce) {\n .oks-access-btn, .oks-access-panel, .oks-access-close,\n .oks-preset, .oks-access-opt { transition: none; }\n .oks-access-btn:hover { transform: scale(1); }\n .oks-preset.is-flashing { transform: none; }\n}\n@media (max-width: 768px) {\n .oks-access-panel {\n width: 100%; height: 100%; max-height: 100%;\n top: 0; left: 0; right: 0; bottom: 0;\n border-radius: 0;\n }\n .oks-access-opt { min-height: 72px; padding: 10px 8px; }\n .oks-icon svg { width: 26px; height: 26px; }\n .oks-label { font-size: 12px; line-height: 1.25; }\n .oks-access-grid { gap: 8px; }\n .oks-access-content { padding: 0 14px 20px; }\n .oks-access-title { font-size: 11px; margin: 10px 0 4px; }\n /* Cursor grande no aplica en t\u00E1ctil: oculto y dejo el \u00FAltimo bot\u00F3n\n de la secci\u00F3n ocupando 2 columnas para no romper la grilla par. */\n .oks-access-opt[data-class=\"oks-big-cursor\"] { display: none; }\n .oks-access-opt[data-class=\"oks-a11y-focus\"] { grid-column: span 2; }\n .oks-access-reset { padding: 14px; font-size: 14px; }\n}\n";
171
+ declare const EFFECT_CSS = "\nhtml.oks-colorblind-1 { filter: url('#oks-filter-protanopia'); }\nhtml.oks-colorblind-2 { filter: url('#oks-filter-deuteranopia'); }\nhtml.oks-colorblind-3 { filter: url('#oks-filter-tritanopia'); }\n\n/* Text-size levels.\n Applied to <html> via :has(), not to <body>. rem is anchored to the root\n element, so a site whose CSS sizes things in rem (most modern Astro / Tailwind\n builds) needs the root font-size to change for the scale to take effect.\n A previous version applied this to body with %, which only moved descendants\n that inherited font-size from body \u2014 anything sized in rem stayed locked to\n the 16px default of <html>. An even earlier version used the universal\n selector with em and compounded the factor at every nesting level.\n :has(body.oks-zoom-N) is the right anchor: one change at the root, rem\n descendants scale exactly once. Hard-coded px is intentionally left alone;\n browser zoom covers that case. */\nhtml:has(body.oks-zoom-1) { font-size: 110% !important; }\nhtml:has(body.oks-zoom-2) { font-size: 120% !important; }\nhtml:has(body.oks-zoom-3) { font-size: 135% !important; }\nhtml:has(body.oks-zoom-4) { font-size: 150% !important; }\n\nbody.oks-lh-1 * { line-height: 1.6 !important; }\nbody.oks-lh-2 * { line-height: 1.9 !important; }\nbody.oks-lh-3 * { line-height: 2.2 !important; }\n\nbody.oks-a11y-font { font-family: Arial, sans-serif !important; }\n\nbody.oks-dyslexia * {\n font-family: 'Comic Sans MS', 'Verdana', sans-serif !important;\n letter-spacing: 0.05em !important;\n word-spacing: 0.1em !important;\n line-height: 1.6 !important;\n}\n\nbody.oks-a11y-hide img { opacity: 0 !important; visibility: hidden !important; }\nbody.oks-a11y-links a { text-decoration: underline !important; background: #ff0 !important; color: #000 !important; }\n\nbody.oks-align-1 * { text-align: left !important; }\nbody.oks-align-2 * { text-align: center !important; }\nbody.oks-align-3 * { text-align: right !important; }\n\nbody.oks-a11y-pause * { animation: none !important; transition: none !important; }\n\nbody.oks-ls-1 * { letter-spacing: 0.05em !important; }\nbody.oks-ls-2 * { letter-spacing: 0.10em !important; }\nbody.oks-ls-3 * { letter-spacing: 0.16em !important; }\n\n/* Focus highlight colour is themeable via --oks-focus-color (default #005fcc):\n set it on :root for a dark theme so the highlight stays visible. The\n persistent \"all interactive\" dashed derives a 45% tint from it; the glow can\n be overridden on its own with --oks-focus-glow. */\nbody.oks-a11y-focus a:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-focus button:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-focus input,\nbody.oks-a11y-focus select,\nbody.oks-a11y-focus textarea {\n outline: 2px dashed color-mix(in srgb, var(--oks-focus-color, #005fcc) 45%, transparent) !important;\n outline-offset: 2px !important;\n}\nbody.oks-a11y-focus *:focus-visible {\n outline: 3px solid var(--oks-focus-color, #005fcc) !important;\n outline-offset: 3px !important;\n box-shadow: 0 0 0 6px var(--oks-focus-glow, color-mix(in srgb, var(--oks-focus-color, #005fcc) 25%, transparent)) !important;\n}\n\nbody.oks-a11y-contrast.oks-a11y-focus *:focus,\nbody.oks-a11y-contrast.oks-a11y-focus *:focus-visible {\n outline-color: #0ff !important;\n box-shadow: 0 0 0 6px rgba(0, 255, 255, 0.3) !important;\n}\n\n@media (pointer: fine) {\n body.oks-big-cursor, body.oks-big-cursor a, body.oks-big-cursor button {\n cursor: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24'%3E%3Cpath d='M4 2L4 18L8 14L11 21L14 19.5L11 13L15.5 13Z' stroke='white' stroke-width='4' stroke-linejoin='round' fill='white'/%3E%3Cpath d='M4 2L4 18L8 14L11 21L14 19.5L11 13L15.5 13Z' fill='black'/%3E%3C/svg%3E\") 8 4, auto !important;\n }\n}\n\nbody.oks-a11y-contrast,\nbody.oks-a11y-contrast *:not(oksigenia-access-panel):not(oksigenia-access-panel *) {\n background-color: #000 !important;\n color: #ff0 !important;\n border-color: #ff0 !important;\n text-shadow: none !important;\n box-shadow: none !important;\n}\nbody.oks-a11y-contrast img { filter: grayscale(100%) contrast(120%) !important; }\nbody.oks-a11y-contrast a:not(oksigenia-access-panel *) { color: #0ff !important; text-decoration: underline !important; }\n\n/* High-contrast applies background:#000 to every descendant of body to flip\n the page to inverted colours. That selector also catches our own overlays\n (.oks-reading-guide, .oks-overlay-effect), which would then paint a solid\n black band on top of the text and defeat their purpose. Restore the\n overlay-specific values here so they keep working in high-contrast mode. */\nbody.oks-a11y-contrast .oks-reading-guide {\n background-color: rgba(255, 255, 0, 0.25) !important;\n border-top-color: #ff0 !important;\n border-bottom-color: #ff0 !important;\n}\nbody.oks-a11y-contrast .oks-overlay-effect {\n background-color: transparent !important;\n}\n\n.oks-overlay-effect {\n position: fixed; top: 0; left: 0;\n width: 100%; height: 100%;\n pointer-events: none;\n z-index: 999990;\n display: none;\n backdrop-filter: grayscale(100%);\n}\n.oks-overlay-effect.is-active { display: block; }\n\n.oks-reading-guide {\n position: fixed; left: 0;\n width: 100%; height: 50px;\n background: rgba(255, 255, 0, 0.2);\n border-top: 3px solid red;\n border-bottom: 3px solid red;\n pointer-events: none;\n z-index: 2147483647;\n display: none;\n transform: translateY(-50%);\n}\nbody.oks-a11y-guide .oks-reading-guide { display: block; }\n\n/* Reading mask: dark overlay leaving a horizontal band lit around the cursor.\n --oks-mask-y is updated from JS on mousemove; band is \u00B190px around it. */\n.oks-reading-mask {\n position: fixed; top: 0; left: 0;\n width: 100vw; height: 100vh;\n pointer-events: none;\n z-index: 2147483646;\n background: rgba(0, 0, 0, 0.75);\n display: none;\n clip-path: polygon(\n 0 0, 100% 0,\n 100% calc(var(--oks-mask-y, 50vh) - 90px),\n 0 calc(var(--oks-mask-y, 50vh) - 90px),\n 0 calc(var(--oks-mask-y, 50vh) + 90px),\n 100% calc(var(--oks-mask-y, 50vh) + 90px),\n 100% 100%, 0 100%\n );\n}\nbody.oks-a11y-mask .oks-reading-mask { display: block; }\nbody.oks-a11y-contrast .oks-reading-mask { background-color: rgba(0, 0, 0, 0.85) !important; }\n\n/* Big targets: bump interactive hit-areas to WCAG 2.5.5 (44\u00D744 minimum).\n Only adjusts padding + min-* \u2014 never display, so layouts that rely on\n inline flow or grid placement survive. Exempts our own shadow-DOM host. */\nbody.oks-a11y-bigtargets a:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets button:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets [role=\"button\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets input[type=\"checkbox\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets input[type=\"radio\"]:not(oksigenia-access-panel):not(oksigenia-access-panel *),\nbody.oks-a11y-bigtargets summary:not(oksigenia-access-panel):not(oksigenia-access-panel *) {\n min-height: 44px !important;\n min-width: 44px !important;\n padding: 8px 12px !important;\n box-sizing: border-box !important;\n}\n";
160
172
  /**
161
173
  * Scoped-mode effect CSS (#scope). Emits only the controls that can be confined
162
174
  * to a container by selector — the same rules as EFFECT_CSS but anchored to the
@@ -175,4 +187,4 @@ declare const EFFECT_CSS = "\nhtml.oks-colorblind-1 { filter: url('#oks-filter-p
175
187
  */
176
188
  declare function scopedEffectCss(s: string): string;
177
189
 
178
- export { ALL_CONTROLS, type BehaviorOptions, COLORBLIND_FILTERS_SVG, type ControlId, DEFAULT_STATE, EFFECT_CSS, type LocaleCode, NON_SCOPABLE_CONTROLS, PANEL_CSS, PRESETS, PRESET_IDS, type PanelController, type PanelState, type Position, type PresetId, type Translation, type TriggerIcon, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, resolveEnabledControls, saveState, scopedControls, scopedEffectCss, supportedLocales };
190
+ export { ALL_CONTROLS, type BehaviorOptions, COLORBLIND_FILTERS_SVG, type ControlId, DEFAULT_STATE, EFFECT_CSS, GLOBAL_OFFERABLE_CONTROLS, type LocaleCode, NON_SCOPABLE_CONTROLS, PANEL_CSS, PRESETS, PRESET_IDS, type PanelController, type PanelState, type Position, type PresetId, type Translation, type TriggerIcon, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, resolveEnabledControls, saveState, scopedControls, scopedEffectCss, supportedLocales };
package/dist/index.js CHANGED
@@ -110,12 +110,11 @@ function resolveEnabledControls(controlsAttr, excludeAttr) {
110
110
  }
111
111
  return enabled;
112
112
  }
113
- var NON_SCOPABLE_CONTROLS = [
114
- "grayscale",
115
- "reading-guide",
116
- "reading-mask",
113
+ var NON_SCOPABLE_CONTROLS = ["grayscale", "colorblind"];
114
+ var GLOBAL_OFFERABLE_CONTROLS = [
117
115
  "big-cursor",
118
- "colorblind"
116
+ "reading-guide",
117
+ "reading-mask"
119
118
  ];
120
119
  function scopedControls(enabled) {
121
120
  const out = new Set(enabled);
@@ -145,6 +144,7 @@ function buildPanelHtml(opts) {
145
144
  const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
146
145
  const showTrigger = opts.showTrigger ?? true;
147
146
  const showPresets = opts.showPresets ?? true;
147
+ const flatLayout = opts.flatLayout ?? false;
148
148
  const trig = TRIGGER_ICONS[triggerIcon];
149
149
  const multi = (id, prefix, levels, label, icon, full = false) => {
150
150
  if (!enabled.has(id)) return "";
@@ -179,44 +179,61 @@ function buildPanelHtml(opts) {
179
179
  };
180
180
  const presetButtons = showPresets ? PRESET_IDS.filter((id) => presetIsAvailable(id, enabled)).map((id) => preset(id, presetLabels[id])) : [];
181
181
  const presetsBlock = presetButtons.length ? `<h4 class="oks-access-title">${escapeHtml(t.presets)}</h4><div class="oks-access-presets">${presetButtons.join("")}</div>` : "";
182
- const triggerBlock = showTrigger ? `
183
- <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
184
- <button class="oks-access-btn" id="oks-trigger" part="trigger" data-trigger-icon="${triggerIcon}" aria-label="${escapeAttr(t.title)}" aria-expanded="false" aria-controls="oks-panel" type="button">
185
- ${trig}
186
- </button>
187
- <span class="oks-active-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg></span>
188
- </div>` : "";
189
- return `${triggerBlock}
190
- <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
191
- <div class="oks-access-header">
192
- <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
193
- <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
194
- </div>
195
- <div class="oks-access-content">
196
- ${presetsBlock}
197
- ${section(t.txt, [
182
+ const textBtns = [
198
183
  multi("text-size", "oks-zoom", 4, t.size, ICON_TXT),
199
184
  multi("line-height", "oks-lh", 3, t.lh, ICON_LH),
200
185
  multi("text-align", "oks-align", 3, t.align, ICON_ALIGN),
201
186
  toggle("readable-font", "oks-a11y-font", t.font, ICON_FONT),
202
187
  toggle("dyslexia-font", "oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
203
188
  multi("letter-spacing", "oks-ls", 3, t.ls, ICON_LS)
204
- ])}
205
- ${section(t.vis, [
189
+ ];
190
+ const visBtns = [
206
191
  toggle("contrast", "oks-a11y-contrast", t.contrast, ICON_CONTRAST),
207
192
  overlay("grayscale", "oks-overlay-gray", t.gray, ICON_GRAY),
208
193
  toggle("hide-images", "oks-a11y-hide", t.hide, ICON_HIDE),
209
194
  toggle("highlight-links", "oks-a11y-links", t.links, ICON_LINK),
210
- multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, true)
211
- ])}
212
- ${section(t.ori, [
195
+ multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, !flatLayout)
196
+ ];
197
+ const oriBtns = [
213
198
  simple("reading-guide", "guide", t.guide, ICON_GUIDE),
214
199
  simple("reading-mask", "mask", t.mask, ICON_MASK),
215
200
  toggle("big-cursor", "oks-big-cursor", t.cursor, ICON_CURSOR),
216
201
  toggle("big-targets", "oks-a11y-bigtargets", t.targets, ICON_TARGETS),
217
202
  toggle("pause-anim", "oks-a11y-pause", t.pause, ICON_PAUSE),
218
203
  toggle("focus", "oks-a11y-focus", t.focus, ICON_FOCUS)
219
- ])}
204
+ ];
205
+ const addFullWidth = (btn) => btn.replace('class="oks-access-opt', 'class="oks-access-opt full-width');
206
+ const flatContent = () => {
207
+ const groups = [
208
+ { title: t.txt, items: textBtns.filter(Boolean) },
209
+ { title: t.vis, items: visBtns.filter(Boolean) },
210
+ { title: t.ori, items: oriBtns.filter(Boolean) }
211
+ ].filter((g) => g.items.length > 0);
212
+ const total = groups.reduce((n, g) => n + g.items.length, 0);
213
+ if (total % 2 === 1 && groups.length > 0) {
214
+ const last = groups[groups.length - 1];
215
+ last.items[last.items.length - 1] = addFullWidth(last.items[last.items.length - 1]);
216
+ }
217
+ const inner = groups.map((g) => `<h4 class="oks-access-title oks-sr-only">${escapeHtml(g.title)}</h4>${g.items.join("")}`).join("");
218
+ return `<div class="oks-access-grid oks-flat">${inner}</div>`;
219
+ };
220
+ const controlsHtml = flatLayout ? flatContent() : section(t.txt, textBtns) + section(t.vis, visBtns) + section(t.ori, oriBtns);
221
+ const triggerBlock = showTrigger ? `
222
+ <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
223
+ <button class="oks-access-btn" id="oks-trigger" part="trigger" data-trigger-icon="${triggerIcon}" aria-label="${escapeAttr(t.title)}" aria-expanded="false" aria-controls="oks-panel" type="button">
224
+ ${trig}
225
+ </button>
226
+ <span class="oks-active-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg></span>
227
+ </div>` : "";
228
+ return `${triggerBlock}
229
+ <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
230
+ <div class="oks-access-header">
231
+ <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
232
+ <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
233
+ </div>
234
+ <div class="oks-access-content">
235
+ ${presetsBlock}
236
+ ${controlsHtml}
220
237
  </div>
221
238
  <div class="oks-access-footer">
222
239
  <button class="oks-access-reset" id="oks-reset" type="button">${escapeHtml(t.reset)}</button>
@@ -374,8 +391,69 @@ function bindPanelBehavior(root, opts = {}) {
374
391
  return noopController();
375
392
  }
376
393
  let state = loadState(storageKey);
394
+ const scoped = opts.scopeEl !== void 0;
395
+ const GLOBAL_KEY = "oksiac::global";
396
+ const GLOBAL_MAP = [
397
+ { flag: "bigCursor", cls: "oks-big-cursor", match: (b) => b.getAttribute("data-class") === "oks-big-cursor" },
398
+ { flag: "readingGuide", cls: "oks-a11y-guide", match: (b) => b.getAttribute("data-action") === "guide" },
399
+ { flag: "readingMask", cls: "oks-a11y-mask", match: (b) => b.getAttribute("data-action") === "mask" }
400
+ ];
401
+ const globalOfferableOf = (b) => GLOBAL_MAP.find((x) => x.match(b));
402
+ const loadGlobal = () => {
403
+ try {
404
+ const r = JSON.parse(localStorage.getItem(GLOBAL_KEY) ?? "{}");
405
+ return { bigCursor: !!r.bigCursor, readingGuide: !!r.readingGuide, readingMask: !!r.readingMask };
406
+ } catch {
407
+ return { bigCursor: false, readingGuide: false, readingMask: false };
408
+ }
409
+ };
410
+ const saveGlobal = (g) => {
411
+ try {
412
+ if (g.bigCursor || g.readingGuide || g.readingMask) localStorage.setItem(GLOBAL_KEY, JSON.stringify(g));
413
+ else localStorage.removeItem(GLOBAL_KEY);
414
+ } catch {
415
+ }
416
+ };
417
+ const applyGlobal = (g) => {
418
+ document.body.classList.toggle("oks-big-cursor", g.bigCursor);
419
+ document.body.classList.toggle("oks-a11y-guide", g.readingGuide);
420
+ document.body.classList.toggle("oks-a11y-mask", g.readingMask);
421
+ for (const btn of opts$) {
422
+ const m = globalOfferableOf(btn);
423
+ if (!m) continue;
424
+ btn.classList.toggle("is-active", g[m.flag]);
425
+ btn.setAttribute("aria-pressed", g[m.flag] ? "true" : "false");
426
+ }
427
+ };
428
+ const refreshGlobal = () => {
429
+ if (scoped) applyGlobal(loadGlobal());
430
+ };
431
+ const syncGlobalFromState = () => {
432
+ if (!scoped) return;
433
+ const g = loadGlobal();
434
+ let changed = false;
435
+ if (state.bigCursor) {
436
+ g.bigCursor = true;
437
+ state.bigCursor = false;
438
+ changed = true;
439
+ }
440
+ if (state.readingGuide) {
441
+ g.readingGuide = true;
442
+ state.readingGuide = false;
443
+ changed = true;
444
+ }
445
+ if (state.readingMask) {
446
+ g.readingMask = true;
447
+ state.readingMask = false;
448
+ changed = true;
449
+ }
450
+ if (changed) {
451
+ saveGlobal(g);
452
+ applyGlobal(g);
453
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
454
+ }
455
+ };
377
456
  function applyState() {
378
- const scoped = opts.scopeEl !== void 0;
379
457
  const fxRoot = scoped ? opts.scopeEl ?? null : document.body;
380
458
  const rootEl = document.documentElement;
381
459
  if (fxRoot) {
@@ -410,6 +488,7 @@ function bindPanelBehavior(root, opts = {}) {
410
488
  }
411
489
  function syncButtonsFromState() {
412
490
  for (const btn of opts$) {
491
+ if (scoped && globalOfferableOf(btn)) continue;
413
492
  const action = btn.getAttribute("data-action");
414
493
  if (action === "multi" || action === "colorblind") {
415
494
  const prefix = btn.getAttribute("data-prefix") ?? "";
@@ -444,6 +523,17 @@ function bindPanelBehavior(root, opts = {}) {
444
523
  }
445
524
  const onOptClick = (e) => {
446
525
  const btn = e.currentTarget;
526
+ if (scoped) {
527
+ const go = globalOfferableOf(btn);
528
+ if (go) {
529
+ const g = loadGlobal();
530
+ g[go.flag] = !g[go.flag];
531
+ saveGlobal(g);
532
+ applyGlobal(g);
533
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
534
+ return;
535
+ }
536
+ }
447
537
  const action = btn.getAttribute("data-action");
448
538
  if (action === "multi") {
449
539
  const prefix = btn.getAttribute("data-prefix") ?? "";
@@ -472,6 +562,7 @@ function bindPanelBehavior(root, opts = {}) {
472
562
  if (id && PRESETS[id]) Object.assign(state, filterPresetForEnabled(id, enabled));
473
563
  btn.classList.add("is-flashing");
474
564
  setTimeout(() => btn.classList.remove("is-flashing"), 250);
565
+ syncGlobalFromState();
475
566
  }
476
567
  applyState();
477
568
  saveState(storageKey, state);
@@ -480,6 +571,12 @@ function bindPanelBehavior(root, opts = {}) {
480
571
  state = { ...DEFAULT_STATE };
481
572
  applyState();
482
573
  saveState(storageKey, state);
574
+ if (scoped) {
575
+ const cleared = { bigCursor: false, readingGuide: false, readingMask: false };
576
+ saveGlobal(cleared);
577
+ applyGlobal(cleared);
578
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
579
+ }
483
580
  };
484
581
  let opener = null;
485
582
  let ignoreDocClose = false;
@@ -647,14 +744,16 @@ function bindPanelBehavior(root, opts = {}) {
647
744
  saveNudge();
648
745
  };
649
746
  const onMove = (e) => {
650
- if (!state.readingGuide && !state.readingMask) return;
747
+ const guideOn = document.body.classList.contains("oks-a11y-guide");
748
+ const maskOn = document.body.classList.contains("oks-a11y-mask");
749
+ if (!guideOn && !maskOn) return;
651
750
  const y = e.touches?.[0]?.clientY ?? e.clientY;
652
751
  if (typeof y !== "number") return;
653
- if (state.readingGuide) {
752
+ if (guideOn) {
654
753
  const guide = document.getElementById("oks-reading-guide");
655
754
  if (guide) guide.style.top = `${y}px`;
656
755
  }
657
- if (state.readingMask) {
756
+ if (maskOn) {
658
757
  const mask = document.getElementById("oks-reading-mask");
659
758
  if (mask) mask.style.setProperty("--oks-mask-y", `${y}px`);
660
759
  }
@@ -670,9 +769,15 @@ function bindPanelBehavior(root, opts = {}) {
670
769
  const onReposition = () => {
671
770
  if (opts.scopeEl && panel.classList.contains("is-open")) positionScopedDialog();
672
771
  };
673
- if (opts.scopeEl !== void 0) {
772
+ const onGlobalChange = () => refreshGlobal();
773
+ const onStorage = (e) => {
774
+ if (e.key === GLOBAL_KEY) refreshGlobal();
775
+ };
776
+ if (scoped) {
674
777
  window.addEventListener("scroll", onReposition, true);
675
778
  window.addEventListener("resize", onReposition);
779
+ document.addEventListener("oksiac:globalchange", onGlobalChange);
780
+ window.addEventListener("storage", onStorage);
676
781
  }
677
782
  if (nudgeMax > 0 && trigger) {
678
783
  loadNudge();
@@ -685,6 +790,7 @@ function bindPanelBehavior(root, opts = {}) {
685
790
  trigger.addEventListener("keydown", onTriggerKey);
686
791
  }
687
792
  applyState();
793
+ refreshGlobal();
688
794
  const dispose = () => {
689
795
  trigger?.removeEventListener("click", onTriggerClick);
690
796
  closeBtn.removeEventListener("click", closePanel);
@@ -694,9 +800,11 @@ function bindPanelBehavior(root, opts = {}) {
694
800
  document.removeEventListener("keydown", onKeyDown);
695
801
  document.removeEventListener("mousemove", onMove);
696
802
  document.removeEventListener("touchmove", onMove);
697
- if (opts.scopeEl !== void 0) {
803
+ if (scoped) {
698
804
  window.removeEventListener("scroll", onReposition, true);
699
805
  window.removeEventListener("resize", onReposition);
806
+ document.removeEventListener("oksiac:globalchange", onGlobalChange);
807
+ window.removeEventListener("storage", onStorage);
700
808
  }
701
809
  trigger?.removeEventListener("pointerdown", onPointerDown);
702
810
  document.removeEventListener("pointermove", onPointerMove);
@@ -833,6 +941,19 @@ var PANEL_CSS = `
833
941
  text-transform: uppercase;
834
942
  color: #888;
835
943
  }
944
+ /* Screen-reader-only: keeps the category headings in the accessibility tree
945
+ (flat scoped layout) while taking them out of the grid flow so they don't
946
+ force a visual row break or occupy a cell. */
947
+ .oks-sr-only {
948
+ position: absolute !important;
949
+ width: 1px; height: 1px;
950
+ margin: 0; padding: 0;
951
+ overflow: hidden;
952
+ clip: rect(0 0 0 0);
953
+ clip-path: inset(50%);
954
+ white-space: nowrap;
955
+ border: 0;
956
+ }
836
957
  .oks-access-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
837
958
  .oks-access-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
838
959
  .oks-preset {
@@ -1004,18 +1125,22 @@ body.oks-ls-1 * { letter-spacing: 0.05em !important; }
1004
1125
  body.oks-ls-2 * { letter-spacing: 0.10em !important; }
1005
1126
  body.oks-ls-3 * { letter-spacing: 0.16em !important; }
1006
1127
 
1128
+ /* Focus highlight colour is themeable via --oks-focus-color (default #005fcc):
1129
+ set it on :root for a dark theme so the highlight stays visible. The
1130
+ persistent "all interactive" dashed derives a 45% tint from it; the glow can
1131
+ be overridden on its own with --oks-focus-glow. */
1007
1132
  body.oks-a11y-focus a:not(oksigenia-access-panel):not(oksigenia-access-panel *),
1008
1133
  body.oks-a11y-focus button:not(oksigenia-access-panel):not(oksigenia-access-panel *),
1009
1134
  body.oks-a11y-focus input,
1010
1135
  body.oks-a11y-focus select,
1011
1136
  body.oks-a11y-focus textarea {
1012
- outline: 2px dashed rgba(0, 95, 204, 0.45) !important;
1137
+ outline: 2px dashed color-mix(in srgb, var(--oks-focus-color, #005fcc) 45%, transparent) !important;
1013
1138
  outline-offset: 2px !important;
1014
1139
  }
1015
1140
  body.oks-a11y-focus *:focus-visible {
1016
- outline: 3px solid #005fcc !important;
1141
+ outline: 3px solid var(--oks-focus-color, #005fcc) !important;
1017
1142
  outline-offset: 3px !important;
1018
- box-shadow: 0 0 0 6px rgba(0, 95, 204, 0.25) !important;
1143
+ box-shadow: 0 0 0 6px var(--oks-focus-glow, color-mix(in srgb, var(--oks-focus-color, #005fcc) 25%, transparent)) !important;
1019
1144
  }
1020
1145
 
1021
1146
  body.oks-a11y-contrast.oks-a11y-focus *:focus,
@@ -1152,13 +1277,13 @@ ${s}.oks-a11y-focus button,
1152
1277
  ${s}.oks-a11y-focus input,
1153
1278
  ${s}.oks-a11y-focus select,
1154
1279
  ${s}.oks-a11y-focus textarea {
1155
- outline: 2px dashed rgba(0, 95, 204, 0.45) !important;
1280
+ outline: 2px dashed color-mix(in srgb, var(--oks-focus-color, #005fcc) 45%, transparent) !important;
1156
1281
  outline-offset: 2px !important;
1157
1282
  }
1158
1283
  ${s}.oks-a11y-focus *:focus-visible {
1159
- outline: 3px solid #005fcc !important;
1284
+ outline: 3px solid var(--oks-focus-color, #005fcc) !important;
1160
1285
  outline-offset: 3px !important;
1161
- box-shadow: 0 0 0 6px rgba(0, 95, 204, 0.25) !important;
1286
+ box-shadow: 0 0 0 6px var(--oks-focus-glow, color-mix(in srgb, var(--oks-focus-color, #005fcc) 25%, transparent)) !important;
1162
1287
  }
1163
1288
 
1164
1289
  ${s}.oks-a11y-contrast, ${s}.oks-a11y-contrast * {
@@ -1189,6 +1314,6 @@ ${s}.oks-a11y-bigtargets summary {
1189
1314
  `;
1190
1315
  }
1191
1316
 
1192
- export { ALL_CONTROLS, COLORBLIND_FILTERS_SVG, DEFAULT_STATE, EFFECT_CSS, NON_SCOPABLE_CONTROLS, PANEL_CSS, PRESETS, PRESET_IDS, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, resolveEnabledControls, saveState, scopedControls, scopedEffectCss, supportedLocales };
1317
+ export { ALL_CONTROLS, COLORBLIND_FILTERS_SVG, DEFAULT_STATE, EFFECT_CSS, GLOBAL_OFFERABLE_CONTROLS, NON_SCOPABLE_CONTROLS, PANEL_CSS, PRESETS, PRESET_IDS, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, resolveEnabledControls, saveState, scopedControls, scopedEffectCss, supportedLocales };
1193
1318
  //# sourceMappingURL=index.js.map
1194
1319
  //# sourceMappingURL=index.js.map