current_scope 0.5.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,917 @@
1
+ /* CurrentScope — the mounted management UI: an authorization console.
2
+ *
3
+ * An opinionated, self-contained admin dashboard: a sidebar shell, a
4
+ * precision-monochrome surface with one cobalt signature accent (the product's
5
+ * "ledger ink"), monospace for the technical layer (permission keys, action
6
+ * columns, event types), and first-class light + dark themes. System stack
7
+ * only — no web fonts, no build step, CSP-safe.
8
+ *
9
+ * Theming: tokens live on :root. Light is the default; system dark applies via
10
+ * prefers-color-scheme; an explicit choice is set as html[data-cs-theme] (from
11
+ * the current_scope_theme cookie, so there's no flash). Retheme by redefining
12
+ * the --cs-* custom properties; restructure by overriding the .cs-* classes.
13
+ *
14
+ * The token defaults sit in a @layer so a host's own (unlayered) :root override
15
+ * always wins — otherwise the data-cs-theme attribute selectors below would
16
+ * outweigh a plain :root { --cs-accent: … } in the host stylesheet.
17
+ */
18
+
19
+ @layer current_scope.tokens {
20
+ :root {
21
+ color-scheme: light;
22
+
23
+ /* Surfaces — cool near-neutral, ink on paper. */
24
+ --cs-bg: oklch(0.992 0.002 264);
25
+ --cs-surface: oklch(0.975 0.003 264);
26
+ --cs-surface-2: oklch(0.955 0.004 264);
27
+ --cs-line: oklch(0.905 0.005 264);
28
+ --cs-line-strong: oklch(0.80 0.008 264);
29
+
30
+ /* Ink. */
31
+ --cs-ink: oklch(0.26 0.014 264);
32
+ --cs-ink-2: oklch(0.18 0.02 264); /* headings */
33
+ --cs-muted: oklch(0.475 0.015 264); /* ~5.4:1 on --cs-bg */
34
+
35
+ /* Cobalt signature accent — used with discipline (active nav, primary
36
+ action, focus, granted state). Rare on purpose. */
37
+ --cs-accent: oklch(0.515 0.163 262);
38
+ --cs-accent-strong: oklch(0.44 0.17 262);
39
+ --cs-accent-wash: oklch(0.955 0.03 262);
40
+ --cs-on-accent: oklch(0.99 0.01 262);
41
+
42
+ /* Semantics. */
43
+ --cs-danger: oklch(0.53 0.19 25);
44
+ --cs-danger-line: oklch(0.82 0.09 25);
45
+ --cs-danger-wash: oklch(0.965 0.018 25);
46
+ --cs-ok: oklch(0.58 0.12 150);
47
+ --cs-ok-line: oklch(0.82 0.07 150);
48
+ --cs-ok-wash: oklch(0.962 0.02 150);
49
+
50
+ --cs-radius: 6px;
51
+ --cs-radius-sm: 4px;
52
+ --cs-shadow: 0 1px 2px oklch(0.2 0.03 264 / 0.06), 0 4px 12px oklch(0.2 0.03 264 / 0.05);
53
+ --cs-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
54
+ --cs-mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, monospace;
55
+ --cs-ease: cubic-bezier(0.22, 1, 0.36, 1);
56
+
57
+ --cs-sidebar-w: 15.5rem;
58
+ --cs-topbar-h: 3.25rem;
59
+ }
60
+
61
+ /* Dark tokens, shared by system-dark (no explicit choice) and an explicit
62
+ data-cs-theme="dark". */
63
+ @media (prefers-color-scheme: dark) {
64
+ :root:not([data-cs-theme]) {
65
+ color-scheme: dark;
66
+ --cs-bg: oklch(0.172 0.008 264);
67
+ --cs-surface: oklch(0.208 0.009 264);
68
+ --cs-surface-2: oklch(0.238 0.010 264);
69
+ --cs-line: oklch(0.305 0.010 264);
70
+ --cs-line-strong: oklch(0.41 0.013 264);
71
+ --cs-ink: oklch(0.905 0.008 264);
72
+ --cs-ink-2: oklch(0.975 0.006 264);
73
+ --cs-muted: oklch(0.68 0.013 264);
74
+ --cs-accent: oklch(0.70 0.15 262);
75
+ --cs-accent-strong: oklch(0.78 0.14 262);
76
+ --cs-accent-wash: oklch(0.30 0.07 262);
77
+ --cs-on-accent: oklch(0.16 0.03 262);
78
+ --cs-danger: oklch(0.68 0.17 25);
79
+ --cs-danger-line: oklch(0.42 0.11 25);
80
+ --cs-danger-wash: oklch(0.27 0.06 25);
81
+ --cs-ok: oklch(0.72 0.13 150);
82
+ --cs-ok-line: oklch(0.40 0.08 150);
83
+ --cs-ok-wash: oklch(0.26 0.05 150);
84
+ --cs-shadow: 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px oklch(0 0 0 / 0.35);
85
+ }
86
+ }
87
+
88
+ /* NB: these dark tokens intentionally duplicate the @media block above — CSS
89
+ can't share a declaration block between a media query and an attribute
90
+ selector without a preprocessor. Keep the two in sync on any token change. */
91
+ :root[data-cs-theme="dark"] {
92
+ color-scheme: dark;
93
+ --cs-bg: oklch(0.172 0.008 264);
94
+ --cs-surface: oklch(0.208 0.009 264);
95
+ --cs-surface-2: oklch(0.238 0.010 264);
96
+ --cs-line: oklch(0.305 0.010 264);
97
+ --cs-line-strong: oklch(0.41 0.013 264);
98
+ --cs-ink: oklch(0.905 0.008 264);
99
+ --cs-ink-2: oklch(0.975 0.006 264);
100
+ --cs-muted: oklch(0.68 0.013 264);
101
+ --cs-accent: oklch(0.70 0.15 262);
102
+ --cs-accent-strong: oklch(0.78 0.14 262);
103
+ --cs-accent-wash: oklch(0.30 0.07 262);
104
+ --cs-on-accent: oklch(0.16 0.03 262);
105
+ --cs-danger: oklch(0.68 0.17 25);
106
+ --cs-danger-line: oklch(0.42 0.11 25);
107
+ --cs-danger-wash: oklch(0.27 0.06 25);
108
+ --cs-ok: oklch(0.72 0.13 150);
109
+ --cs-ok-line: oklch(0.40 0.08 150);
110
+ --cs-ok-wash: oklch(0.26 0.05 150);
111
+ --cs-shadow: 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px oklch(0 0 0 / 0.35);
112
+ }
113
+ } /* @layer current_scope.tokens */
114
+
115
+ *, *::before, *::after { box-sizing: border-box; }
116
+
117
+ body {
118
+ margin: 0;
119
+ background: var(--cs-bg);
120
+ color: var(--cs-ink);
121
+ font-family: var(--cs-font);
122
+ font-size: 0.9375rem;
123
+ line-height: 1.55;
124
+ -webkit-font-smoothing: antialiased;
125
+ }
126
+
127
+ h1 { font-size: 1.4rem; font-weight: 660; letter-spacing: -0.02em; color: var(--cs-ink-2); margin: 0 0 0.375rem; text-wrap: balance; }
128
+ h2 { font-size: 1.0625rem; font-weight: 600; color: var(--cs-ink-2); margin: 2rem 0 0.75rem; text-wrap: balance; }
129
+
130
+ a { color: var(--cs-accent); text-decoration-thickness: 1px; text-underline-offset: 3px; }
131
+ a:hover { color: var(--cs-accent-strong); }
132
+
133
+ :focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 2px; border-radius: 3px; }
134
+
135
+ /* Bypass block (WCAG 2.4.1): off-canvas until keyboard focus lands on it,
136
+ * then a small accent pill in the top-left, above the sticky sidebar. */
137
+ .cs-skip-link {
138
+ position: absolute;
139
+ top: 0.5rem;
140
+ left: 0.5rem;
141
+ z-index: 10;
142
+ padding: 0.5rem 0.875rem;
143
+ background: var(--cs-accent);
144
+ color: var(--cs-on-accent);
145
+ border-radius: 6px;
146
+ font-weight: 600;
147
+ text-decoration: none;
148
+ transform: translateY(calc(-100% - 1rem));
149
+ }
150
+ .cs-skip-link:focus { transform: none; }
151
+
152
+ /* The skip-link target: focusable for the jump, but never shows a ring of
153
+ * its own — the content it reveals is the feedback. */
154
+ #cs-main-content:focus-visible { outline: none; }
155
+
156
+ /* ---- Shell ------------------------------------------------------------ *
157
+ * A left sidebar (wordmark + section nav) and a scrollable main column with
158
+ * a sticky topbar. The dense grid lives inside main.
159
+ */
160
+
161
+ .cs-shell {
162
+ display: grid;
163
+ grid-template-columns: var(--cs-sidebar-w) minmax(0, 1fr);
164
+ min-height: 100vh;
165
+ }
166
+
167
+ .cs-sidebar {
168
+ position: sticky;
169
+ top: 0;
170
+ align-self: start;
171
+ height: 100vh;
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 0.25rem;
175
+ padding: 1.125rem 0.75rem;
176
+ background: var(--cs-surface);
177
+ border-right: 1px solid var(--cs-line);
178
+ }
179
+
180
+ .cs-brand {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 0.5rem;
184
+ padding: 0.25rem 0.5rem 1rem;
185
+ font-weight: 660;
186
+ font-size: 0.95rem;
187
+ letter-spacing: -0.01em;
188
+ color: var(--cs-ink-2);
189
+ }
190
+
191
+ /* A tiny 2x2 "grid" mark — the permission matrix, distilled. */
192
+ .cs-brand-mark {
193
+ width: 1.125rem;
194
+ height: 1.125rem;
195
+ border-radius: 4px;
196
+ flex: none;
197
+ background:
198
+ linear-gradient(var(--cs-accent) 0 0) 0 0 / 45% 45%,
199
+ linear-gradient(var(--cs-accent) 0 0) 100% 0 / 45% 45%,
200
+ linear-gradient(var(--cs-line-strong) 0 0) 0 100% / 45% 45%,
201
+ linear-gradient(var(--cs-accent) 0 0) 100% 100% / 45% 45%;
202
+ background-repeat: no-repeat;
203
+ box-shadow: inset 0 0 0 1px var(--cs-line);
204
+ }
205
+
206
+ .cs-nav { display: flex; flex-direction: column; gap: 0.125rem; }
207
+
208
+ .cs-nav a {
209
+ position: relative;
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 0.5rem;
213
+ padding: 0.4375rem 0.625rem;
214
+ border-radius: var(--cs-radius-sm);
215
+ color: var(--cs-muted);
216
+ text-decoration: none;
217
+ font-weight: 500;
218
+ font-size: 0.875rem;
219
+ transition: background 120ms var(--cs-ease), color 120ms var(--cs-ease);
220
+ }
221
+
222
+ .cs-nav a:hover { color: var(--cs-ink); background: var(--cs-surface-2); }
223
+
224
+ .cs-nav a[aria-current="page"] {
225
+ color: var(--cs-ink-2);
226
+ background: var(--cs-accent-wash);
227
+ font-weight: 560;
228
+ }
229
+
230
+ /* the signature accent rail on the active item */
231
+ .cs-nav a[aria-current="page"]::before {
232
+ content: "";
233
+ position: absolute;
234
+ left: -0.75rem;
235
+ top: 0.375rem;
236
+ bottom: 0.375rem;
237
+ width: 3px;
238
+ border-radius: 0 3px 3px 0;
239
+ background: var(--cs-accent);
240
+ }
241
+
242
+ .cs-sidebar-foot { margin-top: auto; padding: 0.5rem; color: var(--cs-muted); font-size: 0.75rem; }
243
+
244
+ /* ---- Topbar ----------------------------------------------------------- */
245
+
246
+ .cs-topbar {
247
+ position: sticky;
248
+ top: 0;
249
+ z-index: 5;
250
+ display: flex;
251
+ align-items: center;
252
+ min-height: var(--cs-topbar-h);
253
+ gap: 1rem;
254
+ padding: 0.75rem 1.75rem;
255
+ background: color-mix(in oklch, var(--cs-bg) 82%, transparent);
256
+ backdrop-filter: saturate(1.4) blur(8px);
257
+ border-bottom: 1px solid var(--cs-line);
258
+ }
259
+
260
+ .cs-topbar-title { font-weight: 600; font-size: 0.9375rem; color: var(--cs-ink-2); }
261
+ .cs-topbar-spacer { flex: 1; }
262
+
263
+ .cs-badge {
264
+ display: inline-flex;
265
+ align-items: center;
266
+ gap: 0.375rem;
267
+ padding: 0.1875rem 0.5rem;
268
+ border: 1px solid var(--cs-line-strong);
269
+ border-radius: 999px;
270
+ font-size: 0.75rem;
271
+ color: var(--cs-muted);
272
+ font-family: var(--cs-mono);
273
+ }
274
+ .cs-badge::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: var(--cs-ok); }
275
+
276
+ /* Theme toggle — sun/moon glyphs swapped by theme via CSS. */
277
+ .cs-theme-toggle {
278
+ display: inline-grid;
279
+ place-items: center;
280
+ width: 2rem;
281
+ height: 2rem;
282
+ padding: 0;
283
+ border: 1px solid var(--cs-line-strong);
284
+ border-radius: var(--cs-radius-sm);
285
+ background: var(--cs-surface);
286
+ color: var(--cs-ink);
287
+ cursor: pointer;
288
+ font-size: 0.95rem;
289
+ line-height: 1;
290
+ }
291
+ .cs-theme-toggle:hover { border-color: var(--cs-ink); background: var(--cs-surface-2); }
292
+ .cs-theme-toggle .cs-icon-dark { display: none; }
293
+ .cs-theme-toggle .cs-icon-light { display: block; }
294
+ :root[data-cs-theme="dark"] .cs-theme-toggle .cs-icon-dark { display: block; }
295
+ :root[data-cs-theme="dark"] .cs-theme-toggle .cs-icon-light { display: none; }
296
+ @media (prefers-color-scheme: dark) {
297
+ :root:not([data-cs-theme]) .cs-theme-toggle .cs-icon-dark { display: block; }
298
+ :root:not([data-cs-theme]) .cs-theme-toggle .cs-icon-light { display: none; }
299
+ }
300
+
301
+ /* ---- Main + flashes --------------------------------------------------- */
302
+
303
+ .cs-main { min-width: 0; }
304
+ .cs-content { padding: 1.75rem; max-width: 74rem; }
305
+ .cs-lede { color: var(--cs-muted); font-size: 0.875rem; margin: 0 0 1.5rem; max-width: 64ch; }
306
+
307
+ .cs-flash {
308
+ display: flex;
309
+ gap: 0.5rem;
310
+ margin: 1rem 1.75rem -0.5rem;
311
+ padding: 0.5rem 0.875rem;
312
+ border: 1px solid var(--cs-line);
313
+ border-radius: var(--cs-radius);
314
+ font-size: 0.875rem;
315
+ }
316
+ .cs-flash--notice { border-color: var(--cs-ok-line); background: var(--cs-ok-wash); }
317
+ .cs-flash--alert { border-color: var(--cs-danger-line); background: var(--cs-danger-wash); }
318
+
319
+ /* In-view flash aliases (form errors use these). */
320
+ .cs-notice, .cs-alert {
321
+ display: block;
322
+ padding: 0.5rem 0.875rem;
323
+ border: 1px solid var(--cs-line);
324
+ border-radius: var(--cs-radius);
325
+ font-size: 0.875rem;
326
+ margin: 0 0 1rem;
327
+ max-width: 30rem;
328
+ }
329
+ .cs-notice { border-color: var(--cs-ok-line); background: var(--cs-ok-wash); }
330
+ .cs-alert { border-color: var(--cs-danger-line); background: var(--cs-danger-wash); }
331
+
332
+ .cs-hint { color: var(--cs-muted); font-size: 0.8125rem; max-width: 68ch; }
333
+ .cs-subtle { color: var(--cs-muted); font-size: 0.8125rem; margin-top: 0.125rem; }
334
+
335
+ /* ---- Toolbar (page actions) ------------------------------------------- */
336
+
337
+ .cs-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; margin: 0 0 1.25rem; }
338
+ .cs-toolbar .cs-spacer { flex: 1; }
339
+
340
+ /* ---- Filter + bulk (subjects) ----------------------------------------- */
341
+
342
+ .cs-filter { min-width: min(22rem, 100%); }
343
+
344
+ /* The subjects search form: input + Search + Clear on one line, the input
345
+ taking the free space; wraps on a narrow toolbar. */
346
+ .cs-search { display: flex; align-items: center; gap: 0.5rem; flex: 1 1 22rem; min-width: 0; }
347
+ .cs-search .cs-filter { flex: 1; min-width: 0; }
348
+
349
+ .cs-bulk {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 0.75rem;
353
+ margin: 0 0 1rem;
354
+ padding: 0.5rem 0.875rem;
355
+ border: 1px solid var(--cs-accent);
356
+ border-radius: var(--cs-radius);
357
+ background: var(--cs-accent-wash);
358
+ font-size: 0.875rem;
359
+ }
360
+ /* The bar sets display:flex, which otherwise defeats the [hidden] attribute the
361
+ JS toggles — keep it hidden until a subject is selected. */
362
+ .cs-bulk[hidden] { display: none; }
363
+ .cs-bulk .cs-spacer { flex: 1; }
364
+ .cs-bulk-org { display: inline-flex; align-items: center; gap: 0.375rem; margin: 0; }
365
+ .cs-bulk-org select { padding: 0.3125rem 0.5rem; }
366
+
367
+ /* Select column — narrow, centered checkboxes. */
368
+ .cs-select-cell { width: 1%; text-align: center; padding-inline: 0.75rem; }
369
+
370
+ .cs-filter-empty { color: var(--cs-muted); font-size: 0.875rem; margin: 0.75rem 0; }
371
+
372
+ /* ---- Cards ------------------------------------------------------------ */
373
+
374
+ .cs-card {
375
+ background: var(--cs-surface);
376
+ border: 1px solid var(--cs-line);
377
+ border-radius: var(--cs-radius);
378
+ box-shadow: var(--cs-shadow);
379
+ }
380
+ .cs-card:not(.cs-card--flush) > * { padding-inline: 1.125rem; }
381
+
382
+ /* The engine's front-door 403 — rendered standalone, without the console shell
383
+ (a sidebar of links this subject can't open would read as "you're in" and
384
+ then refuse every click). Only the centring is new; the surface, type and
385
+ theme tokens come from .cs-card and the base rules above. */
386
+ .cs-denied {
387
+ max-width: 46rem;
388
+ margin: 12vh auto 0;
389
+ padding-block: 1.5rem;
390
+ }
391
+ .cs-denied p { margin: 0 0 0.75rem; max-width: 68ch; }
392
+ /* Flush cards hold data tables. Let them scroll horizontally inside the card so
393
+ a wide table never overflows the page. On a wide screen the table fills the
394
+ card (base width:100%) and scoped-role chips wrap in their own cell — all
395
+ visible, no scroll. On a narrow screen the table switches to its natural width
396
+ (see the max-width:820px block) so columns stay usable and the row height
397
+ stays normal, scrolling horizontally instead of crushing into tall rows. */
398
+ .cs-card--flush { padding: 0; overflow-x: auto; }
399
+ .cs-card--flush .cs-table th:first-child,
400
+ .cs-card--flush .cs-table td:first-child { padding-left: 1.125rem; }
401
+ .cs-card--flush .cs-table th:last-child,
402
+ .cs-card--flush .cs-table td:last-child { padding-right: 1.125rem; }
403
+
404
+ /* ---- Tables ----------------------------------------------------------- */
405
+
406
+ .cs-table { border-collapse: collapse; width: 100%; }
407
+
408
+ .cs-table th {
409
+ text-align: left;
410
+ font-size: 0.7rem;
411
+ font-weight: 600;
412
+ letter-spacing: 0.04em;
413
+ text-transform: uppercase;
414
+ color: var(--cs-muted);
415
+ background: var(--cs-surface-2);
416
+ border-bottom: 1px solid var(--cs-line);
417
+ padding: 0.5rem 0.75rem;
418
+ }
419
+
420
+ .cs-table td {
421
+ border-bottom: 1px solid var(--cs-line);
422
+ padding: 0.625rem 0.75rem;
423
+ vertical-align: middle;
424
+ }
425
+ .cs-table tbody tr:last-child td { border-bottom: none; }
426
+ .cs-table tbody tr:hover td { background: var(--cs-surface-2); }
427
+ .cs-mono, .cs-table .cs-mono { font-family: var(--cs-mono); font-size: 0.8125rem; }
428
+
429
+ /* ---- Permission grid — the hero surface -------------------------------
430
+ * A true matrix: fixed action columns across the top, one row per controller,
431
+ * a sticky mono controller column down the side. Columns stay ABSOLUTE — a
432
+ * controller that doesn't route a column's actions shows a blank cell, never a
433
+ * shifted one. The signature move is the GRANTED state: a checked cell lights
434
+ * up in the cobalt wash, so access reads at a glance across the whole grid.
435
+ */
436
+
437
+ .cs-grid-wrap {
438
+ overflow-x: auto;
439
+ border: 1px solid var(--cs-line);
440
+ border-radius: var(--cs-radius);
441
+ box-shadow: var(--cs-shadow);
442
+ background: var(--cs-surface);
443
+ }
444
+
445
+ .cs-grid { border-collapse: separate; border-spacing: 0; width: 100%; }
446
+
447
+ /* Column headers — the fixed action/group names. Sticky to the TOP OF THE GRID
448
+ WRAPPER (its sticky containing block, because .cs-grid-wrap sets overflow-x) —
449
+ so top:0, NOT the topbar height: any positive offset here pushes the header
450
+ down over the first data row instead of pinning it under the page topbar. */
451
+ .cs-grid thead th {
452
+ position: sticky;
453
+ top: 0;
454
+ z-index: 2;
455
+ background: var(--cs-surface-2);
456
+ color: var(--cs-muted);
457
+ font-family: var(--cs-mono);
458
+ font-size: 0.7rem;
459
+ font-weight: 600;
460
+ letter-spacing: 0.03em;
461
+ text-transform: uppercase;
462
+ text-align: center;
463
+ white-space: nowrap;
464
+ padding: 0.5rem 0.625rem;
465
+ border-bottom: 1px solid var(--cs-line);
466
+ border-right: 1px solid var(--cs-line);
467
+ }
468
+ .cs-grid thead th:last-child { border-right: none; }
469
+ .cs-grid thead th.cs-grid-corner {
470
+ left: 0;
471
+ z-index: 3;
472
+ text-align: left;
473
+ background: var(--cs-surface-2);
474
+ }
475
+
476
+ /* Sticky controller column. */
477
+ .cs-grid th[scope="row"] {
478
+ position: sticky;
479
+ left: 0;
480
+ z-index: 1;
481
+ background: var(--cs-surface);
482
+ color: var(--cs-ink-2);
483
+ text-align: left;
484
+ white-space: nowrap;
485
+ border-right: 1px solid var(--cs-line);
486
+ border-bottom: 1px solid var(--cs-line);
487
+ padding: 0.375rem 0.75rem;
488
+ }
489
+
490
+ .cs-grid td {
491
+ border-bottom: 1px solid var(--cs-line);
492
+ border-right: 1px solid var(--cs-line);
493
+ padding: 0.375rem;
494
+ text-align: center;
495
+ transition: background 120ms var(--cs-ease);
496
+ }
497
+ .cs-grid td:last-child { border-right: none; }
498
+ .cs-grid tr:last-child th[scope="row"],
499
+ .cs-grid tr:last-child td { border-bottom: none; }
500
+ .cs-grid tbody tr:hover th[scope="row"] { background: var(--cs-surface-2); }
501
+ .cs-grid tbody tr:hover td:not(.cs-cell-blank):not(:has(input:checked)) { background: var(--cs-surface-2); }
502
+
503
+ .cs-grid label { display: inline-flex; align-items: center; justify-content: center; margin: 0; cursor: pointer; }
504
+
505
+ /* A blank cell — the controller doesn't route this column's actions. A faint
506
+ marker so the gap reads as "not applicable", not "unchecked". */
507
+ .cs-grid td.cs-cell-blank::after { content: "·"; color: var(--cs-line-strong); font-size: 1rem; }
508
+
509
+ /* Per-row master toggle ("enable all in this controller") — sits in the sticky
510
+ row header, reading as a select-all next to the mono controller name. */
511
+ .cs-grid .cs-row-all {
512
+ display: inline-flex;
513
+ align-items: center;
514
+ gap: 0.5rem;
515
+ cursor: pointer;
516
+ font-family: var(--cs-mono);
517
+ font-size: 0.8125rem;
518
+ font-weight: 600;
519
+ color: var(--cs-ink-2);
520
+ }
521
+ .cs-grid .cs-row-all input { flex: none; margin: 0; }
522
+ .cs-grid th[scope="row"]:has([data-cs-row-all]:checked) { background: var(--cs-accent-wash); }
523
+
524
+ /* GRANTED: the cell lights up in cobalt. */
525
+ .cs-grid td:has(input:checked) { background: var(--cs-accent-wash); }
526
+
527
+ /* PARTIAL: some-but-not-all of the group is granted. JS also sets the checkbox
528
+ indeterminate, but this keys off the data attribute so the "partial, not
529
+ ungranted" state is still visible with JS disabled. */
530
+ .cs-grid td:has(input[data-cs-partial="true"]) { background: var(--cs-accent-wash); }
531
+ .cs-grid input[data-cs-partial="true"] { outline: 2px dashed var(--cs-accent); outline-offset: 1px; }
532
+
533
+ /* UNGATED (provable): the gate never runs on this controller, so its granted
534
+ routed actions are inert. The badge is advisory — marking is not disabling
535
+ (KTD-8/R5): nothing grayed, nothing disabled. It sits under the controller
536
+ name in the sticky header, wrapping normally inside the nowrap column. */
537
+ /* #76 — declared skip with a reason (intentional; not the unexplained warning). */
538
+ .cs-declared-skip-badge {
539
+ display: block;
540
+ max-width: 19rem;
541
+ margin-top: 0.25rem;
542
+ padding: 0.25rem 0.4375rem;
543
+ border: 1px solid var(--cs-line-strong);
544
+ border-radius: var(--cs-radius-sm);
545
+ background: var(--cs-surface-2);
546
+ color: var(--cs-ink);
547
+ font-family: var(--cs-font);
548
+ font-size: 0.6875rem;
549
+ font-weight: 400; /* th is bold; reset so only <strong> is emphasized */
550
+ line-height: 1.45;
551
+ white-space: normal;
552
+ }
553
+ .cs-declared-skip-badge strong {
554
+ color: var(--cs-muted);
555
+ font-weight: 650;
556
+ letter-spacing: 0.03em;
557
+ text-transform: uppercase;
558
+ }
559
+
560
+ .cs-ungated-badge {
561
+ display: block;
562
+ max-width: 19rem;
563
+ margin-top: 0.25rem;
564
+ padding: 0.25rem 0.4375rem;
565
+ border: 1px solid var(--cs-danger-line);
566
+ border-radius: var(--cs-radius-sm);
567
+ background: var(--cs-danger-wash);
568
+ color: var(--cs-ink);
569
+ font-family: var(--cs-font);
570
+ font-size: 0.6875rem;
571
+ font-weight: 400;
572
+ line-height: 1.45;
573
+ white-space: normal;
574
+ }
575
+ .cs-ungated-badge strong {
576
+ color: var(--cs-danger);
577
+ font-weight: 650;
578
+ letter-spacing: 0.03em;
579
+ text-transform: uppercase;
580
+ }
581
+ .cs-ungated-badge code { font-family: var(--cs-mono); }
582
+
583
+ /* A checked (or partial) cell in a marked row must not read as a live grant:
584
+ swap the cobalt wash for a muted hatch. CSS-only, keyed on the badge — the
585
+ cell markup is untouched. The break-glass cell (.cs-cell-bypass) is excluded
586
+ on purpose (KTD-9): that grant IS live on a marked row, so it keeps the real
587
+ granted wash from the rule above. */
588
+ .cs-grid tbody tr:has(.cs-ungated-badge) td:not(.cs-cell-bypass):has(input:checked),
589
+ .cs-grid tbody tr:has(.cs-ungated-badge) td:not(.cs-cell-bypass):has(input[data-cs-partial="true"]),
590
+ .cs-grid tbody tr:has(.cs-missing-controller-badge) td:not(.cs-cell-bypass):has(input:checked),
591
+ .cs-grid tbody tr:has(.cs-missing-controller-badge) td:not(.cs-cell-bypass):has(input[data-cs-partial="true"]),
592
+ .cs-grid tbody tr:has(.cs-declared-skip-badge) td:not(.cs-cell-bypass):has(input:checked),
593
+ .cs-grid tbody tr:has(.cs-declared-skip-badge) td:not(.cs-cell-bypass):has(input[data-cs-partial="true"]) {
594
+ background: repeating-linear-gradient(-45deg,
595
+ var(--cs-surface-2) 0 4px, var(--cs-bg) 4px 8px);
596
+ }
597
+
598
+ /* #43 — route with no controller class (stale/typo). Advisory only: keys stay
599
+ grantable (catalog = routes) but the request will MissingController 500. */
600
+ .cs-missing-controller-badge {
601
+ display: block;
602
+ max-width: 19rem;
603
+ margin-top: 0.25rem;
604
+ padding: 0.25rem 0.4375rem;
605
+ border: 1px solid var(--cs-warn, #b45309);
606
+ border-radius: var(--cs-radius-sm);
607
+ background: color-mix(in srgb, var(--cs-warn, #b45309) 12%, var(--cs-surface));
608
+ color: var(--cs-ink);
609
+ font-family: var(--cs-font);
610
+ font-size: 0.6875rem;
611
+ line-height: 1.45;
612
+ white-space: normal;
613
+ }
614
+ .cs-missing-controller-badge strong { color: var(--cs-warn, #b45309); }
615
+ .cs-missing-controller-badge code { font-family: var(--cs-mono); }
616
+
617
+ /* UNINSPECTABLE: the controller raised while loading, so the gate state is
618
+ unknown — a distinct third voice, deliberately not the danger badge (no
619
+ claim about gating is being made) and not silence (unmarked reads as
620
+ "checked, and gated" under the hint's rules). */
621
+ .cs-uninspectable-note {
622
+ display: block;
623
+ max-width: 19rem;
624
+ margin-top: 0.25rem;
625
+ padding: 0.25rem 0.4375rem;
626
+ border: 1px dashed var(--cs-line-strong);
627
+ border-radius: var(--cs-radius-sm);
628
+ background: var(--cs-surface-2);
629
+ color: var(--cs-muted);
630
+ font-family: var(--cs-font);
631
+ font-size: 0.6875rem;
632
+ line-height: 1.45;
633
+ white-space: normal;
634
+ }
635
+
636
+ /* The per-cell exemption note on a marked row's break-glass cell. */
637
+ .cs-bypass-exempt {
638
+ display: block;
639
+ margin-top: 0.125rem;
640
+ font-size: 0.625rem;
641
+ line-height: 1.3;
642
+ color: var(--cs-muted);
643
+ white-space: nowrap;
644
+ }
645
+
646
+ /* Checkboxes / radios use the accent. */
647
+ input[type="checkbox"], input[type="radio"] {
648
+ accent-color: var(--cs-accent);
649
+ width: 1rem;
650
+ height: 1rem;
651
+ }
652
+
653
+ /* ---- Chips (scoped-role assignments) ---------------------------------- */
654
+
655
+ .cs-chip {
656
+ display: inline-flex;
657
+ align-items: stretch;
658
+ border: 1px solid var(--cs-line-strong);
659
+ background: var(--cs-surface);
660
+ border-radius: 999px;
661
+ margin: 0 0.25rem 0.25rem 0;
662
+ font-size: 0.8125rem;
663
+ overflow: hidden;
664
+ }
665
+ .cs-chip-label { padding: 0.125rem 0.25rem 0.125rem 0.6875rem; align-self: center; }
666
+ .cs-chip form { display: flex; margin: 0; }
667
+
668
+ /* #90 — orphaned scoped grants (resource deleted) still list as chips/rows but
669
+ open nothing on collection reads since #65. Mark them so operators do not
670
+ trust the grant. Use .cs-inert-badge — NOT .cs-badge (topbar "full access"). */
671
+ .cs-chip--inert {
672
+ border-style: dashed;
673
+ opacity: 0.85;
674
+ background: var(--cs-surface-2);
675
+ }
676
+ .cs-row--inert td { color: var(--cs-muted); }
677
+ /* #134: two states #90's "inert" does not cover. .cs-dead-badge uses a solid
678
+ danger fill where .cs-inert-badge uses a wash of the same token, so the two
679
+ are separable at a glance. Real theme tokens only (--cs-danger, --cs-warn). */
680
+ .cs-dead-badge,
681
+ .cs-check-badge {
682
+ display: inline-block;
683
+ font-size: 0.6875rem;
684
+ font-weight: 600;
685
+ letter-spacing: 0.02em;
686
+ text-transform: uppercase;
687
+ padding: 0.05rem 0.35rem;
688
+ margin-left: 0.35rem;
689
+ border-radius: 0.25rem;
690
+ max-width: 100%;
691
+ }
692
+ .cs-dead-badge {
693
+ color: var(--cs-surface);
694
+ background: var(--cs-danger);
695
+ }
696
+ .cs-check-badge {
697
+ color: var(--cs-warn, #b45309);
698
+ border: 1px solid var(--cs-warn, #b45309);
699
+ background: color-mix(in srgb, var(--cs-warn, #b45309) 12%, var(--cs-surface));
700
+ }
701
+
702
+ /* The caveat must reach keyboard and screen-reader users; a title attribute
703
+ reaches neither reliably. */
704
+ .cs-badge-note {
705
+ position: absolute;
706
+ width: 1px;
707
+ height: 1px;
708
+ padding: 0;
709
+ margin: -1px;
710
+ overflow: hidden;
711
+ clip-path: inset(50%);
712
+ white-space: nowrap;
713
+ border: 0;
714
+ }
715
+
716
+ .cs-inert-badge {
717
+ display: inline-block;
718
+ font-size: 0.6875rem;
719
+ font-weight: 600;
720
+ letter-spacing: 0.02em;
721
+ text-transform: uppercase;
722
+ padding: 0.05rem 0.35rem;
723
+ border-radius: 0.25rem;
724
+ margin-left: 0.35rem;
725
+ vertical-align: middle;
726
+ color: var(--cs-danger, oklch(0.45 0.14 25));
727
+ background: color-mix(in oklch, var(--cs-danger, oklch(0.55 0.18 25)) 12%, transparent);
728
+ border: 1px solid color-mix(in oklch, var(--cs-danger, oklch(0.55 0.18 25)) 35%, transparent);
729
+ }
730
+
731
+ /* Revoke control — a clear, labeled × with a divider so it reads as a button. */
732
+ .cs-chip-remove {
733
+ border: none;
734
+ border-left: 1px solid var(--cs-line);
735
+ border-radius: 0;
736
+ background: none;
737
+ cursor: pointer;
738
+ padding: 0 0.5rem;
739
+ color: var(--cs-muted);
740
+ font-size: 0.95rem;
741
+ line-height: 1;
742
+ transition: background 120ms var(--cs-ease), color 120ms var(--cs-ease);
743
+ }
744
+ .cs-chip-remove:hover, .cs-chip-remove:focus-visible {
745
+ color: var(--cs-on-accent);
746
+ background: var(--cs-danger);
747
+ border-color: var(--cs-danger);
748
+ outline: none;
749
+ }
750
+
751
+ /* "+ scoped role" reads as an add affordance. */
752
+ .cs-add-scoped {
753
+ display: inline-flex;
754
+ align-items: center;
755
+ gap: 0.25rem;
756
+ font-size: 0.8125rem;
757
+ text-decoration: none;
758
+ color: var(--cs-accent);
759
+ padding: 0.125rem 0.4375rem;
760
+ border: 1px dashed var(--cs-line-strong);
761
+ border-radius: 999px;
762
+ }
763
+ .cs-add-scoped:hover { border-color: var(--cs-accent); background: var(--cs-accent-wash); }
764
+
765
+ /* ---- Forms ------------------------------------------------------------ */
766
+
767
+ /* Scoped to the content region: these layout rules must not reach a form the
768
+ host might place in the sidebar or topbar. */
769
+ .cs-main form p { margin: 0 0 1rem; max-width: 30rem; }
770
+
771
+ /* Non-control captions (bulk subjects line) share form-label type. */
772
+ .cs-main form p > .cs-label,
773
+ .cs-main form p > label {
774
+ display: block;
775
+ font-size: 0.7rem;
776
+ font-weight: 600;
777
+ letter-spacing: 0.03em;
778
+ text-transform: uppercase;
779
+ color: var(--cs-muted);
780
+ margin-bottom: 0.375rem;
781
+ }
782
+ .cs-main form p > label:has(input) {
783
+ display: flex;
784
+ align-items: center;
785
+ gap: 0.4375rem;
786
+ font-size: 0.875rem;
787
+ font-weight: 500;
788
+ letter-spacing: 0;
789
+ text-transform: none;
790
+ color: var(--cs-ink);
791
+ cursor: pointer;
792
+ }
793
+
794
+ input[type="text"], input[type="email"], input[type="password"],
795
+ input[type="search"], select, textarea {
796
+ font: inherit;
797
+ color: var(--cs-ink);
798
+ background: var(--cs-bg);
799
+ border: 1px solid var(--cs-line-strong);
800
+ border-radius: var(--cs-radius-sm);
801
+ padding: 0.4375rem 0.625rem;
802
+ max-width: 100%;
803
+ }
804
+ .cs-main form p > input[type="text"],
805
+ .cs-main form p > input[type="email"],
806
+ .cs-main form p > input[type="password"],
807
+ .cs-main form p > select,
808
+ .cs-main form p > textarea { width: 100%; }
809
+ textarea { resize: vertical; min-height: 2.5rem; }
810
+
811
+ input::placeholder { color: var(--cs-muted); opacity: 1; }
812
+ input:focus-visible, select:focus-visible, textarea:focus-visible {
813
+ outline: 2px solid var(--cs-accent);
814
+ outline-offset: 0;
815
+ border-color: var(--cs-accent);
816
+ }
817
+
818
+ /* ---- Buttons ---------------------------------------------------------- */
819
+
820
+ button, input[type="submit"], .cs-btn {
821
+ appearance: none;
822
+ display: inline-flex;
823
+ align-items: center;
824
+ gap: 0.375rem;
825
+ font: 500 0.875rem var(--cs-font);
826
+ color: var(--cs-ink);
827
+ background: var(--cs-surface);
828
+ border: 1px solid var(--cs-line-strong);
829
+ border-radius: var(--cs-radius-sm);
830
+ padding: 0.4375rem 0.875rem;
831
+ cursor: pointer;
832
+ text-decoration: none;
833
+ transition: background 130ms var(--cs-ease), border-color 130ms var(--cs-ease), translate 90ms var(--cs-ease);
834
+ }
835
+ .cs-btn:hover { color: var(--cs-ink); }
836
+ button:hover, input[type="submit"]:hover, .cs-btn:hover { background: var(--cs-surface-2); border-color: var(--cs-ink); }
837
+ button:active, input[type="submit"]:active, .cs-btn:active { translate: 0 1px; }
838
+ button:disabled, input[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
839
+
840
+ /* Primary — the one committed action per form. */
841
+ .cs-btn-primary,
842
+ form > p:last-child input[type="submit"] {
843
+ color: var(--cs-on-accent);
844
+ background: var(--cs-accent);
845
+ border-color: var(--cs-accent);
846
+ }
847
+ .cs-btn-primary:hover,
848
+ form > p:last-child input[type="submit"]:hover {
849
+ background: var(--cs-accent-strong);
850
+ border-color: var(--cs-accent-strong);
851
+ }
852
+
853
+ .cs-btn-danger { color: var(--cs-danger); border-color: var(--cs-danger-line); }
854
+ .cs-btn-danger:hover { color: var(--cs-on-accent); background: var(--cs-danger); border-color: var(--cs-danger); }
855
+
856
+ /* Inline row actions shouldn't shout. */
857
+ .cs-table form { display: inline; margin: 0; }
858
+
859
+ /* ---- Scoped-role picker ----------------------------------------------- */
860
+
861
+ .cs-picker turbo-frame { display: block; }
862
+ .cs-picker-empty { max-width: 30rem; margin: 0 0 1rem; }
863
+ .cs-picker-empty code {
864
+ font-family: var(--cs-mono);
865
+ font-size: 0.8125rem;
866
+ background: var(--cs-surface-2);
867
+ border: 1px solid var(--cs-line);
868
+ border-radius: 3px;
869
+ padding: 0.0625rem 0.25rem;
870
+ }
871
+ .cs-picker-actions { display: flex; align-items: center; gap: 0.625rem; }
872
+ .cs-picker-refresh { font-size: 0.8125rem; color: var(--cs-muted); }
873
+
874
+ /* ---- Pagination ------------------------------------------------------- */
875
+
876
+ .cs-pagination {
877
+ display: flex;
878
+ align-items: center;
879
+ gap: 1rem;
880
+ margin-top: 1rem;
881
+ font-size: 0.8125rem;
882
+ color: var(--cs-muted);
883
+ }
884
+ .cs-pagination a { text-decoration: none; }
885
+
886
+ /* ---- Motion ----------------------------------------------------------- */
887
+
888
+ @media (prefers-reduced-motion: reduce) {
889
+ *, *::before, *::after { transition-duration: 0.01ms !important; }
890
+ }
891
+
892
+ /* ---- Responsive ------------------------------------------------------- *
893
+ * Collapse the sidebar to a top strip on narrow viewports.
894
+ */
895
+
896
+ @media (max-width: 820px) {
897
+ .cs-shell { grid-template-columns: 1fr; }
898
+ .cs-sidebar {
899
+ position: static;
900
+ height: auto;
901
+ flex-direction: row;
902
+ align-items: center;
903
+ flex-wrap: wrap;
904
+ gap: 0.25rem 0.5rem;
905
+ border-right: none;
906
+ border-bottom: 1px solid var(--cs-line);
907
+ }
908
+ .cs-brand { padding: 0.25rem 0.5rem; }
909
+ .cs-nav { flex-direction: row; flex-wrap: wrap; }
910
+ .cs-nav a[aria-current="page"]::before { display: none; }
911
+ .cs-sidebar-foot { display: none; }
912
+ .cs-content { padding: 1.25rem; }
913
+ .cs-topbar { padding: 0.75rem 1.25rem; }
914
+ /* Data tables take their natural width and scroll inside the flush card,
915
+ rather than shrinking columns until cells wrap into very tall rows. */
916
+ .cs-card--flush .cs-table { width: max-content; }
917
+ }