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:
@@ -351,7 +351,41 @@ LumotiaNotice now reads these via Tailwind arbitrary-value classes (`bg-[var(--n
|
|||||||
|
|
||||||
Per Jake's round-5 spec: yellow stays yellow (visible signal), ochre only appears as small-text ink, and brown/khaki never represents the caution role's primary identity again.
|
Per Jake's round-5 spec: yellow stays yellow (visible signal), ochre only appears as small-text ink, and brown/khaki never represents the caution role's primary identity again.
|
||||||
|
|
||||||
### Phase 4h — Counterlines test (TBD: keep or remove). Landed 2026-05-15.
|
### Phase 4i — Counterlines as interaction-affordance identity. Landed 2026-05-15.
|
||||||
|
|
||||||
|
Round-7 redirect (Option D): move counterlines from notices (where they're invisible) to interactive controls (where they add identity). Three-level model:
|
||||||
|
|
||||||
|
| Level | Surface | Width | Opacity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 — none | panels, notices (default), content, disabled | — | — |
|
||||||
|
| 1 — quiet | controls, chips, active nav | 1 px | 48% dark / 34% light |
|
||||||
|
| 2 — focus | keyboard focus, recording state | 2 px | 85% |
|
||||||
|
|
||||||
|
Notice retires from default counterline rendering. Opt-in via `<LumotiaNotice counterline ... />` when a specific notice deliberately mimics control identity.
|
||||||
|
|
||||||
|
LumotiaButton primary + destructive variants gain a Level-1 inset counterline:
|
||||||
|
|
||||||
|
- primary: `--button-brand-counterline` (softened blue, complement of brand amber)
|
||||||
|
- destructive: `--button-danger-counterline` (softened blue, complement of red)
|
||||||
|
- secondary / tertiary: no counterline (clean neutral)
|
||||||
|
- All variants on `:focus-visible`: 2-px ring at `--focus-ring-color` (default `--color-accent`) at 85% opacity. HC overrides to a strong blue `#005FCC`.
|
||||||
|
|
||||||
|
Token deltas vs Phase 4h:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--counterline-width: 1px /* unchanged */
|
||||||
|
--counterline-width-focus: 2px /* new — Level-2 width */
|
||||||
|
--counterline-opacity-dark: 0.48 /* was 0.32 */
|
||||||
|
--counterline-opacity-light: 0.34 /* was 0.22 */
|
||||||
|
--counterline-opacity-focus: 0.85 /* new */
|
||||||
|
--role-brand-counterline: #3A6BFF /* new — blue counter for orange brand */
|
||||||
|
--button-{role}-counterline /* new — per-role component tokens */
|
||||||
|
--focus-ring-color /* new — context-overridable focus tint */
|
||||||
|
```
|
||||||
|
|
||||||
|
HC contract: every counterline token forced transparent, `--counterline-width: 0`, focus width kept at 2 px (HC focus ring carries the tactile detail in that mode), `--focus-ring-color: #005FCC`.
|
||||||
|
|
||||||
|
### Phase 4h — Counterlines test (RETIRED 2026-05-15, superseded by Phase 4i). Landed 2026-05-15.
|
||||||
|
|
||||||
Test commit per Jake's round-6 spec. Subtle 1-px inner-shadow detail in a softened complementary hue, applied to LumotiaNotice only. Other test surfaces (Button.destructive, status pills, record ring, active nav) reserved for after evaluation.
|
Test commit per Jake's round-6 spec. Subtle 1-px inner-shadow detail in a softened complementary hue, applied to LumotiaNotice only. Other test surfaces (Button.destructive, status pills, record ring, active nav) reserved for after evaluation.
|
||||||
|
|
||||||
|
|||||||
@@ -219,29 +219,53 @@
|
|||||||
--quietware-texture-color: rgba(243, 239, 231, 1);
|
--quietware-texture-color: rgba(243, 239, 231, 1);
|
||||||
|
|
||||||
/* ----------------------------------------------------------
|
/* ----------------------------------------------------------
|
||||||
Phase 4h — counterlines (test).
|
Phase 4i — counterlines as interactive-affordance identity
|
||||||
Tactile-only 1-px inner-shadow detail using a softened
|
(per round-7 redirect). Three-level model:
|
||||||
complementary hue. NOT a second semantic signal — meaning is
|
|
||||||
still carried by signal + icon + label. HC contract zeroes
|
Level 0 no counterline (panels, notices, content, disabled)
|
||||||
the width.
|
Level 1 quiet (controls, chips, active nav)
|
||||||
|
Level 2 strong / focus (keyboard focus, recording state)
|
||||||
|
|
||||||
|
Notices retire from default counterline rendering (they have
|
||||||
|
enough semantic language already: bar + icon + border + bg +
|
||||||
|
label). Notice can still opt in via the `counterline` prop.
|
||||||
|
|
||||||
|
HC contract zeroes the normal width but keeps the focus width
|
||||||
|
because the HC focus ring IS the tactile detail in that mode.
|
||||||
---------------------------------------------------------- */
|
---------------------------------------------------------- */
|
||||||
--counterline-width: 1px;
|
--counterline-width: 1px;
|
||||||
|
--counterline-width-focus: 2px;
|
||||||
|
--counterline-opacity-dark: 0.48;
|
||||||
|
--counterline-opacity-light: 0.34;
|
||||||
|
--counterline-opacity-focus: 0.85;
|
||||||
|
|
||||||
/* Raw complementary hues per Jake's spec. */
|
/* Raw complementary hues. Brand orange gets its own counterline
|
||||||
|
(soft blue) so primary buttons land in the same identity as the
|
||||||
|
other roles. */
|
||||||
--role-info-counterline: var(--color-accent);
|
--role-info-counterline: var(--color-accent);
|
||||||
--role-danger-counterline: #6EA8FF;
|
--role-danger-counterline: #6EA8FF;
|
||||||
--role-success-counterline: #B891FF;
|
--role-success-counterline: #B891FF;
|
||||||
--role-caution-counterline: #3A6BFF;
|
--role-caution-counterline: #3A6BFF;
|
||||||
|
--role-brand-counterline: #3A6BFF;
|
||||||
|
|
||||||
/* Tinted per theme. Dark mode uses 32% opacity (~slightly stronger
|
/* Component usage tokens. Buttons + chips + nav subscribe to these.
|
||||||
so it reads against the dark surface). Per-usage aliases so
|
Default opacity is dark; light-mode override below replaces. */
|
||||||
components subscribe semantically. */
|
--button-brand-counterline: color-mix(in srgb, var(--role-brand-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
--notice-info-counterline: color-mix(in srgb, var(--role-info-counterline) 32%, transparent);
|
--button-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
--notice-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) 32%, transparent);
|
--button-info-counterline: color-mix(in srgb, var(--role-info-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
--notice-success-counterline: color-mix(in srgb, var(--role-success-counterline) 32%, transparent);
|
--button-success-counterline: color-mix(in srgb, var(--role-success-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
--notice-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) 32%, transparent);
|
--button-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
|
|
||||||
--button-danger-counterline: var(--notice-danger-counterline);
|
/* Notice-level counterline tokens kept available for opt-in only.
|
||||||
|
LumotiaNotice no longer applies them by default per round-7. */
|
||||||
|
--notice-info-counterline: color-mix(in srgb, var(--role-info-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
|
--notice-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
|
--notice-success-counterline: color-mix(in srgb, var(--role-success-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
|
--notice-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) calc(var(--counterline-opacity-dark) * 100%), transparent);
|
||||||
|
|
||||||
|
/* Focus ring colour — defaults to brand accent. Components can
|
||||||
|
override per-context (eg. info focus = info-counterline). */
|
||||||
|
--focus-ring-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -325,14 +349,18 @@
|
|||||||
--quietware-texture-opacity: var(--grain-opacity);
|
--quietware-texture-opacity: var(--grain-opacity);
|
||||||
--quietware-texture-color: rgba(29, 27, 24, 1);
|
--quietware-texture-color: rgba(29, 27, 24, 1);
|
||||||
|
|
||||||
/* Phase 4h light-mode counterlines. Lower opacity (22%) so the
|
/* Phase 4i light-mode counterlines. Lower opacity per Jake's spec
|
||||||
detail stays whisper-quiet against the cream surface. */
|
so the detail stays restrained against the cream surface. */
|
||||||
--notice-info-counterline: color-mix(in srgb, var(--role-info-counterline) 22%, transparent);
|
--button-brand-counterline: color-mix(in srgb, var(--role-brand-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
--notice-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) 22%, transparent);
|
--button-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
--notice-success-counterline: color-mix(in srgb, var(--role-success-counterline) 22%, transparent);
|
--button-info-counterline: color-mix(in srgb, var(--role-info-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
--notice-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) 22%, transparent);
|
--button-success-counterline: color-mix(in srgb, var(--role-success-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
|
--button-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
|
|
||||||
--button-danger-counterline: var(--notice-danger-counterline);
|
--notice-info-counterline: color-mix(in srgb, var(--role-info-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
|
--notice-danger-counterline: color-mix(in srgb, var(--role-danger-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
|
--notice-success-counterline: color-mix(in srgb, var(--role-success-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
|
--notice-caution-counterline: color-mix(in srgb, var(--role-caution-counterline) calc(var(--counterline-opacity-light) * 100%), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -418,14 +446,28 @@
|
|||||||
--quietware-texture-opacity: 0;
|
--quietware-texture-opacity: 0;
|
||||||
--quietware-texture-color: rgba(0, 0, 0, 0);
|
--quietware-texture-color: rgba(0, 0, 0, 0);
|
||||||
|
|
||||||
/* HC contract: counterlines disabled (the 2-px HC border IS the
|
/* HC contract: counterlines disabled for normal states. The HC
|
||||||
tactile detail in this mode). */
|
focus ring (2 px, full strong-blue) IS the tactile detail in
|
||||||
|
this mode. */
|
||||||
--counterline-width: 0;
|
--counterline-width: 0;
|
||||||
|
--counterline-width-focus: 2px;
|
||||||
|
--counterline-opacity-dark: 0;
|
||||||
|
--counterline-opacity-light: 0;
|
||||||
|
--counterline-opacity-focus: 1;
|
||||||
|
|
||||||
--notice-info-counterline: transparent;
|
--notice-info-counterline: transparent;
|
||||||
--notice-danger-counterline: transparent;
|
--notice-danger-counterline: transparent;
|
||||||
--notice-success-counterline: transparent;
|
--notice-success-counterline: transparent;
|
||||||
--notice-caution-counterline: transparent;
|
--notice-caution-counterline: transparent;
|
||||||
|
|
||||||
|
--button-brand-counterline: transparent;
|
||||||
--button-danger-counterline: transparent;
|
--button-danger-counterline: transparent;
|
||||||
|
--button-info-counterline: transparent;
|
||||||
|
--button-success-counterline: transparent;
|
||||||
|
--button-caution-counterline: transparent;
|
||||||
|
|
||||||
|
/* HC focus ring is the brand-strong blue, not a complement. */
|
||||||
|
--focus-ring-color: #005FCC;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark-base high-contrast — black background, white borders. Used
|
/* Dark-base high-contrast — black background, white borders. Used
|
||||||
|
|||||||
@@ -46,15 +46,26 @@
|
|||||||
// Variant palette uses semantic tokens; primary fills with --color-accent
|
// Variant palette uses semantic tokens; primary fills with --color-accent
|
||||||
// (amber/copper) and pairs with .btn-filled-text for AA. Destructive
|
// (amber/copper) and pairs with .btn-filled-text for AA. Destructive
|
||||||
// mirrors primary on --color-danger.
|
// 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> = {
|
const variantClass: Record<Variant, string> = {
|
||||||
primary:
|
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:
|
secondary:
|
||||||
"bg-bg-elevated text-text border border-border-subtle hover:bg-hover",
|
"bg-bg-elevated text-text border border-border-subtle hover:bg-hover",
|
||||||
tertiary:
|
tertiary:
|
||||||
"bg-transparent text-text-secondary hover:bg-hover hover:text-text",
|
"bg-transparent text-text-secondary hover:bg-hover hover:text-text",
|
||||||
destructive:
|
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);
|
const busy = $derived(loading || disabled);
|
||||||
@@ -63,6 +74,10 @@
|
|||||||
<button
|
<button
|
||||||
{type}
|
{type}
|
||||||
class="rounded-lg font-medium select-none disabled:opacity-50 disabled:cursor-not-allowed
|
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]}
|
{sizeClass[size]}
|
||||||
{variantClass[variant]}
|
{variantClass[variant]}
|
||||||
{fullWidth ? 'w-full' : ''}
|
{fullWidth ? 'w-full' : ''}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
/** Slim variant collapses to a single line with no title block.
|
/** Slim variant collapses to a single line with no title block.
|
||||||
For ambient page-level notices (eg. "You're in browser preview"). */
|
For ambient page-level notices (eg. "You're in browser preview"). */
|
||||||
slim?: boolean;
|
slim?: boolean;
|
||||||
|
/** v0.3 Phase 4i opt-in: render the tactile counterline as an
|
||||||
|
inset shadow. Off by default — notices already carry enough
|
||||||
|
semantic language without the extra decoration. Reserve for
|
||||||
|
cases where the notice deliberately mimics control identity. */
|
||||||
|
counterline?: boolean;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
classes?: string;
|
classes?: string;
|
||||||
}
|
}
|
||||||
@@ -45,6 +50,7 @@
|
|||||||
dismissible = false,
|
dismissible = false,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
slim = false,
|
slim = false,
|
||||||
|
counterline = false,
|
||||||
children,
|
children,
|
||||||
classes = "",
|
classes = "",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -112,7 +118,7 @@
|
|||||||
role={ariaRole}
|
role={ariaRole}
|
||||||
class="rounded-r-lg rounded-l-sm border border-l-4 text-[13px] flex gap-2.5
|
class="rounded-r-lg rounded-l-sm border border-l-4 text-[13px] flex gap-2.5
|
||||||
{slim ? 'px-3 py-2 items-center' : 'px-4 py-3 items-start'}
|
{slim ? 'px-3 py-2 items-center' : 'px-4 py-3 items-start'}
|
||||||
{tonePal.bar} {tonePal.border} {tonePal.bg} {tonePal.shadow} {classes}"
|
{tonePal.bar} {tonePal.border} {tonePal.bg} {counterline ? tonePal.shadow : ''} {classes}"
|
||||||
>
|
>
|
||||||
<ToneIcon size={slim ? 14 : 16} class="shrink-0 {slim ? '' : 'mt-0.5'} {tonePal.iconColor}" aria-hidden="true" />
|
<ToneIcon size={slim ? 14 : 16} class="shrink-0 {slim ? '' : 'mt-0.5'} {tonePal.iconColor}" aria-hidden="true" />
|
||||||
<div class="flex-1 min-w-0 leading-relaxed text-text">
|
<div class="flex-1 min-w-0 leading-relaxed text-text">
|
||||||
|
|||||||
Reference in New Issue
Block a user