Compare commits

..

2 Commits

Author SHA1 Message Date
pigui
e9082b90ee Restore the numeric field's left inset
The value in "Horas por semana" sat 12px to the left of every other
field -- and, more visibly, 12px left of its own label, which stayed
where the others were.

It is a defect in @pi/ui, measured rather than guessed:

  field           has-icon-prefix   prefix   value starts at
  text-field      false             --       16px
  email-field     false             --       16px
  number-field    true              4px      4px

`pi-number-field` declares `<ng-container matPrefix>` unconditionally,
so Material treats the field as having an icon prefix even with nothing
projected into it. The empty 4px prefix displaces the 16px inset the
other fields get. Only two of the library's twenty field templates
declare a prefix, both numeric, and only `incremental-number-field`
actually fills one -- so it is an oversight, not a pattern.

This is the stopgap, marked as such and scoped to that field. The real
fix is already written in the library (`pireon-ui`,
`number-field.component.html`, guarded by a `contentChild`) but is not
built or published yet; this rule comes out when that version lands.

Verified in both themes: all four fields now start their value and their
label at 16px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:36:00 +02:00
pigui
aea8f5675d Make the button-shaped links actual links
"Web oficial" did nothing when clicked, and it was not alone: six places
wrapped `<pi-action-button>` in an `<a>`. That component renders a
`<button>`, so each one was a button inside a link -- invalid HTML, and
the button swallows the click. Besides "Web oficial" it covered Demo,
Repositorio, the contact CTA on every detail page, the 404's way home
and a call card's apply button.

They become one `app-link-button`, which renders a real anchor. The
click was only part of it: a link can also be opened in a new tab,
middle-clicked, copied, tabbed to and followed by a crawler.

Its look does not come from @pi/ui's button classes, and not for want of
trying: those rules are scoped to the element (`html button:not(…)` in
`_pi-button.scss`), so on an anchor they contribute nothing except
`pi-color__primary` -- a *text* colour, which painted the label violet on
a violet fill. The stylesheet reads the same `--pi-theme-button-*`
tokens the library reads instead, so a change of shell or theme still
moves these buttons and dark mode needs nothing of its own.

Verified: the anchor reports href, target and a real navigation to the
external site; no `<button>` inside an `<a>` remains on the page; and
the rendered pill matches the header's button in radius, gradient fill,
white text and weight, in both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:07:15 +02:00
8 changed files with 225 additions and 58 deletions

View File

@@ -16,6 +16,7 @@ import type { Copy } from '../core/copy';
import type { Locale } from '../core/api';
import type { PublicCall } from '../core/api.types';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
@Component({
selector: 'app-call-card',
@@ -32,6 +33,7 @@ import { TechnologyChip } from './technology-chip';
PiTextColorDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
template: `
<pi-card
@@ -96,15 +98,13 @@ import { TechnologyChip } from './technology-chip';
<!-- To the call's own page and its form when it has one; to the
landing's general form otherwise. -->
@if (call().path; as path) {
<a class="landing-card__button-link" [href]="path">
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
/>
</a>
<app-link-button
class="landing-card__button-link"
size="small"
appearance="outline"
[href]="path"
[text]="copy().labels.apply"
/>
} @else {
<pi-action-button
size="small"

View File

@@ -4,11 +4,12 @@ import {
computed,
input,
} from '@angular/core';
import { PiActionButtonComponent, PiTextColorDirective } from '@pi/ui';
import { PiTextColorDirective } from '@pi/ui';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes';
import { LinkButton } from './link-button';
/**
* "Shall we talk?" at the end of a page's side column, so no page is a dead
@@ -17,20 +18,18 @@ import { homeHref } from '../core/routes';
@Component({
selector: 'app-contact-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiTextColorDirective],
imports: [PiTextColorDirective, LinkButton],
template: `
<div class="landing-card contact-cta">
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<!-- An anchor around the library's button, which renders a <button>:
a real link, so it works before hydration and opens in a new tab. -->
<a class="contact-cta__link" [href]="href()">
<pi-action-button
color="primary"
[text]="copy().nav.contact"
[hasIcon]="true"
/>
</a>
<app-link-button
class="contact-cta__link"
[href]="href()"
[text]="copy().nav.contact"
/>
</div>
`,
styles: `

View File

@@ -130,3 +130,26 @@
font-size: var(--pi-font-size-x-small, 0.75rem);
}
}
// TEMPORARY. Remove when @pi/ui ships the fix.
//
// `pi-number-field` declares `<ng-container matPrefix>` unconditionally, so
// Material marks the field as having an icon prefix even though nothing is
// projected into it. The empty prefix is 4px wide and takes the place of the
// 16px inset every other field gets: the value ended up 12px to the left of
// its own label, which is what makes it look wrong.
//
// Only two of the library's twenty fields declare a prefix, both numeric, and
// only `incremental-number-field` actually uses one — so this is an oversight
// rather than a pattern. The fix belongs there and is written:
// `pireon-ui`, `number-field.component.html`, guarded by a `contentChild`.
// This restores the inset here until that version is published.
:host ::ng-deep .pi-form-field__number {
.mat-mdc-form-field-icon-prefix {
display: none;
}
.mat-mdc-form-field-infix {
padding-left: 16px;
}
}

