From 07007be1d6a3366fa8c9c4524fcf73430f7000d6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 08:17:36 +0200 Subject: [PATCH 1/6] Add AI indicator inputs to file settings Files can be marked as AI generated or AI modified following the EU icons for labelling AI-generated content. The free form text input explains which parts of the file were generated or modified. Separators group the inputs, which the settings dialog has grown too many of to read as one list. --- .../pageflow/editor/file_settings_dialog.scss | 2 +- config/locales/de.yml | 16 ++++ config/locales/en.yml | 15 ++++ .../spec/editor/views/EditFileView-spec.js | 78 +++++++++++++++++++ package/src/editor/views/EditFileView.js | 12 +++ 5 files changed, 122 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss b/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss index 33ce33e43e..c7cc5b6edb 100644 --- a/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss +++ b/app/assets/stylesheets/pageflow/editor/file_settings_dialog.scss @@ -3,7 +3,7 @@ min-width: 500px; min-height: 400px; max-width: 800px; - max-height: 600px; + max-height: 790px; } .edit_file { diff --git a/config/locales/de.yml b/config/locales/de.yml index 34402866e4..2fd4e4f49b 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1217,6 +1217,22 @@ de: video_settings_tab: Video-Details files: common_attributes: + ai_indicator: + blank: "(Nicht angegeben)" + inline_help: |- + Kennzeichne, dass die Datei mit künstlicher Intelligenz + erzeugt oder verändert wurde. Wird als EU-KI-Label an + der Stelle im Beitrag angezeigt, wo die Datei verwendet + wird. + label: KI-Kennzeichnung + values: + ai_generated: Vollständig KI-generiert + ai_modified: Teilweise KI-modifiziert + ai_indicator_text: + inline_help: |- + Erläutert, welche Teile der Datei mit künstlicher + Intelligenz erzeugt oder verändert wurden. + label: Text der KI-Kennzeichnung alt: inline_help: Ein Hinweis darauf, was in dem dargestellten Element zum Ausdruck kommt. Der Text ist im Beitrag nicht sichtbar, kann aber von Screen-Readern verarbeitet werden und trägt somit zur barrierearmen Gestaltung der Inhalte bei. label: Alt-Text diff --git a/config/locales/en.yml b/config/locales/en.yml index c2e30093d5..0cc741b2a9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1210,6 +1210,21 @@ en: video_settings_tab: Video Details files: common_attributes: + ai_indicator: + blank: "(Not specified)" + inline_help: |- + Indicate that the file has been generated or modified + using artificial intelligence. Displayed as EU AI label + at the element where the file is used. + label: AI label + values: + ai_generated: Fully AI generated + ai_modified: Partially AI modified + ai_indicator_text: + inline_help: |- + Explains which parts of the file have been generated or + modified using artificial intelligence. + label: AI label text alt: inline_help: A hint describing the contents of the displayed element. Not displayed in the story, but can be used by screen reader software. Improves accessibility of the content. label: Alt-Text diff --git a/package/spec/editor/views/EditFileView-spec.js b/package/spec/editor/views/EditFileView-spec.js index bcb97ff532..ed871e40e2 100644 --- a/package/spec/editor/views/EditFileView-spec.js +++ b/package/spec/editor/views/EditFileView-spec.js @@ -1,4 +1,5 @@ import Backbone from 'backbone'; +import $ from 'jquery'; import {TextInputView} from 'pageflow/ui'; @@ -17,6 +18,10 @@ describe('EditFileView', () => { useFakeTranslations({ 'pageflow.editor.files.common_attributes.source_url.label': 'Source URL', 'pageflow.editor.files.common_attributes.license.label': 'License', + 'pageflow.editor.files.common_attributes.ai_indicator.label': 'AI indicator', + 'pageflow.editor.files.common_attributes.ai_indicator.values.ai_modified': 'AI modified', + 'pageflow.editor.files.common_attributes.ai_indicator.values.ai_generated': 'AI generated', + 'pageflow.editor.files.common_attributes.ai_indicator_text.label': 'AI indicator text', 'pageflow.file_licenses.cc0.name': 'CC0', 'pageflow.entry_types.strange.editor.files.attributes.image_files.custom.label': 'Entry Label', 'pageflow.editor.files.attributes.image_files.custom.label': 'Fallback Label', @@ -146,6 +151,79 @@ describe('EditFileView', () => { expect(getByRole('option', {name: 'CC0'})).not.toBeNull(); }); + it('renders ai indicator options', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + const {getByLabelText} = render(view); + const {getAllByRole} = within(getByLabelText('AI indicator')); + const options = getAllByRole('option').map(option => option.textContent); + + expect(options).toEqual(expect.arrayContaining(['AI modified', 'AI generated'])); + expect(options).not.toContain('AI'); + }); + + it('groups inputs with separators', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + render(view); + const sequence = view.$el.find('.input, .separator').toArray().map(element => + $(element).data('inputPropertyName') || 'separator' + ); + + expect(sequence).toEqual([ + 'display_name', + 'separator', + 'rights', 'source_url', 'license', 'rights_display', + 'separator', + 'ai_indicator', 'ai_indicator_text', + 'separator', + 'download_url' + ]); + }); + + it('hides ai indicator text input if no ai indicator is selected', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file(), + entry: new Backbone.Model() + }); + + render(view); + const configurationEditor = ConfigurationEditorTab.find(view); + + expect(configurationEditor.inputPropertyNames()).toContain('ai_indicator_text'); + expect(configurationEditor.visibleInputPropertyNames()).not.toContain('ai_indicator_text'); + }); + + it('displays ai indicator text input if ai indicator is selected', () => { + editor.registerEntryType('test', { + supportsExtendedFileRights: true + }); + var view = new EditFileView({ + model: f.file({configuration: {ai_indicator: 'ai_generated'}}), + entry: new Backbone.Model() + }); + + render(view); + const configurationEditor = ConfigurationEditorTab.find(view); + + expect(configurationEditor.visibleInputPropertyNames()).toContain('ai_indicator_text'); + }); + it('does not render extended file rights fields if non supported by file type', () => { var fileType = f.fileType({ noExtendedFileRights: true diff --git a/package/src/editor/views/EditFileView.js b/package/src/editor/views/EditFileView.js index 935c494ea6..31f14b96fb 100644 --- a/package/src/editor/views/EditFileView.js +++ b/package/src/editor/views/EditFileView.js @@ -36,6 +36,8 @@ export const EditFileView = Marionette.ItemView.extend({ required: true }); + tab.view(SeparatorView); + tab.input('rights', TextInputView, { model: this.model, placeholder: entry.get('default_file_rights') @@ -53,6 +55,16 @@ export const EditFileView = Marionette.ItemView.extend({ tab.input('rights_display', SelectInputView, { values: ['credits', 'inline'] }); + + tab.view(SeparatorView); + + tab.input('ai_indicator', SelectInputView, { + includeBlank: true, + values: ['ai_modified', 'ai_generated'] + }); + tab.input('ai_indicator_text', TextInputView, { + visibleBinding: 'ai_indicator' + }); } tab.view(SeparatorView); From 8fe9b7d3875839c4dc9185f84014dca1aa5cc23d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 08:22:56 +0200 Subject: [PATCH 2/6] Add AI indicator icons from EU labelling icon set The official icons must not be restyled by themes, so they are not registered as theme icons. Their view boxes are cropped to the mark, which leaves the space around it to stylesheets instead of the clear space that differs per icon. The letter color is exposed as custom property since the badge follows the surrounding text color. Since the icons render the wording as graphic, they carry the translated label as alt text. --- .../scrolled/config/locales/new/public.de.yml | 4 ++ .../scrolled/config/locales/new/public.en.yml | 4 ++ .../scrolled/package/documentation.yml | 1 + .../spec/frontend/AiIndicatorIcon-spec.js | 57 +++++++++++++++++++ .../AiIndicatorIcon.module.css | 5 ++ .../src/frontend/AiIndicatorIcon/ai.svg | 7 +++ .../frontend/AiIndicatorIcon/aiGenerated.svg | 18 ++++++ .../frontend/AiIndicatorIcon/aiModified.svg | 17 ++++++ .../src/frontend/AiIndicatorIcon/index.js | 42 ++++++++++++++ .../scrolled/package/src/frontend/index.js | 1 + 10 files changed, 156 insertions(+) create mode 100644 entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css create mode 100644 entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg create mode 100644 entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg create mode 100644 entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg create mode 100644 entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js diff --git a/entry_types/scrolled/config/locales/new/public.de.yml b/entry_types/scrolled/config/locales/new/public.de.yml index 35d38e0748..605cae9da9 100644 --- a/entry_types/scrolled/config/locales/new/public.de.yml +++ b/entry_types/scrolled/config/locales/new/public.de.yml @@ -1,6 +1,10 @@ de: pageflow_scrolled: public: + ai_indicators: + ai: KI + ai_generated: KI-generiert + ai_modified: KI-modifiziert enter_fullscreen: Vollbildmodus öffnen exit_fullscreen: Vollbildmodus verlassen flip_card: Karte wenden diff --git a/entry_types/scrolled/config/locales/new/public.en.yml b/entry_types/scrolled/config/locales/new/public.en.yml index 05e105e3de..19051f85a2 100644 --- a/entry_types/scrolled/config/locales/new/public.en.yml +++ b/entry_types/scrolled/config/locales/new/public.en.yml @@ -1,6 +1,10 @@ en: pageflow_scrolled: public: + ai_indicators: + ai: AI + ai_generated: AI generated + ai_modified: AI modified enter_fullscreen: Enter fullscreen exit_fullscreen: Exit fullscreen flip_card: Flip card diff --git a/entry_types/scrolled/package/documentation.yml b/entry_types/scrolled/package/documentation.yml index 606f662b21..d4df087bb7 100644 --- a/entry_types/scrolled/package/documentation.yml +++ b/entry_types/scrolled/package/documentation.yml @@ -15,6 +15,7 @@ toc: description: | React components for building content elements. children: + - AiIndicatorIcon - AudioPlayer - ContentElementBox - ContentElementFigure diff --git a/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js b/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js new file mode 100644 index 0000000000..a6068cd79f --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/AiIndicatorIcon-spec.js @@ -0,0 +1,57 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +import {AiIndicatorIcon, useAiIndicatorLabel} from 'frontend/AiIndicatorIcon'; + +import {renderInEntry} from 'support'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('AiIndicatorIcon', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated' + }); + + it('renders icon for kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toHaveAttribute('data-file-name', + 'SvgAiGenerated'); + }); + + it('renders different icon per kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toHaveAttribute('data-file-name', + 'SvgAiModified'); + }); + + it('renders nothing for unknown kind', () => { + const {container} = renderInEntry(); + + expect(container.querySelector('svg')).toBeNull(); + }); + + it('renders translated label for screen readers', () => { + const {queryByRole} = renderInEntry(); + + expect(queryByRole('img', {name: 'AI generated'})).not.toBeNull(); + }); +}); + +describe('useAiIndicatorLabel', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated' + }); + + function Probe({kind}) { + const aiIndicatorLabel = useAiIndicatorLabel(); + + return {aiIndicatorLabel(kind)}; + } + + it('translates kind', () => { + const {container} = renderInEntry(); + + expect(container).toHaveTextContent('AI generated'); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css new file mode 100644 index 0000000000..90ec3d4b84 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/AiIndicatorIcon.module.css @@ -0,0 +1,5 @@ +/* The view boxes are cropped to the mark, so its antialiased edges + would be clipped. */ +.icon { + overflow: visible; +} diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg new file mode 100644 index 0000000000..be674f1c6f --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/ai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg new file mode 100644 index 0000000000..128c13f300 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiGenerated.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg new file mode 100644 index 0000000000..26746842e7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/aiModified.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js new file mode 100644 index 0000000000..3d0e43b4a9 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/AiIndicatorIcon/index.js @@ -0,0 +1,42 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {useI18n} from '../i18n'; + +import ai from './ai.svg'; +import aiGenerated from './aiGenerated.svg'; +import aiModified from './aiModified.svg'; + +import styles from './AiIndicatorIcon.module.css'; + +const icons = { + ai, + ai_generated: aiGenerated, + ai_modified: aiModified +}; + +/** + * Render one of the EU icons for labelling AI generated content. + * + * @param {Object} props + * @param {string} props.kind - Either: ai, ai_generated, ai_modified. + * @param {string} [props.className] + */ +export function AiIndicatorIcon({kind, className}) { + const aiIndicatorLabel = useAiIndicatorLabel(); + const Icon = icons[kind]; + + if (!Icon) { + return null; + } + + return ; +} + +export function useAiIndicatorLabel() { + const {t} = useI18n(); + + return kind => t(kind, {scope: 'pageflow_scrolled.public.ai_indicators'}); +} diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 7e216b0b65..3c9541c5f4 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -133,6 +133,7 @@ export { export {FitViewport} from './FitViewport'; export {Tooltip} from './Tooltip'; export {ThemeIcon} from './ThemeIcon'; +export {AiIndicatorIcon, useAiIndicatorLabel} from './AiIndicatorIcon'; export {InlineFileRights} from './InlineFileRights'; export {useFloatingPortalRoot, FloatingPortalRootProvider} from './FloatingPortalRootProvider'; export {SectionIntersectionObserver} from './SectionIntersectionObserver'; From e88545bd43329deb715b8663230e97ee58eb676b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 08:18:03 +0200 Subject: [PATCH 3/6] Render AI indicators as part of inline file rights items Items combine label, rights, license and AI indicator, so that the widgets do not have to lay out both kinds of information themselves. Data attributes mark which items carry rights, where labels are and which parts an item consists of, since widget stylesheets cannot address the hashed class names of another CSS module. Laying out the parts as wrapping flex items makes narrow columns break between them instead of separating an icon from the text that explains it, and leaves no indent in front of a part that moved to its own line. --- .../spec/frontend/InlineFileRights-spec.js | 221 ++++++++++++++++++ .../package/src/frontend/InlineFileRights.js | 56 ++++- .../src/frontend/InlineFileRights.module.css | 19 ++ 3 files changed, 286 insertions(+), 10 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js index fce8b7c85a..ac5e27e741 100644 --- a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js +++ b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js @@ -9,8 +9,11 @@ import {useFakeTranslations} from 'pageflow/testHelpers'; describe('InlineFileRights', () => { useFakeTranslations({ + 'pageflow_scrolled.public.inline_file_rights_labels.image': 'Image', 'pageflow_scrolled.public.inline_file_rights_labels.poster': 'Poster', 'pageflow_scrolled.public.inline_file_rights_labels.video': 'Video', + 'pageflow_scrolled.public.ai_indicators.ai_generated': 'AI generated', + 'pageflow_scrolled.public.ai_indicators.ai_modified': 'AI modified' }); beforeEach(() => { @@ -21,6 +24,17 @@ describe('InlineFileRights', () => { ) } }); + + api.widgetTypes.register('inlineFileRightsFlags', { + component: function ({hasRights, hasAiIndicators, children}) { + return ( +
+ {`${hasRights}|${hasAiIndicators}`} + {children} +
+ ) + } + }); }); const seed = { @@ -222,6 +236,213 @@ describe('InlineFileRights', () => { .toHaveAttribute('href', 'https://stock.example.com/123'); }); + it('renders ai indicator icon and text as part of item', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: 'Sky replaced' + }, + inlineRights: true + }; + + const {getByRole, queryByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveTextContent('Image: My stock images Sky replaced'); + expect(queryByRole('img', {name: 'AI modified'})).not.toBeNull(); + }); + + it('keeps label, rights and license together for line breaking', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: true, + license: { + name: 'CC0', + url: 'https://creativecommons.org/publicdomain/zero/1.0/' + } + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.querySelector('[data-part="rights"]')) + .toHaveTextContent('Image: My stock images (CC0)'); + }); + + it('keeps ai indicator icon and text together for line breaking', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: 'Sky replaced' + }, + inlineRights: true + }; + + const {container, getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('img', {name: 'AI modified'}).parentElement) + .toHaveAttribute('data-part', 'ai-indicator'); + expect(container.querySelector('[data-part="ai-indicator"]')) + .toHaveTextContent('Sky replaced'); + }); + + it('renders item for file with ai indicator even without inline rights', () => { + const file = { + id: 10, + rights: '', + configuration: { + ai_indicator: 'ai_generated', + ai_indicator_text: 'Voices cloned' + }, + inlineRights: false + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveTextContent('Video: Voices cloned'); + }); + + it('renders one item per file with ai indicator', () => { + const videoFile = { + id: 10, + configuration: {ai_indicator: 'ai_generated', ai_indicator_text: 'Voices'}, + inlineRights: false + }; + const posterFile = { + id: 11, + configuration: {ai_indicator: 'ai_generated', ai_indicator_text: 'Voices'}, + inlineRights: false + }; + + const {queryAllByRole} = renderInEntry( + , + {seed} + ); + + const listItems = queryAllByRole('listitem'); + + expect(listItems).toHaveLength(2); + expect(listItems[0]).toHaveTextContent('Video: Voices'); + expect(listItems[1]).toHaveTextContent('Poster: Voices'); + }); + + it('marks items that have rights', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {}, + inlineRights: true + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).toHaveAttribute('data-rights', ''); + }); + + it('does not mark items that only have an ai indicator', () => { + const file = { + id: 10, + rights: '', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).not.toHaveAttribute('data-rights'); + }); + + it('marks labels of items to allow hiding them', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {}, + inlineRights: true + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.querySelector('[data-label]')).toHaveTextContent('Image:'); + }); + + it('does not render license of file without inline rights', () => { + const file = { + id: 10, + rights: 'My stock images', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false, + license: { + name: 'CC0', + url: 'https://creativecommons.org/publicdomain/zero/1.0/' + } + }; + + const {getByRole} = renderInEntry( + , + {seed} + ); + + expect(getByRole('listitem')).not.toHaveTextContent('CC0'); + }); + + it('passes flags for rights and ai indicators to widget', () => { + const file = { + id: 10, + rights: '', + configuration: {ai_indicator: 'ai_generated'}, + inlineRights: false + }; + + const {container} = renderInEntry( + , + {seed: {widgets: [{role: 'inlineFileRights', typeName: 'inlineFileRightsFlags'}]}} + ); + + expect(container).toHaveTextContent('false|true'); + }); + + it('does not render widget for files without rights and ai indicator', () => { + const file = { + id: 10, + rights: '', + configuration: {}, + inlineRights: true + }; + + const {container} = renderInEntry( + , + {seed} + ); + + expect(container.textContent).toEqual(''); + }); + it('renders license links for for files with license', () => { const file = { id: 10, diff --git a/entry_types/scrolled/package/src/frontend/InlineFileRights.js b/entry_types/scrolled/package/src/frontend/InlineFileRights.js index 40809e1eab..8026da890e 100644 --- a/entry_types/scrolled/package/src/frontend/InlineFileRights.js +++ b/entry_types/scrolled/package/src/frontend/InlineFileRights.js @@ -1,5 +1,6 @@ import React from 'react'; +import {AiIndicatorIcon} from './AiIndicatorIcon'; import {useI18n} from './i18n'; import {isBlank} from './utils/blank'; import {Widget} from './Widget'; @@ -12,9 +13,8 @@ export function InlineFileRights({items = [], playerControlsFadedOut, playerControlsStandAlone, configuration = {}}) { - const {t} = useI18n(); const filteredItems = items.filter(item => - item.file && item.file.inlineRights && !isBlank(item.file.rights) + item.file && (hasRights(item.file) || hasAiIndicator(item.file)) ); if (!filteredItems.length) { @@ -25,22 +25,44 @@ export function InlineFileRights({items = [], + configuration, + hasRights: filteredItems.some(({file}) => hasRights(file)), + hasAiIndicators: filteredItems.some(({file}) => hasAiIndicator(file))}}>
    {filteredItems.map(({label, file}) => -
  • - - {label && - {t(label, {scope: 'pageflow_scrolled.public.inline_file_rights_labels'})}: } - {renderRights(file)} - {renderLicense(file)} -
  • + )}
); } +function Item({label, file}) { + const {t} = useI18n(); + + return ( +
  • + {(label || hasRights(file)) && + + {label && + {t(label, {scope: 'pageflow_scrolled.public.inline_file_rights_labels'})}: } + {hasRights(file) && renderRights(file)} + {hasRights(file) && renderLicense(file)} + } + {hasAiIndicator(file) && ' '} + {renderAiIndicator(file)} +
  • + ); +} + +function hasRights(file) { + return file.inlineRights && !isBlank(file.rights); +} + +function hasAiIndicator(file) { + return !!file.configuration.ai_indicator; +} + function renderRights(file) { if (isBlank(file.configuration.source_url)) { return file.rights; @@ -65,3 +87,17 @@ function renderLicense(file) { ); } + +function renderAiIndicator(file) { + if (!hasAiIndicator(file)) { + return null; + } + + return ( + + + {!isBlank(file.configuration.ai_indicator_text) && + ` ${file.configuration.ai_indicator_text}`} + + ); +} diff --git a/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css b/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css index 5d70b78197..2587ea1512 100644 --- a/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css +++ b/entry_types/scrolled/package/src/frontend/InlineFileRights.module.css @@ -5,3 +5,22 @@ margin: 0; list-style: none; } + +.list > li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.5em; +} + +/* 1.08em renders the "AI" glyphs at the height they have in the round + badge of the icon variant, which encloses them with more padding + than the pills do. The vertical align value centers the pill on the + cap height of the text, which sits above the x-height center that + `middle` uses. */ +.aiIcon { + height: 1.08em; + width: auto; + vertical-align: -0.19em; + margin-right: space(0.5); +} From 02dd9668af6f45e4dc929bdf7279e31344627a07 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 08:26:21 +0200 Subject: [PATCH 4/6] Render AI indicator icon in icon inline file rights widget Both icons share one button so that they appear as one marker and the tooltip covers rights and AI information alike. The copyright icon comes last to stay aligned with the tooltip arrow. Only the basic AI symbol is used since the tooltip spells out the wording. Keeping items from wrapping only inside their parts lets rights and AI information sit side by side or stack, and leaves the scroller for parts that are too wide on their own. Since only the button follows the surrounding text color, the letters of the marks are inverted there alone: the tooltip is dark in any case. --- .../IconInlineFileRights-spec.js | 82 +++++++++++++++++++ .../IconInlineFileRights.js | 15 +++- .../IconInlineFileRights.module.css | 27 +++++- 3 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js diff --git a/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js b/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js new file mode 100644 index 0000000000..4dd2c67e6a --- /dev/null +++ b/entry_types/scrolled/package/spec/widgets/iconInlineFileRights/IconInlineFileRights-spec.js @@ -0,0 +1,82 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; + +import {IconInlineFileRights} from 'widgets/iconInlineFileRights/IconInlineFileRights'; + +import {renderInEntry} from 'support'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('IconInlineFileRights', () => { + useFakeTranslations({ + 'pageflow_scrolled.public.ai_indicators.ai': 'AI' + }); + + it('renders items in tooltip', () => { + const {container} = renderInEntry( + +
    • Some rights
    +
    + ); + + expect(container).toHaveTextContent('Some rights'); + }); + + it('renders copyright icon for rights', () => { + const {container} = renderInEntry( + + ); + + expect(container.querySelector('[data-file-name="SvgCopyright"]')).not.toBeNull(); + }); + + it('renders ai icon for ai indicators', () => { + const {queryByRole} = renderInEntry( + + ); + + expect(queryByRole('img', {name: 'AI'})).not.toBeNull(); + }); + + it('renders both icons in shared button', () => { + const {queryAllByRole, getByRole} = renderInEntry( + + ); + + expect(queryAllByRole('button')).toHaveLength(1); + expect(getByRole('button')).toContainElement(getByRole('img', {name: 'AI'})); + }); + + it('renders copyright icon last for the tooltip arrow to point at it', () => { + const {getByRole} = renderInEntry( + + ); + + const icons = getByRole('button').querySelectorAll('svg'); + + expect(icons[icons.length - 1]).toHaveAttribute('data-file-name', 'SvgCopyright'); + }); + + it('does not render copyright icon without rights', () => { + const {container} = renderInEntry( + + ); + + expect(container.querySelector('[data-file-name="SvgCopyright"]')).toBeNull(); + }); + + it('does not render ai icon without ai indicators', () => { + const {queryByRole} = renderInEntry( + + ); + + expect(queryByRole('img')).toBeNull(); + }); + + it('renders nothing in afterElement context', () => { + const {container} = renderInEntry( + + ); + + expect(container.textContent).toEqual(''); + }); +}); diff --git a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js index 34c023171e..f96ea42452 100644 --- a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js +++ b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js @@ -1,13 +1,16 @@ import React from 'react'; import classNames from 'classnames'; -import {ThemeIcon} from 'pageflow-scrolled/frontend'; +import {AiIndicatorIcon, ThemeIcon, useDarkBackground} from 'pageflow-scrolled/frontend'; import styles from './IconInlineFileRights.module.css'; export function IconInlineFileRights({ - context, position, playerControlsStandAlone, playerControlsFadedOut, children + context, position, playerControlsStandAlone, playerControlsFadedOut, + hasRights, hasAiIndicators, children }) { + const darkBackground = useDarkBackground(); + if (context === 'afterElement') { return null; } @@ -15,10 +18,14 @@ export function IconInlineFileRights({ return (
    diff --git a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css index 8ddead8aa7..fcb47d4f7c 100644 --- a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css +++ b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.module.css @@ -1,3 +1,15 @@ +@value ( + darkContentSurfaceColor, lightContentSurfaceColor +) from "pageflow-scrolled/values/colors.module.css"; + +.wrapper { + --ai-indicator-letter-color: darkContentSurfaceColor; +} + +.onLightBackground .button { + --ai-indicator-letter-color: lightContentSurfaceColor; +} + .standAlone { color: #fff; position: absolute; @@ -23,14 +35,16 @@ .button { border: 0; - padding: 0; + padding: 0 10px; background-color: transparent; - width: 44px; + box-sizing: border-box; + min-width: 44px; height: 40px; margin: 0; display: flex; align-items: center; justify-content: center; + gap: 4px; color: currentColor; cursor: pointer; position: relative; @@ -57,6 +71,11 @@ height: 23px; } +.button .aiIcon { + width: 19px; + height: 19px; +} + .standAlone .button svg { filter: drop-shadow(0 1px 4px #000); } @@ -111,11 +130,11 @@ text-decoration-color: rgba(255, 255, 255, 0.3); } -.tooltip li { +.tooltip [data-part] { white-space: nowrap; } -.tooltip span { +.tooltip [data-label] { color: rgba(255, 255, 255, 0.7); } From 6f5d1f212a3a4ae4805975988201f1d4f685d8d6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 08:28:57 +0200 Subject: [PATCH 5/6] Style combined items in text inline file rights widget The copyright sign now only precedes items that actually carry rights and belongs to that part, so it stays with the rights when the AI indicator moves to its own line. Addressing the label via data attribute keeps the rule that hides it for single items independent of which other elements an item contains. --- .../TextInlineFileRights.module.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css b/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css index 11bb262893..ebae370d4e 100644 --- a/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css +++ b/entry_types/scrolled/package/src/widgets/textInlineFileRights/TextInlineFileRights.module.css @@ -9,19 +9,25 @@ position: relative; z-index: 2; color: var(--content-text-color); + --ai-indicator-letter-color: lightContentSurfaceColor; +} + +.text.darkBackground { + --ai-indicator-letter-color: darkContentSurfaceColor; } .text li { text-align: right; + justify-content: flex-end; padding-top: 0.2em; } -.text li::before { +.text li[data-rights] [data-part="rights"]::before { content: "© "; opacity: 0.7; } -.text:not(:has(li:nth-child(2))) span { +.text li:only-child [data-label] { display: none; } @@ -54,6 +60,6 @@ text-decoration-color: color-mix(in srgb, currentColor, transparent); } -.text span { +.text [data-label] { opacity: 0.7; } From 590f2da7984de9521cd0c1ed909863d854bba69a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 28 Aug 2026 16:06:57 +0200 Subject: [PATCH 6/6] Show AI indicators in inline file rights stories Content elements opt in per element, so that a representative subset renders the indicators. Each of them gets its own story next to the one without indicators to keep both cases covered. The kind of indicator differs per file collection, which puts both pills next to each other in stories of elements that combine a media file with a poster image. --- .../spec/support/__spec__/stories-spec.js | 93 +++++++++++++++++++ .../scrolled/package/spec/support/stories.js | 65 +++++++++---- .../contentElements/inlineAudio/stories.js | 3 +- .../contentElements/inlineImage/stories.js | 3 +- .../contentElements/inlineVideo/stories.js | 3 +- 5 files changed, 146 insertions(+), 21 deletions(-) diff --git a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js index ca803b9834..678f549922 100644 --- a/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js +++ b/entry_types/scrolled/package/spec/support/__spec__/stories-spec.js @@ -218,6 +218,79 @@ describe('exampleStories', () => { })); }); + it('supports adding stories with AI indicators', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ], + videoFiles: [ + {id: 20, permaId: 2} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + aiIndicators: true, + baseConfiguration: {} + }); + + expect(stories).toContainEqual(expect.objectContaining({ + title: 'Inline File Rights - Icon with AI Indicators', + seed: expect.objectContaining({ + collections: expect.objectContaining({ + imageFiles: [ + expect.objectContaining({ + configuration: expect.objectContaining({ai_indicator: 'ai_modified'}) + }) + ], + videoFiles: [ + expect.objectContaining({ + configuration: expect.objectContaining({ai_indicator: 'ai_generated'}) + }) + ] + }) + }) + })); + }); + + it('keeps stories without AI indicators when adding stories with them', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + aiIndicators: true, + baseConfiguration: {} + }); + + const story = stories.find(story => story.title === 'Inline File Rights - Icon'); + + expect(story.seed.collections.imageFiles[0].configuration) + .not.toHaveProperty('ai_indicator'); + }); + + it('does not add stories with AI indicators by default', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const stories = exampleStories({ + typeName: 'test', + inlineFileRights: true, + baseConfiguration: {} + }); + + expect(stories.map(story => story.title)) + .not.toContain('Inline File Rights - Icon with AI Indicators'); + }); + it('supports adding story for inline file rights', () => { stubSeedFixture(normalizeSeed({ imageFiles: [ @@ -313,4 +386,24 @@ describe('normalizeAndMergeFixture', () => { } }) }); + + it('supports AI indicators for inline file rights', () => { + stubSeedFixture(normalizeSeed({ + imageFiles: [ + {id: 10, permaId: 1} + ] + })); + + const seed = normalizeAndMergeFixture({ + inlineFileRightsFor: ['imageFiles'], + aiIndicators: true + }); + + expect(seed.collections.imageFiles[0]).toMatchObject({ + configuration: { + ai_indicator: 'ai_modified', + ai_indicator_text: expect.any(String) + } + }) + }); }) diff --git a/entry_types/scrolled/package/spec/support/stories.js b/entry_types/scrolled/package/spec/support/stories.js index 14ad924079..ab7298f9e6 100644 --- a/entry_types/scrolled/package/spec/support/stories.js +++ b/entry_types/scrolled/package/spec/support/stories.js @@ -38,6 +38,12 @@ let seedFixture = seedFixtureFromFile; * @param {boolean} [consent=false] - * Set to true, to include a story which renders the content element * in a state where consent has not yet been given. + * @param {boolean} [inlineFileRights=false] - + * Set to true, to include stories which render the content element + * with file rights displayed inline. + * @param {boolean} [aiIndicators=false] - + * Set to true, to add further inline file rights stories in which + * files are marked as AI generated or AI modified. * * @example * @@ -175,6 +181,13 @@ const inlineFileRightsCollectionNames = [ 'audioFiles', 'imageFiles', 'lottieFiles', 'videoFiles' ]; +const aiIndicatorsByCollectionName = { + audioFiles: {kind: 'ai_generated', text: 'Voices cloned'}, + imageFiles: {kind: 'ai_modified', text: 'Sky replaced'}, + lottieFiles: {kind: 'ai_generated', text: 'Shapes generated'}, + videoFiles: {kind: 'ai_generated', text: 'Scene generated'} +}; + export function exampleStories(options) { return [ ...variantsExampleStories(options), @@ -287,7 +300,7 @@ function consentOptInStories({typeName, consent, baseConfiguration}) { }); } -function inlineFileRightsStories({typeName, inlineFileRights, baseConfiguration}) { +function inlineFileRightsStories({typeName, inlineFileRights, aiIndicators, baseConfiguration}) { if (!inlineFileRights) { return []; } @@ -297,22 +310,30 @@ function inlineFileRightsStories({typeName, inlineFileRights, baseConfiguration} name: 'Inline File Rights', inlineFileRightsFor: inlineFileRightsCollectionNames, examples: [ - ['Icon', 'iconInlineFileRights'], ['Text', 'textInlineFileRights'] - ].map(([name, typeName]) => ( - { - name, - widgets: [{ - role: 'inlineFileRights', - typeName - }], - contentElementConfiguration: { - ...baseConfiguration - } - } - )) + ...inlineFileRightsExamples({baseConfiguration}), + ...(aiIndicators ? inlineFileRightsExamples({baseConfiguration, aiIndicators: true}) : []) + ] }); } +function inlineFileRightsExamples({baseConfiguration, aiIndicators}) { + return [ + ['Icon', 'iconInlineFileRights'], ['Text', 'textInlineFileRights'] + ].map(([name, typeName]) => ( + { + name: aiIndicators ? `${name} with AI Indicators` : name, + aiIndicators, + widgets: [{ + role: 'inlineFileRights', + typeName + }], + contentElementConfiguration: { + ...baseConfiguration + } + } + )); +} + function exampleStoryGroup({ name, typeName, examples, viewport, consentVendors, inlineFileRightsFor, widgets }) { @@ -347,6 +368,7 @@ function exampleStoryGroup({ themeOptions: examples[index].themeOptions, consentVendors: exampleConsentVendors, inlineFileRightsFor: inlineFileRightsFor || examples[index].inlineFileRightsFor, + aiIndicators: examples[index].aiIndicators, contentElementConsentVendors: exampleConsentVendors && contentElements .filter(({id}) => id) @@ -401,7 +423,7 @@ function dasherize(text) { ); } -export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} = {}) { +export function normalizeAndMergeFixture({inlineFileRightsFor = [], aiIndicators, ...options} = {}) { const seed = normalizeSeed(options); return { @@ -420,7 +442,10 @@ export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} ...seedFixture.collections, ...inlineFileRightsFor.reduce((memo, collectionName) => ({ ...memo, - [collectionName]: applyInlineFileRights(seedFixture.collections[collectionName]) + [collectionName]: applyInlineFileRights( + seedFixture.collections[collectionName], + aiIndicators && aiIndicatorsByCollectionName[collectionName] + ) }), {}), storylines: seed.collections.storylines, chapters: seed.collections.chapters, @@ -431,14 +456,18 @@ export function normalizeAndMergeFixture({inlineFileRightsFor = [], ...options} }; } -function applyInlineFileRights(files = []) { +function applyInlineFileRights(files = [], aiIndicator) { return files.map(file => ({ ...file, rights: 'Jane Doe', configuration: { ...file.configuration, rights_display: 'inline', - source_url: 'https://example.com/jane-doe/image' + source_url: 'https://example.com/jane-doe/image', + ...(aiIndicator && { + ai_indicator: aiIndicator.kind, + ai_indicator_text: aiIndicator.text + }) } })); } diff --git a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js index fa5410cddf..0e371e9600 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineAudio/stories.js @@ -74,5 +74,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js b/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js index 79a16b1bad..0cecde51fe 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineImage/stories.js @@ -65,5 +65,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js index 4a4913e33c..ef3d58d80c 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js @@ -42,5 +42,6 @@ storiesOfContentElement(module, { } } ], - inlineFileRights: true + inlineFileRights: true, + aiIndicators: true });