La auditoria del tier tenia los veredictos desemparejados de sus hallazgos: un join por POSICION, y el journal del workflow era de sesion. Reproducido en vivo al re-verificar — el journal devuelve los resultados en otro orden que la entrada. Ledger nuevo con 90 ids estables (AUDIT-blocks-ledger.md), union SIEMPRE por id: 41 arreglados, 34 confirmados, 12 refutados con motivo escrito, 3 dato. La tasa real de refutacion es del 13%, no del 28%. De las 12 ALTA de percepcion, 8 tenian la causa raiz en el CANON. Los blocks componen bien; lo que estaba roto era el arco perceptivo. CANON - Link: la prop color era inerte en subtle/plain (inherit a 0-2-0 ganaba a la paleta a 0-1-0), el hover clavaba primary y el active quedaba tapado por el hover de variante. Medido: el enlace del footer pasa de la tinta del padre a la suya. - Card: prometia BoxProps y no los aplicaba — height="100%" era un atributo inerte. El tipo dice la verdad y el eje de tamano se aplica con un helper compartido (buildSizeStyle). Tarjetas al fin de igual alto. - Form.Submit / Form.Reset: componen el Button del canon, asi que la accion principal de un formulario recupera el contact-activate en el gesto. Y el aria-label generico deja de pisar el texto propio (WCAG 2.5.3 para todo consumidor), verificado con el AX tree de Chrome. - Field: dejaba de duplicar commit-submit dentro de un Form — un Enter emitia dos commits con intents contradictorios y dos earcons. - NavigationMenu: commit-select se mueve al Link (navegar es el acto evaluable), el despliegue habla como emerge-open/close en vez de fingir una seleccion por hover, y el pack casa por fin con la parte que recibe el estampado — antes sonaba a la ganancia base, 10x lo disenado. - Badge: el boton de quitar compone IconButton; la altura del chip pasa a ser la del control, asi que md significa lo mismo en todo el sistema. - Fundacion: [data-on] arrastra la propiedad color, no solo las variables. BLOCKS - hero: la CTA secundaria pasa de 1.61:1 a 6.61:1. - contact: separa incomplete de invalid — el camino de error por campo era inalcanzable por construccion — y su frase llega a la AT. - newsletter: coordina (fase 4 del plan). Maquina de cinco estados, palabras propias, Submit y Reason como partes que leen el contexto. - site-header ya no congela la pagina al cruzar el breakpoint; site-footer emite su commit; pricing no se vacia; cta llega a sangre de verdad. DOCTRINA - La frontera dura 1 nombra $libs/forms como puerta sancionada. - El contrato B admite un segundo servicio: el anunciador. Un block que posee las palabras de sus estados tiene que poder decirlas. Gates: blocks:check 15/0 · vitest 20/20 en blocks y 402/402 en eidos+blocks · svelte-check 75/54 (linea base) · docs:check 0/0 · prettier limpio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>alpha-0.1-dir-prefs
parent
c46a955a0f
commit
866089a407
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { NewsletterState } from './state';
|
||||
|
||||
/**
|
||||
* What `Newsletter` shares with its parts. The section's state is derived ONCE at
|
||||
* the root and read from here — no part recomputes it, and no part invents a
|
||||
* `disabled` of its own.
|
||||
*/
|
||||
export interface NewsletterContext {
|
||||
/** The section's state. A getter, so reads track the reactive source. */
|
||||
readonly state: NewsletterState;
|
||||
/** The id of the reason line, for `aria-describedby` from the action. */
|
||||
readonly reasonId: string;
|
||||
/** Resolve an idlangref through the active translator. */
|
||||
t(ref: string): string;
|
||||
}
|
||||
|
||||
const KEY = Symbol('uix.newsletter');
|
||||
|
||||
export function setNewsletterContext(ctx: NewsletterContext): void {
|
||||
setContext(KEY, ctx);
|
||||
}
|
||||
|
||||
export function getNewsletterContext(): NewsletterContext | undefined {
|
||||
return getContext<NewsletterContext | undefined>(KEY);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Newsletter.Reason — why the section is where it is.
|
||||
*
|
||||
* The sentence comes from the SAME map that blocks the action, so a state that
|
||||
* stops the subscribe without saying why cannot be added: the record in
|
||||
* `state.ts` is exhaustive.
|
||||
*
|
||||
* It reaches assistive tech through the system's announcer (`uix.announce`),
|
||||
* the second service the B contract sanctions for a block — never through a
|
||||
* live region of its own, which would re-implement `Announce` and put a second
|
||||
* announcer on the page. Same shape as `Contact.Reason`.
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { getNewsletterContext } from './context';
|
||||
import { NEWSLETTER_REASON } from './state';
|
||||
import type { NewsletterReasonProps } from './types';
|
||||
|
||||
let { size = 'sm', ...rest }: NewsletterReasonProps = $props();
|
||||
|
||||
const newsletter = getNewsletterContext();
|
||||
const eidos = ActiveEidos.require();
|
||||
const state = $derived(newsletter?.state ?? 'incomplete');
|
||||
const ref = $derived(NEWSLETTER_REASON[state]);
|
||||
const sentence = $derived(ref && newsletter ? newsletter.t(ref) : '');
|
||||
|
||||
// `sent` is the one reason that is good news; the rest are things still missing.
|
||||
const color = $derived(state === 'sent' ? 'affirm' : 'muted');
|
||||
|
||||
let announced = '';
|
||||
$effect(() => {
|
||||
const text = sentence;
|
||||
if (!text || text === announced) return;
|
||||
announced = text;
|
||||
eidos.uix?.announce(text, 'polite');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if newsletter}
|
||||
<Text as="p" id={newsletter.reasonId} {size} {color} {...rest}>
|
||||
{sentence}
|
||||
</Text>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Newsletter.Submit — the subscribe action, disabled and labelled BY THE
|
||||
* SECTION'S STATE.
|
||||
*
|
||||
* This is why the block earns a part here at all: the tier's rule says compound
|
||||
* is earned when parts REPEAT or COORDINATE, and this one coordinates — it
|
||||
* reads the state from context instead of taking a `disabled` the app assembled
|
||||
* at the call site. That expression, re-derived by every consumer, is exactly
|
||||
* what left an action blocked with nothing on screen saying why.
|
||||
*
|
||||
* The state word also goes in `aria-label`, and the reason in
|
||||
* `aria-describedby`: the canon `Form.Submit` no longer stamps a generic label
|
||||
* over the button's own text, but an explicit one still wins, and it is what
|
||||
* carries «Subscribing…» / «Subscribed» to assistive tech.
|
||||
*/
|
||||
import { Form } from '$uix/eidos/components/form';
|
||||
import { getNewsletterContext } from './context';
|
||||
import { NEWSLETTER_ACTION, canSubscribe } from './state';
|
||||
import type { NewsletterSubmitProps } from './types';
|
||||
|
||||
let { color = 'fulfill', children, ...rest }: NewsletterSubmitProps = $props();
|
||||
|
||||
const newsletter = getNewsletterContext();
|
||||
const state = $derived(newsletter?.state ?? 'incomplete');
|
||||
const label = $derived(newsletter?.t(NEWSLETTER_ACTION[state]) ?? '');
|
||||
</script>
|
||||
|
||||
<Form.Submit
|
||||
{...rest}
|
||||
{color}
|
||||
disabled={!canSubscribe(state)}
|
||||
aria-label={label}
|
||||
aria-describedby={newsletter?.reasonId}
|
||||
>
|
||||
{#if children}{@render children({ state })}{:else}{label}{/if}
|
||||
</Form.Submit>
|
||||
@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
resolveNewsletterState,
|
||||
canSubscribe,
|
||||
NEWSLETTER_REASON,
|
||||
NEWSLETTER_ACTION,
|
||||
type NewsletterState,
|
||||
type NewsletterStateInput
|
||||
} from './state';
|
||||
|
||||
/**
|
||||
* The second coordinating block of the tier, and the same two properties worth
|
||||
* locking as in `contact`: the priority order, and the impossibility of a mute
|
||||
* blocked state.
|
||||
*/
|
||||
|
||||
const base: NewsletterStateInput = {
|
||||
hasValue: false,
|
||||
isValid: false,
|
||||
isPending: false,
|
||||
sent: false
|
||||
};
|
||||
|
||||
const filled = { ...base, hasValue: true, isValid: true } as const;
|
||||
|
||||
const ALL_STATES: NewsletterState[] = ['incomplete', 'invalid', 'ready', 'sending', 'sent'];
|
||||
|
||||
describe('resolveNewsletterState', () => {
|
||||
it('an untouched field is incomplete, not invalid', () => {
|
||||
// Asking the schema about an empty string would call it invalid and the
|
||||
// section would open by telling the reader they got it wrong before typing.
|
||||
expect(resolveNewsletterState(base)).toBe('incomplete');
|
||||
});
|
||||
|
||||
it('separates «nothing typed» from «typed wrong»', () => {
|
||||
expect(resolveNewsletterState({ ...base, hasValue: true, isValid: false })).toBe('invalid');
|
||||
expect(resolveNewsletterState(filled)).toBe('ready');
|
||||
});
|
||||
|
||||
it('what already happened wins over what is missing', () => {
|
||||
expect(resolveNewsletterState({ ...base, sent: true })).toBe('sent');
|
||||
expect(resolveNewsletterState({ ...base, isPending: true })).toBe('sending');
|
||||
// Even mid-flight with a value the machine reports the flight, not the value.
|
||||
expect(resolveNewsletterState({ ...filled, isPending: true })).toBe('sending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSubscribe', () => {
|
||||
it('lets ready AND invalid through', () => {
|
||||
// `invalid` is deliberate: gating on validity makes the field's own error
|
||||
// unreachable, because `progressive` only writes it after a failed submit.
|
||||
expect(ALL_STATES.filter(canSubscribe)).toEqual(['invalid', 'ready']);
|
||||
});
|
||||
|
||||
it('never lets one through while something already happened', () => {
|
||||
for (const state of ['sending', 'sent'] as const) {
|
||||
expect(canSubscribe(state), `${state} must not submit`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the words', () => {
|
||||
it('every blocked state says why', () => {
|
||||
for (const state of ALL_STATES) {
|
||||
if (canSubscribe(state) || state === 'sending') continue;
|
||||
expect(NEWSLETTER_REASON[state], `${state} has no reason`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('every state names its action', () => {
|
||||
for (const state of ALL_STATES) expect(NEWSLETTER_ACTION[state]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the words are idlangrefs with an english fallback', () => {
|
||||
const refs = [...Object.values(NEWSLETTER_REASON), ...Object.values(NEWSLETTER_ACTION)].filter(
|
||||
(v): v is string => v !== null
|
||||
);
|
||||
for (const ref of refs) expect(ref).toMatch(/^#\?blocks\.newsletter\.[a-z.]+\|.+/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The sign-up section's state — ONE machine, derived from every source that can
|
||||
* block a subscribe.
|
||||
*
|
||||
* Why this block coordinates at all: it places an action that can be blocked, and
|
||||
* before this the `disabled` was assembled by each app at the point of use. That
|
||||
* is how holes appear — a button that cannot be pressed and nothing on screen
|
||||
* saying why. The same reading `contact` settled on 2026-07-31, applied to the
|
||||
* block the doctrine explicitly left open.
|
||||
*/
|
||||
|
||||
/** The section's state. Exhaustive by construction. */
|
||||
export type NewsletterState =
|
||||
/** Nothing typed yet. */
|
||||
| 'incomplete'
|
||||
/** Something typed, but it is not an address the schema accepts. */
|
||||
| 'invalid'
|
||||
/** Good to go. */
|
||||
| 'ready'
|
||||
/** The submit handler is running. */
|
||||
| 'sending'
|
||||
/** It went out. */
|
||||
| 'sent';
|
||||
|
||||
export interface NewsletterStateInput {
|
||||
/** Has the reader typed anything at all? Asked of the VALUE, not of the schema:
|
||||
* «you have not written yet» and «what you wrote is wrong» are different
|
||||
* blockers and need different sentences. */
|
||||
readonly hasValue: boolean;
|
||||
/**
|
||||
* Does the value satisfy the shape? NOT `form.isValid` — under progressive
|
||||
* validation that means «nothing has been found wrong YET», so an untouched
|
||||
* empty form reports valid and `incomplete` would be unreachable at load. The
|
||||
* root asks the schema (`validateSync`), which answers without writing errors
|
||||
* and therefore without lighting the field red on first paint.
|
||||
*/
|
||||
readonly isValid: boolean;
|
||||
/** `form.isPending` — true while an async `onValidSubmit` runs. */
|
||||
readonly isPending: boolean;
|
||||
/** The app flipped it after a successful subscribe. */
|
||||
readonly sent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order is the priority: what already happened wins over what is missing.
|
||||
*/
|
||||
export function resolveNewsletterState(input: NewsletterStateInput): NewsletterState {
|
||||
if (input.sent) return 'sent';
|
||||
if (input.isPending) return 'sending';
|
||||
if (!input.hasValue) return 'incomplete';
|
||||
if (!input.isValid) return 'invalid';
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the section can be submitted in this state.
|
||||
*
|
||||
* `invalid` goes through ON PURPOSE, same as in `contact`: with progressive
|
||||
* validation the form only writes field errors after a failed submit, so gating
|
||||
* the button on validity makes the per-field error path unreachable by
|
||||
* construction — the reader gets a dead button and no marked field. Letting the
|
||||
* submit run hands the job to the canon `Form`, which marks the offender, focuses
|
||||
* it and emits `signal-warn-invalid`.
|
||||
*/
|
||||
export function canSubscribe(state: NewsletterState): boolean {
|
||||
return state === 'ready' || state === 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* The idlangref for each state's reason. `null` means «nothing to explain».
|
||||
*
|
||||
* Fallbacks are English, like the canon's own `texts:`; an app translates by
|
||||
* registering `blocks.newsletter.*` in its langs schema.
|
||||
*/
|
||||
export const NEWSLETTER_REASON: Record<NewsletterState, string | null> = {
|
||||
incomplete: '#?blocks.newsletter.reason.incomplete|Enter your email to subscribe.',
|
||||
invalid: '#?blocks.newsletter.reason.invalid|That does not look like an email address.',
|
||||
ready: null,
|
||||
sending: null,
|
||||
sent: '#?blocks.newsletter.reason.sent|You are in. Check your inbox to confirm.'
|
||||
};
|
||||
|
||||
/** The submit label per state — the action names what is happening. */
|
||||
export const NEWSLETTER_ACTION: Record<NewsletterState, string> = {
|
||||
incomplete: '#?blocks.newsletter.action.subscribe|Subscribe',
|
||||
invalid: '#?blocks.newsletter.action.subscribe|Subscribe',
|
||||
ready: '#?blocks.newsletter.action.subscribe|Subscribe',
|
||||
sending: '#?blocks.newsletter.action.sending|Subscribing…',
|
||||
sent: '#?blocks.newsletter.action.sent|Subscribed'
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue