diff --git a/app/lib/frontend/templates/layout.dart b/app/lib/frontend/templates/layout.dart
index 61a5e6eab1..c2a68ec68f 100644
--- a/app/lib/frontend/templates/layout.dart
+++ b/app/lib/frontend/templates/layout.dart
@@ -62,7 +62,9 @@ String renderLayoutPage(
if (type == PageType.standalone) 'page-standalone',
if (type == PageType.landing) 'page-landing',
];
- final announcementBannerHtml = announcementBackend.getAnnouncementHtml();
+ //final announcementBannerHtml = announcementBackend.getAnnouncementHtml();
+ final announcementBannerHtml =
+ 'EXAMPLE BANNER -- EXAMPLE BANNER -- EXAMPLE BANNER -- EXAMPLE BANNER!!!!!!!!';
final session = requestContext.sessionData;
final moderationSubject = () {
if (pageData == null) {
diff --git a/app/lib/frontend/templates/views/shared/layout.dart b/app/lib/frontend/templates/views/shared/layout.dart
index de1cc2239c..9d8e8a5a2e 100644
--- a/app/lib/frontend/templates/views/shared/layout.dart
+++ b/app/lib/frontend/templates/views/shared/layout.dart
@@ -195,7 +195,14 @@ d.Node pageLayoutNode({
if (announcementBanner != null)
d.div(
classes: ['announcement-banner'],
- child: announcementBanner,
+ attributes: {
+ 'data-widget': 'dismissible',
+ 'data-dismissible-by': '.dismisser'
+ },
+ children: [
+ announcementBanner,
+ d.div(classes: ['dismisser'], text: 'x'),
+ ],
),
],
),
diff --git a/pkg/web_app/lib/src/widget/completion/widget.dart b/pkg/web_app/lib/src/widget/completion/widget.dart
index e60e9892dd..0d460094b0 100644
--- a/pkg/web_app/lib/src/widget/completion/widget.dart
+++ b/pkg/web_app/lib/src/widget/completion/widget.dart
@@ -31,7 +31,7 @@ import '../../web_util.dart';
/// * `completion-dropdown` for the completion dropdown.
/// * `completion-option` for each option in the dropdown, and,
/// * `completion-option-select` is applied to selected options.
-void create(Element element, Map options) {
+void create(HTMLElement element, Map options) {
if (!element.isA()) {
throw UnsupportedError('Must be element');
}
diff --git a/pkg/web_app/lib/src/widget/dismissible/widget.dart b/pkg/web_app/lib/src/widget/dismissible/widget.dart
new file mode 100644
index 0000000000..9bbbd8c146
--- /dev/null
+++ b/pkg/web_app/lib/src/widget/dismissible/widget.dart
@@ -0,0 +1,105 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:js_interop';
+
+import 'package:collection/collection.dart';
+import 'package:web/web.dart';
+
+import '../../web_util.dart';
+
+/// Forget dismissed messages that are more than 2 years old
+late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2));
+
+/// Don't save more than 50 entries
+const _maxMissedMessages = 50;
+
+/// Create a dismissible widget on [element].
+///
+/// A `data-dismissible-by` is required, this must be a CSS selected for a
+/// child element that when clicked will dismiss the message by removing
+/// [element].
+///
+/// A hash is computed from `innerText` of [element], once dismissed this hash
+/// will be stored in `localStorage`. And next time this widget is instantiated
+/// with the same `innerText` it'll be removed immediately.
+///
+/// Hashes of dismissed messages will be stored for up to 2 years.
+/// No more than 50 dismissed messages are retained in `localStorage`.
+void create(HTMLElement element, Map options) {
+ final by = options['by'];
+ if (by == null) throw UnsupportedError('data-dismissible-by required');
+
+ late final hash = _cheapNaiveHash(element.innerText);
+ if (_dismissed.any((e) => e.hash == hash)) {
+ element.remove();
+ return;
+ } else {
+ element.style.display = 'revert';
+ }
+
+ final dismiss = (Event e) {
+ e.preventDefault();
+
+ element.remove();
+ _dismissed.add((
+ hash: hash,
+ date: DateTime.now(),
+ ));
+ _saveDismissed();
+ };
+
+ var dismissible = false;
+ for (final dismisser in element.querySelectorAll(by).toList()) {
+ if (!dismisser.isA()) continue;
+ dismisser.addEventListener('click', dismiss.toJS);
+ dismissible = true;
+ }
+ if (!dismissible) {
+ throw UnsupportedError(
+ 'data-dismissible-by must point to a child element',
+ );
+ }
+}
+
+/// Create a cheap naive hash from [text].
+String _cheapNaiveHash(String text) {
+ final half = (text.length / 2).floor();
+ return text.length.toRadixString(16).padLeft(2, '0') +
+ text.hashCode.toRadixString(16).padLeft(2, '0') +
+ text.substring(0, half).hashCode.toRadixString(16).padLeft(2, '0') +
+ text.substring(half).hashCode.toRadixString(16).padLeft(2, '0');
+}
+
+/// LocalStorage key where we store the hashes of messages that have been
+/// dismissed.
+///
+/// Data is stored on the format: `@;@;...`
+const _dismissedMessageslocalStorageKey = 'dismissed-messages';
+
+late final _dismissed = [
+ ...?window.localStorage
+ .getItem(_dismissedMessageslocalStorageKey)
+ ?.split(';')
+ .where((e) => e.contains('@'))
+ .map((entry) {
+ final [hash, date, ...] = entry.split('@');
+ return (
+ hash: hash,
+ date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0),
+ );
+ }).where((entry) => entry.date.isAfter(_deadline)),
+];
+
+void _saveDismissed() {
+ window.localStorage.setItem(
+ _dismissedMessageslocalStorageKey,
+ _dismissed
+ .sortedBy((e) => e.date) // Sort by date
+ .reversed // Reverse ordering to prefer newest dates
+ .take(_maxMissedMessages) // Limit how many entries we save
+ .map((e) => e.hash + '@' + e.date.toIso8601String().split('T').first)
+ .join(';'),
+ );
+}
diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart
index 46c877fd08..a01d558ca0 100644
--- a/pkg/web_app/lib/src/widget/widget.dart
+++ b/pkg/web_app/lib/src/widget/widget.dart
@@ -10,6 +10,7 @@ import 'package:web/web.dart';
import '../web_util.dart';
import 'completion/widget.dart' deferred as completion;
+import 'dismissible/widget.dart' deferred as dismissible;
/// Function to create an instance of the widget given an element and options.
///
@@ -21,7 +22,7 @@ import 'completion/widget.dart' deferred as completion;
/// `data-widget="completion"`. And option `src` is specified with:
/// `data-completion-src="$value"`.
typedef _WidgetFn = FutureOr Function(
- Element element,
+ HTMLElement element,
Map options,
);
@@ -31,6 +32,8 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
/// Map from widget name to widget loader
final _widgets = {
'completion': () => completion.loadLibrary().then((_) => completion.create),
+ 'dismissible': () =>
+ dismissible.loadLibrary().then((_) => dismissible.create),
};
Future<_WidgetFn> _noSuchWidget() async =>
diff --git a/pkg/web_css/lib/src/_base.scss b/pkg/web_css/lib/src/_base.scss
index a34400f10c..7bb3d46fe3 100644
--- a/pkg/web_css/lib/src/_base.scss
+++ b/pkg/web_css/lib/src/_base.scss
@@ -19,7 +19,10 @@ body {
overflow-wrap: anywhere;
}
-body, input, button, select {
+body,
+input,
+button,
+select {
font-family: var(--pub-default-text-font_family);
-webkit-font-smoothing: antialiased;
// we don't use font ligatures, and Google Sans fonts would otherwise change text in surprising ways
@@ -41,17 +44,39 @@ body,
font-weight: 400;
font-size: 16px;
- h1, h2, h3, h4, h5, h6 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
font-family: var(--pub-default-text-font_family);
font-weight: 400;
}
- h1 { font-size: 36px; }
- h2 { font-size: 24px; }
- h3 { font-size: 18px; }
- h4 { font-size: 18px; }
- h5 { font-size: 16px; }
- h6 { font-size: 16px; }
+ h1 {
+ font-size: 36px;
+ }
+
+ h2 {
+ font-size: 24px;
+ }
+
+ h3 {
+ font-size: 18px;
+ }
+
+ h4 {
+ font-size: 18px;
+ }
+
+ h5 {
+ font-size: 16px;
+ }
+
+ h6 {
+ font-size: 16px;
+ }
}
img {
@@ -134,6 +159,7 @@ main {
.standalone-side-image-block {
display: block;
}
+
.standalone-wrapper.-has-side-image {
.standalone-content {
margin: 0px;
@@ -167,7 +193,7 @@ pre {
padding: 30px;
overflow-x: auto;
- > code {
+ >code {
padding: 0px !important;
border-radius: 0;
display: inline-block;
@@ -232,29 +258,37 @@ pre {
.markdown-body {
p {
- margin-top: 8px; /* overrides github-markdown.css */
- margin-bottom: 12px; /* overrides github-markdown.css */
+ margin-top: 8px;
+ /* overrides github-markdown.css */
+ margin-bottom: 12px;
+ /* overrides github-markdown.css */
}
table {
- td, th {
- padding: 12px 12px 12px 0; /* overrides github-markdown.css */
- border: none; /* overrides github-markdown.css */
+ td,
+ th {
+ padding: 12px 12px 12px 0;
+ /* overrides github-markdown.css */
+ border: none;
+ /* overrides github-markdown.css */
}
tr {
- border-top: none; /* overrides github-markdown.css */
+ border-top: none;
+ /* overrides github-markdown.css */
&:nth-child(2n) {
- background-color: inherit; /* overrides github-markdown.css */
+ background-color: inherit;
+ /* overrides github-markdown.css */
}
}
th {
font-family: var(--pub-default-text-font_family);
font-size: 16px;
- font-weight: 400; /* overrides github-markdown.css */
+ font-weight: 400;
+ /* overrides github-markdown.css */
border-bottom: 1px solid #c8c8ca;
text-align: left;
}
@@ -264,7 +298,8 @@ pre {
}
img {
- background-color: inherit; /* overrides github-markdown.css */
+ background-color: inherit;
+ /* overrides github-markdown.css */
}
}
}
@@ -355,8 +390,13 @@ pre {
}
@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
}
.hash-link {
@@ -384,12 +424,37 @@ pre {
}
.announcement-banner {
+ display: none;
+
padding: 10px 0;
background: var(--pub-home_announcement-background-color);
font-size: 16px;
text-align: center;
+
+ .dismisser {
+ float: right;
+ padding: 5px 15px;
+ margin-top: -5px;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ z-index: 0;
+ animation-duration: 200ms;
+ animation-name: slide-down;
+ animation-timing-function: ease;
+}
+
+@keyframes slide-down {
+ from {
+ translate: 0 -100%;
+ }
+
+ to {
+ translate: 0 0;
+ }
}
a.-x-ago {
diff --git a/pkg/web_css/lib/src/_site_header.scss b/pkg/web_css/lib/src/_site_header.scss
index 0d7b2ad4a4..245066b409 100644
--- a/pkg/web_css/lib/src/_site_header.scss
+++ b/pkg/web_css/lib/src/_site_header.scss
@@ -21,6 +21,7 @@
}
.site-header {
+ z-index: 100; // for animation of announcement
background: var(--pub-site_header_banner-background-color);
color: var(--pub-site_header_banner-text-color);
display: flex;
@@ -32,6 +33,7 @@
@media (max-width: $device-mobile-max-width) {
&:focus-within {
+
.hamburger,
.site-logo {
opacity: 0.3;
@@ -223,6 +225,7 @@
}
.site-header-nav {
+
/* Navigation styles for mobile. */
@media (max-width: $device-mobile-max-width) {
position: fixed;
@@ -354,7 +357,7 @@
padding: 12px;
min-width: 100px;
- > h3 {
+ >h3 {
border-bottom: 1px solid var(--pub-site_header_popup-border-color);
}
}