View File

@@ -0,0 +1,79 @@
:host {
display: inline-flex;
}
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
// still the library's: these are the same `--pi-theme-button-*` custom
// properties its own rules read, so switching shell or theme moves this button
// too, and dark mode needs nothing of its own.
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 5.5rem;
border: none;
border-radius: var(--pi-theme-button-radius);
line-height: 100%;
text-decoration: none;
letter-spacing: var(--pi-theme-button-letter-spacing);
text-transform: var(--pi-theme-button-text-transform);
font-weight: var(--pi-theme-button-font-weight);
transition: var(--pi-theme-button-transition);
cursor: pointer;
svg {
flex: none;
width: 0.375rem;
height: 0.5625rem;
}
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 3px;
}
}
.link-button--medium,
.link-button--large {
min-height: var(--pi-theme-button-height);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
}
.link-button--small {
min-width: 4.5rem;
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
font-size: 0.8125rem;
}
// Filled. The theme may inject a gradient over the flat colour, exactly as it
// does for a real button.
.link-button--fill {
color: var(--pi-color-white);
box-shadow: var(--pi-theme-button-shadow);
&.link-button--primary {
background-color: var(--pi-color-primary);
background-image: var(--pi-theme-button-fill-primary, none);
}
&.link-button--grey {
background-color: var(--pi-color-grey);
}
}
// Outline. The colour is the border and the text; the surface stays clear.
.link-button--outline {
background: none;
&.link-button--primary {
border: 1px solid var(--pi-color-primary);
color: var(--pi-color-primary);
}
&.link-button--grey {
border: 1px solid var(--pi-color-grey-200);
color: var(--pi-color-grey);
}
}

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/**
* A link that looks like the library's action button.
*
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
* button inside a link: invalid HTML, and the button swallows the click, so
* nothing navigates. That is what stopped "Web oficial" working, and the same
* pattern sat in five other places.
*
* Making it an anchor is not only about the click. A real link can be opened
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
* crawler; a button can do none of that.
*
* The library's own button classes are deliberately NOT reused. Its rules are
* scoped to the element — `html button:not(…)` — so on an anchor they
* contribute nothing except `pi-color__primary`, which is a *text* colour and
* actively fights the white a filled button needs. The stylesheet beside this
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
* so a change of shell or theme still reaches this button.
*/
type Size = 'small' | 'medium' | 'large';
type Tone = 'primary' | 'grey';
@Component({
selector: 'app-link-button',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './link-button.scss',
template: `
<a
[class]="classes()"
[href]="href()"
[attr.target]="external() ? '_blank' : null"
[attr.rel]="external() ? 'noopener noreferrer' : null"
>
<span>{{ text() }}</span>
<svg
viewBox="0 0 6 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g>
<path
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
stroke="currentColor"
stroke-width="1"
/>
</g>
<g>
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
</g>
</svg>
</a>
`,
})
export class LinkButton {
readonly href = input.required<string>();
readonly text = input.required<string>();
readonly size = input<Size>('medium');
readonly color = input<Tone>('primary');
readonly appearance = input<'fill' | 'outline'>('fill');
/** Opens in a new tab, for addresses that are not ours. */
readonly external = input(false);
protected readonly classes = computed(() => [
'link-button',
`link-button--${this.size()}`,
`link-button--${this.color()}`,
`link-button--${this.appearance()}`,
]);
}

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LinkButton } from './link-button';
import {
PiActionButtonComponent,
PiIconComponent,
PiTextColorDirective,
PiTextMediumDirective,
@@ -26,12 +26,12 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiIconComponent,
PiActionButtonComponent,
PiTextXLargeDirective,
PiTextXLargeDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './not-found.scss',
template: `
@@ -47,14 +47,12 @@ import {
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
</p>
<a class="not-found__home" href="/">
<pi-action-button
size="large"
color="primary"
text="Ir al inicio"
[hasIcon]="true"
/>
</a>
<app-link-button
class="not-found__home"
size="large"
href="/"
text="Ir al inicio"
/>
<p s pi-color color="grey" class="not-found__note">
Si has llegado desde un enlace nuestro, cuéntanoslo.

View File

@@ -5,7 +5,6 @@ import {
input,
} from '@angular/core';
import {
PiActionButtonComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
@@ -21,6 +20,7 @@ import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
import { PROJECT_STATUS_TONES } from './project-card';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
/**
* A project on its own address: what it is, why it was built the way it was,
@@ -35,14 +35,14 @@ import { TechnologyChip } from './technology-chip';
imports: [
ApiImage,
ContactCta,
PiActionButtonComponent,
PiAvatarComponent,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
styleUrl: './project-detail.scss',
template: `
@@ -92,23 +92,20 @@ import { TechnologyChip } from './technology-chip';
@if (project().demo_url || repositoryUrl()) {
<div class="content-hero__actions">
@if (project().demo_url; as demo) {
<a [href]="demo" target="_blank" rel="noopener">
<pi-action-button
color="primary"
[text]="copy().labels.demo"
[hasIcon]="true"
/>
</a>
<app-link-button
[href]="demo"
[text]="copy().labels.demo"
[external]="true"
/>
}
@if (repositoryUrl(); as repository) {
<a [href]="repository" target="_blank" rel="noopener">
<pi-action-button
appearance="outline"
color="grey"
[text]="copy().labels.repository"
[hasIcon]="true"
/>
</a>
<app-link-button
color="grey"
appearance="outline"
[href]="repository"
[text]="copy().labels.repository"
[external]="true"
/>
}
</div>
}

View File

@@ -5,7 +5,6 @@ import {
input,
} from '@angular/core';
import {
PiActionButtonComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
@@ -15,6 +14,7 @@ import type { Locale } from '../core/api';
import type { PublicTechnology } from '../core/api.types';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { LinkButton } from './link-button';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
@@ -36,10 +36,10 @@ import { ContactCta } from './contact-cta';
imports: [
ApiImage,
ContactCta,
PiActionButtonComponent,
PiTextColorDirective,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './technology-detail.scss',
template: `
@@ -75,14 +75,12 @@ import { ContactCta } from './contact-cta';
@if (technology().website; as website) {
<p class="content-hero__actions">
<a [href]="website" target="_blank" rel="noopener noreferrer">
<pi-action-button
size="large"
color="primary"
[text]="copy().technology.website"
[hasIcon]="true"
/>
</a>
<app-link-button
size="large"
[href]="website"
[text]="copy().technology.website"
[external]="true"
/>
</p>
}
</div>