diff --git a/assertions.test.ts b/assertions.test.ts index 2ff81193779..506609fbf72 100644 --- a/assertions.test.ts +++ b/assertions.test.ts @@ -1,5 +1,9 @@ import assert from "node:assert/strict"; -import { assertValidFeatureReference } from "./assertions"; +import { + assertFreshRegressionNotes, + assertValidFeatureReference, +} from "./assertions"; +import { FeatureData } from "./types"; describe("assertValidReference()", function () { it("throws if target ID is a move", function () { @@ -34,3 +38,111 @@ describe("assertValidReference()", function () { }); }); }); + +describe("assertFreshRegressionNotes", function () { + it("throws when the current status is the same as the previous status", function () { + const f = { + kind: "feature", + status: { baseline: "low" }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: "low", + }, + ], + } as Partial as FeatureData; + assert.throws(() => { + assertFreshRegressionNotes("a", f); + }); + }); + + it("throws when the current status is better than the previous status", function () { + const highLow = { + kind: "feature", + status: { baseline: "high" }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: "low", + }, + ], + } as Partial as FeatureData; + assert.throws(() => { + assertFreshRegressionNotes("a", highLow); + }); + + const lowFalse = { + kind: "feature", + status: { baseline: "low" }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: false, + }, + ], + } as Partial as FeatureData; + assert.throws(() => { + assertFreshRegressionNotes("a", lowFalse); + }); + + const highFalse = { + kind: "feature", + status: { baseline: "high" }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: false, + }, + ], + } as Partial as FeatureData; + assert.throws(() => { + assertFreshRegressionNotes("a", highFalse); + }); + }); + + it("does not throw when the current status is lower than the previous status", function () { + const lowHigh = { + kind: "feature", + status: { baseline: "low" }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: "high", + }, + ], + } as Partial as FeatureData; + assertFreshRegressionNotes("a", lowHigh); + + const falseLow = { + kind: "feature", + status: { baseline: false }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: "low", + }, + ], + } as Partial as FeatureData; + assertFreshRegressionNotes("a", falseLow); + + const falseHigh = { + kind: "feature", + status: { baseline: false }, + notes: [ + { + category: "baseline-regression", + previous_baseline_value: "high", + }, + ], + } as Partial as FeatureData; + assertFreshRegressionNotes("a", falseHigh); + }); + + it("does not throw without a regression note", function () { + const noRegressionNotes = { + kind: "feature", + status: { baseline: "high" }, + } as Partial as FeatureData; + assertFreshRegressionNotes("a", noRegressionNotes); + }); +}); diff --git a/assertions.ts b/assertions.ts index 11bfbd5c22e..363e7e12e8c 100644 --- a/assertions.ts +++ b/assertions.ts @@ -1,5 +1,5 @@ import { isOrdinaryFeatureData } from "./type-guards"; -import { FeatureData } from "./types"; +import { BaselineValue, FeatureData } from "./types"; import { WebFeaturesData } from "./types.quicktype"; /** @@ -27,6 +27,49 @@ export function assertValidFeatureReference( } } +/** + * Assert that a regression note is still relevant. + * + * A fresh regression note must represent a status change where the + * `previous_baseline_value` value is better than the current `status.baseline` + * value. A regression note must represent a change in status from high to low, + * high to not Baseline, or low to not Baseline. A regression note must not + * represent a status change that has aged, such that the current + * `status.baseline` value has progressed back to `previous_baseline_value`. + * + * @export + * @param {string} id The ID of the feature to be checked + * @param {FeatureData} data The ordinary feature data to be checked + */ +export function assertFreshRegressionNotes( + id: string, + data: FeatureData, +): void { + if (!isOrdinaryFeatureData(data)) { + return; + } + + const { baseline } = data.status; + const notes = data.notes ? data.notes : []; + + for (const [index, note] of notes.entries()) { + if (compareBaselineValue(note.previous_baseline_value, baseline) <= 0) { + throw new Error( + `regression note ${index} on ${id}.yml no longer applies (status is ${baseline}, was ${note.previous_baseline_value}). Delete this note.`, + ); + } + } +} + +function compareBaselineValue(a: BaselineValue, b: BaselineValue): number { + const statusToNumber = new Map([ + ["high", 2], + ["low", 1], + [false, 0], + ]); + return statusToNumber.get(a) - statusToNumber.get(b); +} + /** * Assert that a discouraged feature with no supporting browsers has a * `removal_date`. diff --git a/docs/guidelines.md b/docs/guidelines.md index fae24d70be0..4409bd43ac8 100644 --- a/docs/guidelines.md +++ b/docs/guidelines.md @@ -423,3 +423,21 @@ When you set a `discouraged` block in a feature file, do: - Set one or more (optional) `alternatives` feature IDs that are whole or partial substitutes for the discouraged feature. An alternative doesn't have to be a narrow drop-in replacement for the discouraged feature but it must handle some use case of the discouraged feature. Guide developers to the most relevant features that would help them stop using the discouraged feature. + +## Notes + +Features may have notes. +Presently, there is one type of note, a Baseline regression note. + +### Baseline regression note + +Use a note with a `category: baseline-regression` whenever the Baseline status goes backwards (such as from `"high"` to `"low"`). +This note type applies to any regression, whether it was caused by changes in upstream data or an editorial override. + +In the `message` field, explain the cause of the regression. +Write the message to developers, to help them understand whether the regression applies to their use case. +If the cause is a newly-discovered or reported bug then briefly describe the nature of the bug. +If the cause is a correction then briefly describe the nature and origin of the correction (usually, upstream data). + +In the `citations` field, include the URLs that are most important to understanding the nature and origin of the change. +For example, if BCD marked a feature as a `partial_implementation` due to a browser bug, include the URL for the browser bug and the BCD pull request or issue where the `partial_implementation` status was agreed. diff --git a/features/content-visibility.yml b/features/content-visibility.yml index 34da9be944f..df7bd09d71b 100644 --- a/features/content-visibility.yml +++ b/features/content-visibility.yml @@ -3,12 +3,6 @@ description: The `content-visibility` CSS property delays rendering an element, spec: https://drafts.csswg.org/css-contain-2/#content-visibility group: css caniuse: css-content-visibility -# TODO: https://github.com/web-platform-dx/web-features/issues/1971 -# Status changed: https://github.com/web-platform-dx/web-features/pull/2591 -# 2025-01-30 — low → false — Safari hides text behind `content-visibility: auto` from "Find…" in the page. -# References: -# - https://github.com/mdn/browser-compat-data/pull/25781 -# - https://bugs.webkit.org/show_bug.cgi?id=283846 compat_features: - api.ContentVisibilityAutoStateChangeEvent - api.ContentVisibilityAutoStateChangeEvent.ContentVisibilityAutoStateChangeEvent diff --git a/features/createimagebitmap.yml b/features/createimagebitmap.yml index bd56d7cc6c2..bfa4275b540 100644 --- a/features/createimagebitmap.yml +++ b/features/createimagebitmap.yml @@ -2,6 +2,14 @@ name: createImageBitmap description: The `createImageBitmap()` global method creates an `ImageBitmap` object from a source such as an image, SVG, blob, or canvas. An `ImageBitmap` object represents pixel data that can be drawn to a canvas with lower latency than other types, such as `ImageData`. spec: https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#imagebitmap caniuse: createimagebitmap +notes: + - date: 2025-08-11 + category: baseline-regression + previous_baseline_value: high + message: > + This feature's status was recalculated to be more consistent with caniuse's criteria for full support, requiring `SVGImageElement` as a supported image source. + citations: + - https://github.com/web-platform-dx/web-features/pull/3173 status: compute_from: - api.createImageBitmap diff --git a/features/font-variant-position.yml b/features/font-variant-position.yml index 7bfa1284d0e..226d6725bdf 100644 --- a/features/font-variant-position.yml +++ b/features/font-variant-position.yml @@ -2,9 +2,12 @@ name: font-variant-position description: The `font-variant-position` CSS property sets whether to use alternate glyphs for subscript and superscript text. spec: https://drafts.csswg.org/css-fonts-4/#font-variant-position-prop group: font-features -# TODO: https://github.com/web-platform-dx/web-features/issues/1971 -# Status changed: https://github.com/web-platform-dx/web-features/pull/1958 -# 2024-10-15 — low → false — Chrome, Edge, and Safari do not implement font synthesis for missing superscript or subscript glyphs. -# References: -# - https://issues.chromium.org/issues/352218916 -# - https://bugs.webkit.org/show_bug.cgi?id=151471 +notes: + - date: 2024-10-15 + category: baseline-regression + previous_baseline_value: low + message: > + Chrome, Edge, and Safari do not implement font synthesis for missing superscript or subscript glyphs. + citations: + - https://issues.chromium.org/issues/352218916 + - https://bugs.webkit.org/show_bug.cgi?id=151471 diff --git a/features/link-rel-dns-prefetch.yml b/features/link-rel-dns-prefetch.yml index a559ac5cc8c..ea5a59337ed 100644 --- a/features/link-rel-dns-prefetch.yml +++ b/features/link-rel-dns-prefetch.yml @@ -5,9 +5,3 @@ caniuse: link-rel-dns-prefetch group: resource-hints compat_features: - html.elements.link.rel.dns-prefetch -# TODO: https://github.com/web-platform-dx/web-features/issues/1971 -# Status changed: https://github.com/web-platform-dx/web-features/pull/3074/ -# 2025-06-23 — low → false — On iOS, it was erroneously reported that this feature was supported. -# References: -# - https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes#Networking -# - https://github.com/mdn/browser-compat-data/pull/27057 diff --git a/features/popover.yml b/features/popover.yml index 41e8afbb4a6..d3180933427 100644 --- a/features/popover.yml +++ b/features/popover.yml @@ -2,12 +2,15 @@ name: Popover description: The `popover` HTML attribute creates an overlay to display content on top of other page content. Popovers can be shown declaratively using HTML, or using the `showPopover()` method. spec: https://html.spec.whatwg.org/multipage/popover.html group: html -# TODO: https://github.com/web-platform-dx/web-features/issues/1971 -# Status changed: https://github.com/web-platform-dx/web-features/pull/1797 -# 2024-09-18 — low → false — Safari on iOS has a bug that prevents dismissing popovers by touch. -# References: -# - https://github.com/mdn/browser-compat-data/issues/22927 -# - https://bugs.webkit.org/show_bug.cgi?id=267688 +# notes: +# - date: 2024-09-18 +# category: baseline-regression +# previous_baseline_value: low +# message: > +# Safari on iOS has a bug that prevents light dismiss (tapping outside the element to close it). +# citations: +# - https://bugs.webkit.org/show_bug.cgi?id=267688 +# - https://github.com/mdn/browser-compat-data/issues/22927 status: compute_from: - api.HTMLElement.popover diff --git a/features/streams.yml b/features/streams.yml index 2a55ff8defd..91c950ed0a0 100644 --- a/features/streams.yml +++ b/features/streams.yml @@ -2,12 +2,6 @@ name: Streams description: The streams API creates, composes, and consumes continuously generated data. spec: https://streams.spec.whatwg.org/ group: streams -# TODO: https://github.com/web-platform-dx/web-features/issues/1971 -# Status changed: https://github.com/web-platform-dx/web-features/pull/2358, https://github.com/web-platform-dx/web-features/pull/2491 -# 2024-12-19 — low → false — Regressed status to match Caniuse, which considers support beginning at BYOB shipping. -# 2025-01-30 — false → high — Split BYOB into a separate "readable-byte-streams" feature. Linked that one to Caniuse. -# References: -# - https://caniuse.com/streams status: compute_from: - api.ReadableStream diff --git a/index.ts b/index.ts index cd9a441237b..00786692085 100644 --- a/index.ts +++ b/index.ts @@ -4,13 +4,13 @@ import path from 'path'; import { Temporal } from '@js-temporal/polyfill'; import { fdir } from 'fdir'; import YAML from 'yaml'; -import { convertMarkdown } from "./text"; -import { GroupData, SnapshotData, WebFeaturesData } from './types'; import { BASELINE_LOW_TO_HIGH_DURATION, coreBrowserSet, getStatus, parseRangedDateString } from 'compute-baseline'; import { Compat } from 'compute-baseline/browser-compat-data'; -import { assertRequiredRemovalDateSet, assertValidFeatureReference } from './assertions'; +import { assertFreshRegressionNotes, assertRequiredRemovalDateSet, assertValidFeatureReference } from './assertions'; +import { convertMarkdown } from "./text"; import { isMoved, isOrdinaryFeatureData, isSplit } from './type-guards'; +import { FeatureData, GroupData, SnapshotData, WebFeaturesData } from './types'; // The longest name allowed, to allow for compact display. const nameMaxLength = 80; @@ -178,6 +178,14 @@ for (const [key, data] of yamlEntries('features')) { data.discouraged.reason = text; data.discouraged.reason_html = html; } + + if (Array.isArray(data.notes)) { + for (const note of data.notes as FeatureData["notes"]) { + const { text, html } = convertMarkdown(note.message); + note.message = text; + note.message_html = html; + } + } } // Compute Baseline high date from low date. @@ -233,7 +241,7 @@ for (const [key, data] of yamlEntries('features')) { } } - assertRequiredRemovalDateSet(key, data); + assertRequiredRemovalDateSet(key, data); features[key] = data; } @@ -242,6 +250,7 @@ for (const [id, feature] of Object.entries(features)) { const { kind } = feature; switch (kind) { case "feature": + assertFreshRegressionNotes(id, feature); for (const alternative of feature.discouraged?.alternatives ?? []) { assertValidFeatureReference(id, alternative, features) } diff --git a/schemas/data.schema.json b/schemas/data.schema.json index 5fd0c4c319b..f3e8aabfac1 100644 --- a/schemas/data.schema.json +++ b/schemas/data.schema.json @@ -76,6 +76,10 @@ "required": ["browsers", "features", "groups", "snapshots"], "additionalProperties": false, "definitions": { + "BaselineValue": { + "description": "Whether the feature is Baseline (low substatus), Baseline (high substatus), or not (false)", + "enum": ["high", "low", false] + }, "Discouraged": { "type": "object", "description": "Whether developers are formally discouraged from using this feature", @@ -149,6 +153,53 @@ "description": "Group identifiers", "$ref": "#/definitions/Strings" }, + "notes": { + "description": "Notes about this feature", + "items": { + "additionalProperties": false, + "description": "A note describing a Baseline status regression. For example, a feature that has moved from Baseline low to not Baseline.", + "properties": { + "category": { + "const": "baseline-regression", + "description": "The topic of this note. This field is also a discriminator for any future note types.", + "type": "string" + }, + "citations": { + "description": "One or more URLs, such as bugs, used to justify the regression", + "items": { + "type": "string" + }, + "type": "array" + }, + "date": { + "description": "The date that the regression was added to web-features data", + "type": "string" + }, + "message": { + "description": "A short description of the cause of the regression as a plain text", + "type": "string" + }, + "message_html": { + "description": "A short description of the cause of the regression as HTML", + "type": "string" + }, + "previous_baseline_value": { + "description": "The `baseline` status value before the regression", + "$ref": "#/definitions/BaselineValue" + } + }, + "required": [ + "category", + "date", + "message", + "message_html", + "citations", + "previous_baseline_value" + ], + "type": "object" + }, + "type": "array" + }, "snapshot": { "description": "Snapshot identifiers", "$ref": "#/definitions/Strings" @@ -260,8 +311,8 @@ "type": "object", "properties": { "baseline": { - "description": "Whether the feature is Baseline (low substatus), Baseline (high substatus), or not (false)", - "enum": ["high", "low", false] + "description": "Whether the feature is not Baseline, Baseline newly available, or Baseline widely available", + "$ref": "#/definitions/BaselineValue" }, "baseline_high_date": { "description": "Date the feature achieved Baseline high status", diff --git a/types.quicktype.ts b/types.quicktype.ts index 40f7421f01b..e6425904ab6 100644 --- a/types.quicktype.ts +++ b/types.quicktype.ts @@ -95,6 +95,10 @@ export interface FeatureData { * Short name */ name?: string; + /** + * Notes about this feature + */ + notes?: Note[]; /** * Snapshot identifiers */ @@ -150,15 +154,49 @@ export interface Discouraged { export type Kind = "feature" | "moved" | "split"; +/** + * A note describing a Baseline status regression. For example, a feature that has moved + * from Baseline low to not Baseline. + */ +export interface Note { + /** + * The topic of this note. This field is also a discriminator for any future note types + */ + category: "baseline-regression"; + /** + * One or more URLs, such as bugs, used to justify the regression + */ + citations: string[]; + /** + * The date that the regression was added to web-features data + */ + date: string; + /** + * A short description of the cause of the regression as a plain text + */ + message: string; + /** + * A short description of the cause of the regression as HTML + */ + message_html: string; + /** + * The `baseline` status value before the regression + */ + previous_baseline_value: boolean | BaselineValueEnum; +} + +export type BaselineValueEnum = "high" | "low"; + /** * Whether a feature is considered a "Baseline" web platform feature and when it achieved * that status */ export interface StatusHeadline { /** - * Whether the feature is Baseline (low substatus), Baseline (high substatus), or not (false) + * Whether the feature is not Baseline, Baseline newly available, or Baseline widely + * available */ - baseline: boolean | BaselineEnum; + baseline: boolean | BaselineValueEnum; /** * Date the feature achieved Baseline high status */ @@ -177,13 +215,12 @@ export interface StatusHeadline { support: Support; } -export type BaselineEnum = "high" | "low"; - export interface Status { /** - * Whether the feature is Baseline (low substatus), Baseline (high substatus), or not (false) + * Whether the feature is not Baseline, Baseline newly available, or Baseline widely + * available */ - baseline: boolean | BaselineEnum; + baseline: boolean | BaselineValueEnum; /** * Date the feature achieved Baseline high status */ diff --git a/types.ts b/types.ts index 24256c80498..0b9877054a9 100644 --- a/types.ts +++ b/types.ts @@ -5,13 +5,13 @@ // nicer to work with in TypeScript. import type { - BaselineEnum as BaselineHighLow, BrowserData, Browsers, Discouraged, GroupData, Kind, FeatureData as QuicktypeMonolithicFeatureData, + Note as QuicktypeNote, Status as QuicktypeStatus, StatusHeadline as QuicktypeStatusHeadline, WebFeaturesData as QuicktypeWebFeaturesData, @@ -22,7 +22,6 @@ import type { // Passthrough types export type { - BaselineHighLow, BrowserData, Browsers, Discouraged, @@ -32,12 +31,17 @@ export type { Support, }; +// Quicktype interprets the schema's `baseline: false | "high" | "low"` as +// meaning `baseline: boolean | "high" | "low"`. `BaselineValue` patches it. +export type BaselineValue = "high" | "low" | false; export interface Status extends QuicktypeStatus { - baseline: false | BaselineHighLow; + baseline: BaselineValue; } - export interface SupportStatus extends QuicktypeStatusHeadline { - baseline: false | BaselineHighLow; + baseline: BaselineValue; +} +export interface RegressionNote extends QuicktypeNote { + previous_baseline_value: BaselineValue; } // These are "tests" for our type definitions. @@ -74,7 +78,7 @@ export interface WebFeaturesData extends Pick< }; } -export type FeatureData = { kind: "feature" } & Required< +type IntermediateFeatureData = { kind: "feature" } & Required< Pick< QuicktypeMonolithicFeatureData, "description_html" | "description" | "name" | "spec" | "status" @@ -83,10 +87,20 @@ export type FeatureData = { kind: "feature" } & Required< Partial< Pick< QuicktypeMonolithicFeatureData, - "caniuse" | "compat_features" | "discouraged" | "group" | "snapshot" + | "caniuse" + | "compat_features" + | "discouraged" + | "group" + | "snapshot" + | "notes" > >; +export interface FeatureData extends IntermediateFeatureData { + status: Status; + notes?: RegressionNote[]; +} + const goodFeatureData: FeatureData = { kind: "feature", name: "Test",