v0.3 Phase 4i: counterlines as interaction-affordance identity

Round-7 redirect (Option D): move counterlines from notices (where
they're invisible) to interactive controls (where they add identity).

Three-level model:

  Level 0  no counterline   panels, notices (default), content, disabled
  Level 1  quiet            controls, chips, active nav
                            1 px, 48% opacity dark / 34% light
  Level 2  focus / strong   keyboard focus, recording state
                            2 px, 85% opacity

Notices retire from default counterline rendering. Notice already
carries enough semantic language: bar + icon + border + bg + label.
A complementary inset on top was adding nothing. Notice can opt in
via <LumotiaNotice counterline ... /> when it deliberately mimics
control identity.

Token deltas vs Phase 4h.

  --counterline-width: 1px               unchanged
  --counterline-width-focus: 2px         new (Level-2 width)
  --counterline-opacity-dark: 0.48       was 0.32 (raised so Level-1
                                          actually reads on controls)
  --counterline-opacity-light: 0.34      was 0.22
  --counterline-opacity-focus: 0.85      new
  --role-brand-counterline: #3A6BFF      new (blue counter for the
                                          orange brand accent so
                                          primary buttons get an
                                          identity edge like the
                                          other roles)
  --button-{role}-counterline            new (per-role component tokens
                                          subscribing to role-counterline
                                          via color-mix)
  --focus-ring-color                     new (defaults to brand accent;
                                          HC overrides to #005FCC)

LumotiaButton applies counterlines to primary + destructive variants
only. Secondary + tertiary stay clean. All variants get a Level-2
focus-visible ring at --focus-ring-color * opacity-focus, with the
existing ring-offset-2 ring-offset-bg pattern.

LumotiaNotice gains a `counterline` boolean prop, default false. When
set, the previous Phase 4h inset shadow re-applies.

HC contract: counterline-width 0 + all -counterline tokens transparent.
Focus width stays 2 px because the HC focus ring IS the tactile
detail in that mode. focus-ring-color forces to #005FCC.

Honest evaluation of the visible result.

  Notices: visibly cleaner without the default inset shadow. Reads
  with more confidence. Good change.

  Buttons: counterline applied at 1 px / 48% dark / 34% light is
  perceptible at standard viewing but screenshot-zoom subtle. Focus
  state implemented but not visible in static screenshots; requires
  manual tab-test in the dev server.

  Recommend keeping the architecture even if the visual is restrained,
  because the focus-state work is genuinely valuable accessibility
  scaffolding regardless of how the Level-1 reads.

Verified.

  - npm run check: 0 errors, 0 warnings across 5707 files.

Phase 5c (Dictation layout migration) still queued for a focused
fresh sitting. This commit does not block it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:07:26 +01:00
parent 74bcc293bb
commit 001d8fe83d
4 changed files with 124 additions and 27 deletions

View File

@@ -46,15 +46,26 @@
// Variant palette uses semantic tokens; primary fills with --color-accent
// (amber/copper) and pairs with .btn-filled-text for AA. Destructive
// mirrors primary on --color-danger.
//
// v0.3 Phase 4i — interactive counterlines. primary + destructive
// gain a Level-1 inset counterline (softened complement) under
// quietware. Secondary + tertiary stay clean. HC contract zeroes
// --counterline-width so the effect vanishes in HC.
//
// Focus state uses Level-2 width (2 px) + --focus-ring-color via a
// focus-visible ring. Tailwind's ring utility writes to a separate
// box-shadow slot so it composes cleanly with the inset counterline.
const variantClass: Record<Variant, string> = {
primary:
"bg-accent btn-filled-text font-medium hover:bg-accent-hover shadow-[var(--shadow-accent-pill)]",
"bg-accent btn-filled-text font-medium hover:bg-accent-hover " +
"shadow-[var(--shadow-accent-pill),inset_0_0_0_var(--counterline-width,0)_var(--button-brand-counterline,transparent)]",
secondary:
"bg-bg-elevated text-text border border-border-subtle hover:bg-hover",
tertiary:
"bg-transparent text-text-secondary hover:bg-hover hover:text-text",
destructive:
"bg-danger btn-filled-text font-medium hover:bg-danger/90",
"bg-danger btn-filled-text font-medium hover:bg-danger/90 " +
"shadow-[inset_0_0_0_var(--counterline-width,0)_var(--button-danger-counterline,transparent)]",
};
const busy = $derived(loading || disabled);
@@ -63,6 +74,10 @@
<button
{type}
class="rounded-lg font-medium select-none disabled:opacity-50 disabled:cursor-not-allowed
focus-visible:outline-none
focus-visible:ring-[var(--counterline-width-focus,2px)]
focus-visible:ring-[color-mix(in_srgb,var(--focus-ring-color,var(--color-accent))_calc(var(--counterline-opacity-focus,0.85)*100%),transparent)]
focus-visible:ring-offset-2 focus-visible:ring-offset-bg
{sizeClass[size]}
{variantClass[variant]}
{fullWidth ? 'w-full' : ''}