From c4b84ceed3b1768fa8911adb25c3fcbd54c33d6b Mon Sep 17 00:00:00 2001 From: yimingl9 Date: Tue, 12 May 2026 19:39:38 -0400 Subject: [PATCH 1/4] feat(mywebsite): add new site Co-authored-by: Lxr-max --- sites/mega/_health.py | 5 + sites/mega/app.py | 748 ++++++++++ sites/mega/requirements.txt | 4 + sites/mega/seed_data.py | 391 +++++ sites/mega/static/css/.gitkeep | 0 sites/mega/static/css/buttons.css.css | 582 ++++++++ sites/mega/static/css/mega.css | 121 ++ sites/mega/static/css/theme.css.css | 1399 ++++++++++++++++++ sites/mega/static/icons/.gitkeep | 0 sites/mega/static/icons/check-circle-mto.svg | 3 + sites/mega/static/icons/cloud-mto.svg | 3 + sites/mega/static/icons/cloud-upload-mto.svg | 3 + sites/mega/static/icons/code-mro.svg | 3 + sites/mega/static/icons/database-mto.svg | 3 + sites/mega/static/icons/folder-mro.svg | 3 + sites/mega/static/icons/key-01-mto.svg | 4 + sites/mega/static/icons/lock-mto.svg | 3 + sites/mega/static/icons/mega.svg | 4 + sites/mega/static/icons/monitor-mro.svg | 3 + sites/mega/static/icons/password-mto.svg | 3 + sites/mega/static/icons/search-sro.svg | 4 + sites/mega/static/icons/share-mro.svg | 3 + sites/mega/static/icons/shield-mro.svg | 3 + sites/mega/static/icons/sync-mto.svg | 4 + sites/mega/static/icons/transfer-it-mto.svg | 5 + sites/mega/static/icons/users-mro.svg | 3 + sites/mega/static/js/.gitkeep | 0 sites/mega/static/js/mega.js | 6 + sites/mega/tasks.jsonl | 18 + sites/mega/templates/.gitkeep | 0 sites/mega/templates/404.html | 3 + sites/mega/templates/_plan_card.html | 19 + sites/mega/templates/account.html | 33 + sites/mega/templates/account_edit.html | 13 + sites/mega/templates/base.html | 68 + sites/mega/templates/checkout.html | 33 + sites/mega/templates/cloud_drive.html | 42 + sites/mega/templates/contact.html | 12 + sites/mega/templates/download_detail.html | 18 + sites/mega/templates/downloads.html | 21 + sites/mega/templates/file_detail.html | 26 + sites/mega/templates/help.html | 19 + sites/mega/templates/help_article.html | 21 + sites/mega/templates/index.html | 71 + sites/mega/templates/login.html | 13 + sites/mega/templates/order_detail.html | 11 + sites/mega/templates/payment_methods.html | 23 + sites/mega/templates/plan_detail.html | 39 + sites/mega/templates/pricing.html | 32 + sites/mega/templates/product_page.html | 28 + sites/mega/templates/register.html | 14 + sites/mega/templates/search.html | 39 + sites/mega/templates/ticket_detail.html | 14 + sites/mega/templates/vault.html | 33 + 54 files changed, 3976 insertions(+) create mode 100644 sites/mega/_health.py create mode 100644 sites/mega/app.py create mode 100644 sites/mega/requirements.txt create mode 100644 sites/mega/seed_data.py create mode 100644 sites/mega/static/css/.gitkeep create mode 100644 sites/mega/static/css/buttons.css.css create mode 100644 sites/mega/static/css/mega.css create mode 100644 sites/mega/static/css/theme.css.css create mode 100644 sites/mega/static/icons/.gitkeep create mode 100644 sites/mega/static/icons/check-circle-mto.svg create mode 100644 sites/mega/static/icons/cloud-mto.svg create mode 100644 sites/mega/static/icons/cloud-upload-mto.svg create mode 100644 sites/mega/static/icons/code-mro.svg create mode 100644 sites/mega/static/icons/database-mto.svg create mode 100644 sites/mega/static/icons/folder-mro.svg create mode 100644 sites/mega/static/icons/key-01-mto.svg create mode 100644 sites/mega/static/icons/lock-mto.svg create mode 100644 sites/mega/static/icons/mega.svg create mode 100644 sites/mega/static/icons/monitor-mro.svg create mode 100644 sites/mega/static/icons/password-mto.svg create mode 100644 sites/mega/static/icons/search-sro.svg create mode 100644 sites/mega/static/icons/share-mro.svg create mode 100644 sites/mega/static/icons/shield-mro.svg create mode 100644 sites/mega/static/icons/sync-mto.svg create mode 100644 sites/mega/static/icons/transfer-it-mto.svg create mode 100644 sites/mega/static/icons/users-mro.svg create mode 100644 sites/mega/static/js/.gitkeep create mode 100644 sites/mega/static/js/mega.js create mode 100644 sites/mega/tasks.jsonl create mode 100644 sites/mega/templates/.gitkeep create mode 100644 sites/mega/templates/404.html create mode 100644 sites/mega/templates/_plan_card.html create mode 100644 sites/mega/templates/account.html create mode 100644 sites/mega/templates/account_edit.html create mode 100644 sites/mega/templates/base.html create mode 100644 sites/mega/templates/checkout.html create mode 100644 sites/mega/templates/cloud_drive.html create mode 100644 sites/mega/templates/contact.html create mode 100644 sites/mega/templates/download_detail.html create mode 100644 sites/mega/templates/downloads.html create mode 100644 sites/mega/templates/file_detail.html create mode 100644 sites/mega/templates/help.html create mode 100644 sites/mega/templates/help_article.html create mode 100644 sites/mega/templates/index.html create mode 100644 sites/mega/templates/login.html create mode 100644 sites/mega/templates/order_detail.html create mode 100644 sites/mega/templates/payment_methods.html create mode 100644 sites/mega/templates/plan_detail.html create mode 100644 sites/mega/templates/pricing.html create mode 100644 sites/mega/templates/product_page.html create mode 100644 sites/mega/templates/register.html create mode 100644 sites/mega/templates/search.html create mode 100644 sites/mega/templates/ticket_detail.html create mode 100644 sites/mega/templates/vault.html diff --git a/sites/mega/_health.py b/sites/mega/_health.py new file mode 100644 index 00000000..f7b28fdc --- /dev/null +++ b/sites/mega/_health.py @@ -0,0 +1,5 @@ +"""Per-site health probe.""" + + +def health(): + return {"ok": True, "site": "mega"} diff --git a/sites/mega/app.py b/sites/mega/app.py new file mode 100644 index 00000000..e94a1e46 --- /dev/null +++ b/sites/mega/app.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +"""MEGA.io mirror — encrypted storage, apps, account, checkout, and help.""" +import json +import os +import re +from datetime import datetime +from functools import wraps + +from flask import ( + Flask, abort, flash, redirect, render_template, request, session, url_for +) +from flask_bcrypt import Bcrypt +from flask_login import ( + LoginManager, UserMixin, current_user, login_required, login_user, logout_user +) +from flask_sqlalchemy import SQLAlchemy + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, "instance", "mega.db") + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config["SECRET_KEY"] = "webharbor-mega-dev-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Log in to manage your MEGA account." + +STOP_WORDS = { + "the", "a", "an", "of", "in", "on", "at", "to", "for", "with", "and", + "or", "is", "are", "be", "by", "from", "how", "what", "which", "that", + "this", "me", "my", "mega", "plan", "plans", "file", "files" +} + + +class User(db.Model, UserMixin): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(140), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + display_name = db.Column(db.String(120), nullable=False) + phone = db.Column(db.String(40), default="") + company = db.Column(db.String(120), default="") + role = db.Column(db.String(80), default="") + address_line1 = db.Column(db.String(180), default="") + address_line2 = db.Column(db.String(180), default="") + city = db.Column(db.String(90), default="") + state = db.Column(db.String(60), default="") + postal_code = db.Column(db.String(20), default="") + country = db.Column(db.String(80), default="United States") + language = db.Column(db.String(40), default="English") + timezone = db.Column(db.String(80), default="America/Indiana/Indianapolis") + plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=True) + storage_used_gb = db.Column(db.Float, default=0) + transfer_used_gb = db.Column(db.Float, default=0) + two_factor_enabled = db.Column(db.Boolean, default=False) + recovery_key_saved = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + plan = db.relationship("Plan", backref="users", lazy=True) + cloud_items = db.relationship("CloudItem", backref="owner", lazy=True, cascade="all, delete-orphan") + vault_items = db.relationship("VaultItem", backref="owner", lazy=True, cascade="all, delete-orphan") + payment_methods = db.relationship("PaymentMethod", backref="user", lazy=True, cascade="all, delete-orphan") + orders = db.relationship("SubscriptionOrder", backref="user", lazy=True, cascade="all, delete-orphan") + tickets = db.relationship("SupportTicket", backref="user", lazy=True, cascade="all, delete-orphan") + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode("utf-8") + + def check_password(self, pw): + return bcrypt.check_password_hash(self.password_hash, pw) + + +class Plan(db.Model): + __tablename__ = "plans" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + audience = db.Column(db.String(60), default="individual") + category = db.Column(db.String(60), default="storage") + monthly_price = db.Column(db.Float, default=0) + yearly_price = db.Column(db.Float, default=0) + storage_tb = db.Column(db.Float, default=0) + transfer_tb = db.Column(db.Float, default=0) + users_included = db.Column(db.Integer, default=1) + vpn_devices = db.Column(db.Integer, default=0) + pass_accounts = db.Column(db.Integer, default=0) + s4_base_tb = db.Column(db.Float, default=0) + active = db.Column(db.Boolean, default=True) + popular = db.Column(db.Boolean, default=False) + tagline = db.Column(db.String(240), default="") + description = db.Column(db.Text, default="") + features = db.Column(db.Text, default="[]") + caveats = db.Column(db.Text, default="[]") + image = db.Column(db.String(240), default="") + + def get_features(self): + return _loads(self.features, []) + + def get_caveats(self): + return _loads(self.caveats, []) + + def annual_savings(self): + if not self.monthly_price: + return 0 + return max(0, round(self.monthly_price * 12 - self.yearly_price, 2)) + + +class ProductPage(db.Model): + __tablename__ = "product_pages" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(180), nullable=False) + slug = db.Column(db.String(100), unique=True, nullable=False, index=True) + section = db.Column(db.String(80), default="") + summary = db.Column(db.String(300), default="") + body = db.Column(db.Text, default="") + hero_image = db.Column(db.String(240), default="") + highlights = db.Column(db.Text, default="[]") + faq = db.Column(db.Text, default="[]") + nav_order = db.Column(db.Integer, default=100) + + def get_highlights(self): + return _loads(self.highlights, []) + + def get_faq(self): + return _loads(self.faq, []) + + +class Download(db.Model): + __tablename__ = "downloads" + id = db.Column(db.Integer, primary_key=True) + product = db.Column(db.String(80), nullable=False, index=True) + platform = db.Column(db.String(80), nullable=False, index=True) + package_name = db.Column(db.String(120), nullable=False) + version = db.Column(db.String(40), default="") + size_mb = db.Column(db.Float, default=0) + release_date = db.Column(db.String(20), default="") + architecture = db.Column(db.String(60), default="") + checksum = db.Column(db.String(80), default="") + notes = db.Column(db.Text, default="") + recommended = db.Column(db.Boolean, default=False) + icon = db.Column(db.String(240), default="") + + +class HelpArticle(db.Model): + __tablename__ = "help_articles" + id = db.Column(db.Integer, primary_key=True) + category = db.Column(db.String(80), nullable=False, index=True) + title = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + body = db.Column(db.Text, default="") + applies_to = db.Column(db.String(160), default="") + difficulty = db.Column(db.String(40), default="standard") + updated_at = db.Column(db.String(20), default="2026-05-12") + related_terms = db.Column(db.String(260), default="") + + +class CloudItem(db.Model): + __tablename__ = "cloud_items" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + name = db.Column(db.String(180), nullable=False) + slug = db.Column(db.String(180), nullable=False, index=True) + item_type = db.Column(db.String(30), default="file") + folder = db.Column(db.String(160), default="/") + extension = db.Column(db.String(20), default="") + size_mb = db.Column(db.Float, default=0) + modified_at = db.Column(db.String(20), default="") + sync_status = db.Column(db.String(40), default="Synced") + shared_with = db.Column(db.String(240), default="") + share_link = db.Column(db.String(240), default="") + favorite = db.Column(db.Boolean, default=False) + backup_source = db.Column(db.String(120), default="") + content_summary = db.Column(db.Text, default="") + + +class VaultItem(db.Model): + __tablename__ = "vault_items" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + title = db.Column(db.String(160), nullable=False) + slug = db.Column(db.String(160), nullable=False, index=True) + username = db.Column(db.String(140), default="") + site_url = db.Column(db.String(220), default="") + category = db.Column(db.String(60), default="Login") + strength = db.Column(db.String(40), default="Strong") + last_changed = db.Column(db.String(20), default="") + two_factor = db.Column(db.Boolean, default=False) + notes = db.Column(db.Text, default="") + + +class PaymentMethod(db.Model): + __tablename__ = "payment_methods" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + label = db.Column(db.String(80), default="Personal Visa") + card_type = db.Column(db.String(40), default="Visa") + last4 = db.Column(db.String(4), default="4242") + exp_month = db.Column(db.Integer, default=12) + exp_year = db.Column(db.Integer, default=2028) + billing_country = db.Column(db.String(80), default="United States") + is_default = db.Column(db.Boolean, default=False) + + +class SubscriptionOrder(db.Model): + __tablename__ = "subscription_orders" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False) + order_number = db.Column(db.String(40), unique=True, nullable=False) + billing_cycle = db.Column(db.String(20), default="monthly") + seats = db.Column(db.Integer, default=1) + subtotal = db.Column(db.Float, default=0) + tax = db.Column(db.Float, default=0) + total = db.Column(db.Float, default=0) + status = db.Column(db.String(40), default="active") + created_at = db.Column(db.String(20), default="") + plan = db.relationship("Plan", lazy=True) + + +class SupportTicket(db.Model): + __tablename__ = "support_tickets" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + ticket_number = db.Column(db.String(40), unique=True, nullable=False) + subject = db.Column(db.String(180), nullable=False) + category = db.Column(db.String(80), default="General") + priority = db.Column(db.String(30), default="Normal") + status = db.Column(db.String(40), default="Open") + message = db.Column(db.Text, default="") + created_at = db.Column(db.String(20), default="") + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +def _loads(value, default): + try: + return json.loads(value or "null") or default + except Exception: + return default + + +def slugify(value): + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def tokenize(query): + return [ + token for token in re.split(r"\W+", (query or "").lower()) + if len(token) > 1 and token not in STOP_WORDS + ] + + +def score_text(tokens, *fields): + haystack = " ".join(str(field or "") for field in fields).lower() + score = 0 + for token in tokens: + if token in haystack: + score += 2 + elif any(word.startswith(token) for word in haystack.split()): + score += 1 + return score + + +def scored_search(query, items, fields): + tokens = tokenize(query) + if not tokens: + return list(items) + ranked = [] + for item in items: + score = score_text(tokens, *[getattr(item, field, "") for field in fields]) + if score: + ranked.append((score, item.id, item)) + ranked.sort(key=lambda row: (-row[0], row[1])) + return [item for _, __, item in ranked] + + +def selected_plan(): + slug = session.get("checkout_plan") + return Plan.query.filter_by(slug=slug, active=True).first() if slug else None + + +def current_cart_count(): + return 1 if selected_plan() else 0 + + +def order_total(plan, billing_cycle, seats): + seats = max(1, int(seats or 1)) + if billing_cycle == "yearly": + base = plan.yearly_price + else: + base = plan.monthly_price + subtotal = round(base * seats, 2) + tax = round(subtotal * 0.07, 2) + return subtotal, tax, round(subtotal + tax, 2) + + +def user_files_query(): + return CloudItem.query.filter_by(user_id=current_user.id) + + +def anonymous_allowed(route): + @wraps(route) + def wrapped(*args, **kwargs): + return route(*args, **kwargs) + return wrapped + + +@app.context_processor +def inject_globals(): + return { + "nav_products": ProductPage.query.order_by(ProductPage.nav_order).limit(9).all(), + "cart_plan": selected_plan(), + "cart_count": current_cart_count(), + "image_url": lambda name: url_for("static", filename=f"images/{name}"), + "icon_url": lambda name: url_for("static", filename=f"icons/{name}"), + } + + +@app.template_filter("json") +def json_filter(value): + return _loads(value, []) + + +@app.template_filter("size") +def size_filter(value): + value = float(value or 0) + if value >= 1024: + return f"{value / 1024:.2f} GB" + return f"{value:.1f} MB" + + +@app.route("/") +def index(): + products = ProductPage.query.order_by(ProductPage.nav_order).limit(8).all() + featured_plans = Plan.query.filter_by(active=True).order_by(Plan.popular.desc(), Plan.monthly_price).limit(4).all() + help_articles = HelpArticle.query.order_by(HelpArticle.updated_at.desc()).limit(6).all() + return render_template("index.html", products=products, featured_plans=featured_plans, help_articles=help_articles) + + +@app.route("/pricing") +def pricing(): + audience = request.args.get("audience", "") + category = request.args.get("category", "") + billing = request.args.get("billing", "monthly") + plans_q = Plan.query.filter_by(active=True) + if audience: + plans_q = plans_q.filter_by(audience=audience) + if category: + plans_q = plans_q.filter_by(category=category) + plans = plans_q.order_by(Plan.category, Plan.monthly_price, Plan.storage_tb).all() + return render_template("pricing.html", plans=plans, audience=audience, category=category, billing=billing) + + +@app.route("/plans/") +def plan_detail(slug): + plan = Plan.query.filter_by(slug=slug, active=True).first_or_404() + alternatives = Plan.query.filter(Plan.id != plan.id, Plan.category == plan.category, Plan.active == True).limit(5).all() + return render_template("plan_detail.html", plan=plan, alternatives=alternatives) + + +@app.route("/cart/add/", methods=["POST"]) +def add_to_cart(slug): + plan = Plan.query.filter_by(slug=slug, active=True).first_or_404() + session["checkout_plan"] = plan.slug + session["billing_cycle"] = request.form.get("billing_cycle", "monthly") + session["seats"] = max(1, int(request.form.get("seats", 1) or 1)) + flash(f"{plan.name} is ready for checkout.", "success") + return redirect(url_for("checkout")) + + +@app.route("/cart") +@login_required +def cart(): + return redirect(url_for("checkout")) + + +@app.route("/checkout", methods=["GET", "POST"]) +@login_required +def checkout(): + plan = selected_plan() + if not plan: + flash("Choose a plan before checkout.", "info") + return redirect(url_for("pricing")) + billing = request.form.get("billing_cycle") or session.get("billing_cycle", "monthly") + seats = max(1, int(request.form.get("seats") or session.get("seats", 1) or 1)) + payment_id = request.form.get("payment_id", type=int) + methods = PaymentMethod.query.filter_by(user_id=current_user.id).all() + subtotal, tax, total = order_total(plan, billing, seats) + if request.method == "POST": + if not payment_id: + flash("Select a saved payment method to complete checkout.", "error") + else: + order = SubscriptionOrder( + user_id=current_user.id, + plan_id=plan.id, + order_number=f"MEGA-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{current_user.id}", + billing_cycle=billing, + seats=seats, + subtotal=subtotal, + tax=tax, + total=total, + status="active", + created_at=datetime.utcnow().strftime("%Y-%m-%d"), + ) + current_user.plan_id = plan.id + db.session.add(order) + db.session.commit() + session.pop("checkout_plan", None) + flash("Your MEGA subscription is active.", "success") + return redirect(url_for("order_detail", order_number=order.order_number)) + return render_template("checkout.html", plan=plan, methods=methods, billing=billing, seats=seats, subtotal=subtotal, tax=tax, total=total) + + +@app.route("/orders/") +@login_required +def order_detail(order_number): + order = SubscriptionOrder.query.filter_by(user_id=current_user.id, order_number=order_number).first_or_404() + return render_template("order_detail.html", order=order) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + user = User.query.filter_by(email=request.form.get("email", "").strip().lower()).first() + if user and user.check_password(request.form.get("password", "")): + login_user(user) + return redirect(request.args.get("next") or url_for("account")) + flash("Email or password did not match.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + if User.query.filter_by(email=email).first(): + flash("That email already has an account.", "error") + elif request.form.get("password") != request.form.get("confirm"): + flash("Passwords must match.", "error") + else: + user = User( + username=slugify(email.split("@")[0]), + email=email, + display_name=request.form.get("display_name", "MEGA User"), + recovery_key_saved=False, + ) + user.set_password(request.form.get("password", "")) + db.session.add(user) + db.session.commit() + login_user(user) + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + recent_files = user_files_query().order_by(CloudItem.modified_at.desc()).limit(6).all() + recent_orders = SubscriptionOrder.query.filter_by(user_id=current_user.id).order_by(SubscriptionOrder.id.desc()).limit(5).all() + tickets = SupportTicket.query.filter_by(user_id=current_user.id).order_by(SupportTicket.id.desc()).limit(5).all() + return render_template("account.html", recent_files=recent_files, recent_orders=recent_orders, tickets=tickets) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + for field in ["display_name", "phone", "company", "role", "address_line1", "address_line2", "city", "state", "postal_code", "country", "language", "timezone"]: + setattr(current_user, field, request.form.get(field, "")) + current_user.two_factor_enabled = bool(request.form.get("two_factor_enabled")) + current_user.recovery_key_saved = bool(request.form.get("recovery_key_saved")) + db.session.commit() + flash("Account details updated.", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.route("/billing/payment", methods=["GET", "POST"]) +@login_required +def payment_methods(): + if request.method == "POST": + last4 = re.sub(r"\D", "", request.form.get("card_number", ""))[-4:] + if len(last4) != 4: + flash("Enter a card number with at least four digits.", "error") + else: + if request.form.get("is_default"): + PaymentMethod.query.filter_by(user_id=current_user.id).update({"is_default": False}) + method = PaymentMethod( + user_id=current_user.id, + label=request.form.get("label", "New card"), + card_type=request.form.get("card_type", "Visa"), + last4=last4, + exp_month=int(request.form.get("exp_month", 12)), + exp_year=int(request.form.get("exp_year", 2029)), + billing_country=request.form.get("billing_country", "United States"), + is_default=bool(request.form.get("is_default")), + ) + db.session.add(method) + db.session.commit() + flash("Payment method saved.", "success") + return redirect(url_for("payment_methods")) + methods = PaymentMethod.query.filter_by(user_id=current_user.id).all() + return render_template("payment_methods.html", methods=methods) + + +@app.route("/cloud") +@app.route("/drive") +@login_required +def cloud_drive(): + q = request.args.get("q", "") + folder = request.args.get("folder", "") + items = user_files_query().all() + if folder: + items = [item for item in items if item.folder == folder] + if q: + items = scored_search(q, items, ["name", "folder", "extension", "content_summary", "shared_with", "backup_source"]) + else: + items = sorted(items, key=lambda item: (item.folder, item.item_type != "folder", item.name.lower())) + folders = sorted({item.folder for item in user_files_query().all()}) + return render_template("cloud_drive.html", items=items, folders=folders, q=q, folder=folder) + + +@app.route("/cloud/new-folder", methods=["POST"]) +@login_required +def new_folder(): + name = request.form.get("name", "").strip() + parent = request.form.get("folder", "/") + if not name: + flash("Folder name is required.", "error") + else: + item = CloudItem(user_id=current_user.id, name=name, slug=slugify(f"{parent}-{name}-{current_user.id}"), item_type="folder", folder=parent, modified_at=datetime.utcnow().strftime("%Y-%m-%d"), content_summary="User-created folder") + db.session.add(item) + db.session.commit() + flash("Folder created.", "success") + return redirect(url_for("cloud_drive", folder=parent)) + + +@app.route("/cloud/upload", methods=["POST"]) +@login_required +def upload_file(): + name = request.form.get("name", "").strip() + folder = request.form.get("folder", "/") + if not name: + flash("File name is required.", "error") + else: + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" + item = CloudItem( + user_id=current_user.id, + name=name, + slug=slugify(f"{folder}-{name}-{current_user.id}-{datetime.utcnow().timestamp()}"), + item_type="file", + folder=folder, + extension=ext, + size_mb=float(request.form.get("size_mb", 12) or 12), + modified_at=datetime.utcnow().strftime("%Y-%m-%d"), + sync_status="Synced", + content_summary=request.form.get("content_summary", "Uploaded through the MEGA web client."), + ) + db.session.add(item) + current_user.storage_used_gb += item.size_mb / 1024 + db.session.commit() + flash("Upload added to Cloud drive.", "success") + return redirect(url_for("cloud_drive", folder=folder)) + + +@app.route("/cloud/item/", methods=["GET", "POST"]) +@login_required +def file_detail(slug): + item = CloudItem.query.filter_by(user_id=current_user.id, slug=slug).first_or_404() + if request.method == "POST": + item.shared_with = request.form.get("shared_with", item.shared_with) + item.favorite = bool(request.form.get("favorite")) + if request.form.get("create_link") and not item.share_link: + item.share_link = f"https://mega.nz/file/{item.slug[:10].upper()}#webharbor" + db.session.commit() + flash("Sharing settings updated.", "success") + return redirect(url_for("file_detail", slug=item.slug)) + return render_template("file_detail.html", item=item) + + +@app.route("/pass") +def pass_page(): + page = ProductPage.query.filter_by(slug="pass").first_or_404() + return render_template("product_page.html", page=page) + + +@app.route("/vault", methods=["GET", "POST"]) +@login_required +def vault(): + if request.method == "POST": + item = VaultItem( + user_id=current_user.id, + title=request.form.get("title", "").strip(), + slug=slugify(f"{request.form.get('title', '')}-{current_user.id}-{datetime.utcnow().timestamp()}"), + username=request.form.get("username", ""), + site_url=request.form.get("site_url", ""), + category=request.form.get("category", "Login"), + strength=request.form.get("strength", "Strong"), + last_changed=datetime.utcnow().strftime("%Y-%m-%d"), + two_factor=bool(request.form.get("two_factor")), + notes=request.form.get("notes", ""), + ) + if not item.title: + flash("Vault entry title is required.", "error") + else: + db.session.add(item) + db.session.commit() + flash("Vault entry saved.", "success") + return redirect(url_for("vault")) + q = request.args.get("q", "") + items = VaultItem.query.filter_by(user_id=current_user.id).all() + if q: + items = scored_search(q, items, ["title", "username", "site_url", "category", "notes", "strength"]) + return render_template("vault.html", items=items, q=q) + + +@app.route("/downloads") +def downloads(): + product = request.args.get("product", "") + platform = request.args.get("platform", "") + q = Download.query + if product: + q = q.filter_by(product=product) + if platform: + q = q.filter_by(platform=platform) + downloads_list = q.order_by(Download.product, Download.platform, Download.recommended.desc()).all() + return render_template("downloads.html", downloads=downloads_list, product=product, platform=platform) + + +@app.route("/downloads/") +def download_detail(download_id): + download = Download.query.get_or_404(download_id) + return render_template("download_detail.html", download=download) + + +@app.route("/help") +def help_center(): + q = request.args.get("q", "") + category = request.args.get("category", "") + articles = HelpArticle.query.all() + if category: + articles = [a for a in articles if a.category == category] + if q: + articles = scored_search(q, articles, ["title", "body", "applies_to", "related_terms", "category"]) + categories = sorted({a.category for a in HelpArticle.query.all()}) + return render_template("help.html", articles=articles, categories=categories, q=q, category=category) + + +@app.route("/help/") +def help_article(slug): + article = HelpArticle.query.filter_by(slug=slug).first_or_404() + related = scored_search(article.related_terms, HelpArticle.query.filter(HelpArticle.id != article.id).all(), ["title", "body", "category"])[:4] + return render_template("help_article.html", article=article, related=related) + + +@app.route("/contact", methods=["GET", "POST"]) +@login_required +def contact(): + if request.method == "POST": + subject = request.form.get("subject", "").strip() + message = request.form.get("message", "").strip() + if not subject or not message: + flash("Subject and message are required.", "error") + else: + ticket = SupportTicket( + user_id=current_user.id, + ticket_number=f"MEGA-T{datetime.utcnow().strftime('%m%d%H%M%S')}{current_user.id}", + subject=subject, + category=request.form.get("category", "General"), + priority=request.form.get("priority", "Normal"), + status="Open", + message=message, + created_at=datetime.utcnow().strftime("%Y-%m-%d"), + ) + db.session.add(ticket) + db.session.commit() + flash("Support ticket submitted.", "success") + return redirect(url_for("ticket_detail", ticket_number=ticket.ticket_number)) + return render_template("contact.html") + + +@app.route("/support/tickets/") +@login_required +def ticket_detail(ticket_number): + ticket = SupportTicket.query.filter_by(user_id=current_user.id, ticket_number=ticket_number).first_or_404() + return render_template("ticket_detail.html", ticket=ticket) + + +@app.route("/search") +def search(): + q = request.args.get("q", "") + plans = scored_search(q, Plan.query.filter_by(active=True).all(), ["name", "audience", "category", "tagline", "description", "features"])[:10] + pages = scored_search(q, ProductPage.query.all(), ["title", "section", "summary", "body", "highlights"])[:10] + articles = scored_search(q, HelpArticle.query.all(), ["title", "body", "applies_to", "related_terms", "category"])[:10] + downloads_found = scored_search(q, Download.query.all(), ["product", "platform", "package_name", "version", "architecture", "notes"])[:10] + files = [] + vault_items = [] + if current_user.is_authenticated: + files = scored_search(q, user_files_query().all(), ["name", "folder", "extension", "content_summary", "shared_with", "backup_source"])[:10] + vault_items = scored_search(q, VaultItem.query.filter_by(user_id=current_user.id).all(), ["title", "username", "site_url", "category", "notes", "strength"])[:10] + return render_template("search.html", q=q, plans=plans, pages=pages, articles=articles, downloads=downloads_found, files=files, vault_items=vault_items) + + +@app.route("/") +def product_page(slug): + page = ProductPage.query.filter_by(slug=slug).first() + if not page: + abort(404) + return render_template("product_page.html", page=page) + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "mega", "plans": Plan.query.count(), "users": User.query.count()} + + +with app.app_context(): + db.create_all() + from seed_data import seed_benchmark_users, seed_database + + seed_database() + seed_benchmark_users() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/mega/requirements.txt b/sites/mega/requirements.txt new file mode 100644 index 00000000..d98a3746 --- /dev/null +++ b/sites/mega/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-Bcrypt diff --git a/sites/mega/seed_data.py b/sites/mega/seed_data.py new file mode 100644 index 00000000..d3582ab9 --- /dev/null +++ b/sites/mega/seed_data.py @@ -0,0 +1,391 @@ +"""Deterministic seed data for the MEGA mirror.""" +import json +from datetime import datetime + +from app import ( + CloudItem, Download, HelpArticle, PaymentMethod, Plan, ProductPage, + SubscriptionOrder, SupportTicket, User, VaultItem, db, slugify +) + +PASSWORD = "TestPass123!" +SEED_DATE = "2026-05-12" + + +def dumps(value): + return json.dumps(value, sort_keys=True) + + +def seed_database(): + if Plan.query.count() > 0: + return + + plans = [ + ("Free", "free", "individual", "storage", 0, 0, 0.02, 0.10, 1, 0, 0, 0, False, + "Start with encrypted storage", + "A no-cost account for basic encrypted storage, sharing, and testing MEGA apps.", + ["20 GB starting storage", "Encrypted file sharing", "Mobile and desktop apps", "Basic transfer quota"], ["Transfer quota is limited"]), + ("Pro Lite", "pro-lite", "individual", "storage", 5.49, 54.99, 0.40, 1.00, 1, 5, 1, 0, False, + "Extra space for everyday files", + "A compact plan for personal documents, phone photos, and a small password vault.", + ["400 GB storage", "1 TB transfer", "MEGA Pass included", "VPN on 5 devices"], ["Best for one person"]), + ("Pro I", "pro-i", "individual", "storage", 10.99, 109.99, 2.00, 2.00, 1, 10, 1, 0, True, + "More room for photos and projects", + "Balanced encrypted cloud storage with enough transfer for active personal workflows.", + ["2 TB storage", "2 TB transfer", "10 VPN devices", "Password manager", "Priority email support"], []), + ("Pro II", "pro-ii", "individual", "storage", 21.99, 219.99, 8.00, 8.00, 1, 10, 1, 0, False, + "Large encrypted storage for creators", + "A larger plan for video archives, client project files, and recurring backups.", + ["8 TB storage", "8 TB transfer", "File versioning", "MEGA Pass", "MEGA VPN"], []), + ("Pro III", "pro-iii", "individual", "storage", 32.99, 329.99, 16.00, 16.00, 1, 10, 1, 0, False, + "Maximum personal capacity", + "High-capacity encrypted storage for heavy archives and large media libraries.", + ["16 TB storage", "16 TB transfer", "Large shared links", "Priority support", "Advanced recovery tools"], []), + ("VPN Monthly", "vpn-monthly", "individual", "vpn", 3.99, 0, 0, 0, 1, 10, 0, 0, False, + "Private browsing month to month", + "Standalone VPN access with ad blocking, unsafe network detection, and a kill switch.", + ["10 devices", "Unlimited VPN traffic", "Ad blocking", "Kill switch", "Automatic unsafe network protection"], []), + ("VPN Annual", "vpn-annual", "individual", "vpn", 2.34, 28.08, 0, 0, 1, 10, 0, 0, True, + "Lowest VPN monthly equivalent", + "Annual VPN access for users who want the lowest monthly equivalent price.", + ["10 devices", "Unlimited traffic", "Always-on protection", "City-level locations", "Ad blocking"], ["Charged annually"]), + ("Pass Free", "pass-free", "individual", "pass", 0, 0, 0, 0, 1, 0, 1, 0, False, + "Secure password storage", + "MEGA Pass for a starter vault with encrypted login storage.", + ["Encrypted vault", "Password generator", "Autofill", "Device sync"], ["Limited sharing"]), + ("Pass Pro", "pass-pro", "individual", "pass", 2.99, 29.99, 0, 0, 1, 0, 1, 0, False, + "Unlimited password protection", + "Full MEGA Pass with unlimited vault entries, secure notes, and sharing.", + ["Unlimited passwords", "Secure notes", "Password health", "One-time password support", "Vault export"], []), + ("Business Starter", "business-starter", "business", "business", 15.00, 150.00, 3.00, 3.00, 3, 10, 3, 0, False, + "Small team storage", + "A team plan for startups that need encrypted storage, user management, and shared folders.", + ["3 users included", "3 TB pooled storage", "Admin dashboard", "Shared folders", "Team chat"], []), + ("Business Pro", "business-pro", "business", "business", 24.00, 240.00, 10.00, 10.00, 5, 10, 5, 0, True, + "Team collaboration with room to grow", + "Expanded team storage with stronger administration, compliance exports, and priority support.", + ["5 users included", "10 TB pooled storage", "User management dashboard", "External collaborator controls", "Priority support"], []), + ("Business Enterprise", "business-enterprise", "business", "business", 40.00, 400.00, 25.00, 25.00, 10, 10, 10, 0, False, + "Large-team governance", + "Encrypted collaboration for larger teams with advanced support and account governance.", + ["10 users included", "25 TB pooled storage", "Advanced account recovery", "Audit-ready exports", "Dedicated onboarding"], []), + ("S4 Fixed Storage", "s4-fixed-storage", "business", "objectstorage", 19.99, 199.99, 3.00, 15.00, 1, 0, 0, 3, False, + "Predictable S3-compatible storage", + "Object storage for backups and media libraries with zero surprise egress on typical workloads.", + ["3 TB base object storage", "S3-compatible API", "5x included egress guide", "Lifecycle-friendly archive use"], []), + ("Pro Flexi", "pro-flexi", "business", "objectstorage", 16.00, 160.00, 3.00, 3.00, 1, 10, 1, 3, False, + "Pay as you grow", + "Flexible base storage and transfer for variable cloud workloads and S4 experiments.", + ["3 TB base storage and transfer", "Additional TB billing", "MEGA VPN", "MEGA Pass", "Priority support"], ["Usage can vary monthly"]), + ("S4 Media Vault", "s4-media-vault", "business", "objectstorage", 29.00, 290.00, 6.00, 18.00, 1, 0, 0, 6, False, + "Media archive object storage", + "A media-focused object storage plan for teams keeping large video libraries online.", + ["6 TB object storage", "Media lifecycle workflows", "Preview-friendly archive structure", "Predictable monthly billing"], []), + ("S4 Analytics Reserve", "s4-analytics-reserve", "business", "objectstorage", 34.00, 340.00, 8.00, 12.00, 1, 0, 0, 8, False, + "Data lake reserve capacity", + "Object storage for analytics exports and model training sets with steady retention needs.", + ["8 TB object storage", "Analytics export staging", "Retention labels", "Team access policies"], []), + ("S4 Developer Sandbox", "s4-developer-sandbox", "business", "objectstorage", 9.00, 90.00, 1.00, 2.00, 1, 0, 0, 1, False, + "Small object storage sandbox", + "A lower-capacity object storage plan for development tests and automation rehearsals.", + ["1 TB object storage", "Test automation workflows", "Temporary datasets", "Developer-friendly setup"], []), + ("S4 Enterprise Archive", "s4-enterprise-archive", "business", "objectstorage", 58.00, 580.00, 20.00, 30.00, 1, 0, 0, 20, False, + "Long-retention archive storage", + "Large object storage capacity for compliance archives and disaster recovery copies.", + ["20 TB object storage", "Archive policy planning", "Disaster recovery copies", "Priority onboarding"], []), + ("Backup Reserve", "backup-reserve", "business", "objectstorage", 12.00, 120.00, 2.00, 4.00, 1, 0, 0, 2, False, + "Backup-focused storage pool", + "A near-miss storage option for backup teams that need object-style retention but less transfer.", + ["2 TB retained backups", "Desktop backup staging", "Version history review", "Recovery planning"], []), + ] + for row in plans: + db.session.add(Plan( + name=row[0], slug=row[1], audience=row[2], category=row[3], + monthly_price=row[4], yearly_price=row[5], storage_tb=row[6], + transfer_tb=row[7], users_included=row[8], vpn_devices=row[9], + pass_accounts=row[10], s4_base_tb=row[11], popular=row[12], + tagline=row[13], description=row[14], features=dumps(row[15]), + caveats=dumps(row[16]), image="logo-mega.png", + )) + + pages = [ + ("Cloud storage", "storage", "Products", "Securely store, manage, and share encrypted files online.", "Feature-1.png", + ["Encrypted storage by default", "Fast downloads and uploads", "Mobile and desktop access", "File and folder links"]), + ("Business", "business", "Business", "Encrypted team storage, user management, and project collaboration.", "Business-img-1.png", + ["Team dashboard", "Shared folders", "Video meetings", "Client-safe file exchange"]), + ("Object storage", "objectstorage", "Products", "S3-compatible storage for backup, media, analytics, and automation.", "True-Nas-Community-edition.png", + ["S3-compatible API", "Predictable pricing", "Backup and archiving", "Media and ML workloads"]), + ("VPN", "vpn", "Products", "Private browsing with ad blocking, unsafe network detection, and a kill switch.", "MEGA-VPN_Locations.png", + ["10 devices", "Unlimited VPN traffic", "Unsafe network detection", "Kill switch"]), + ("Password manager", "pass", "Products", "Encrypted password vault, autofill, generator, and one-time-password support.", "pass-hero.png", + ["Encrypted vault", "Password health", "OTP support", "Secure notes"]), + ("Transfer.it", "transfer-it", "Products", "Fast, simple, and secure file transfers powered by MEGA.", "transfer-it-hero-1.png", + ["Generous limits", "Frictionless performance", "Link-based transfers", "MEGA account controls"]), + ("Desktop app", "desktop", "Apps", "Windows, macOS, and Linux app for sync, backup, and transfer control.", "DA-img-2.png", + ["Selective sync", "Backup folders", "Transfer manager", "Windows, macOS, Linux"]), + ("Mobile apps", "mobile", "Apps", "Android and iOS cloud storage, camera uploads, scan, print, and offline files.", "Mobile-img-2.png", + ["Camera uploads", "Offline access", "Document scan", "Slideshow viewing"]), + ("MEGA CMD", "cmd", "Apps", "Command-line access to MEGA services for automation and advanced workflows.", "20230215_Mega_icons_upd_00017.png", + ["Interactive shell", "Scriptable commands", "QNAP and Synology packages", "Build documentation"]), + ("Sync", "syncing", "Features", "Automated synchronisation across devices and shared folders.", "Sync-img-1-1.png", + ["Shared-folder sync", "Selective sync", "Version handling", "Secure transport"]), + ("Backup", "megabackup", "Features", "Back up and recover important files with visibility across devices.", "Backup-2.png", + ["Device backups", "Recovery history", "Ransomware recovery", "Phone photo backups"]), + ("Share", "share", "Features", "Secure sharing with links, contacts, upload folders, and chat collaboration.", "share-1.png", + ["Folder sharing", "Upload requests", "Chat collaboration", "Link controls"]), + ("Security and privacy", "security", "Company", "Zero-knowledge encryption, recovery keys, 2FA, and transparent security design.", "Sec-img-1.png", + ["End-to-end encryption", "Recovery key", "2FA", "Ransomware recovery"]), + ("Developers", "developers", "Resources", "MEGA SDK and developer documentation for integrating MEGA client access.", "20230215_Mega_icons_upd_00003.png", + ["Core SDK", "C++ client engine", "API access model", "Open source references"]), + ("Freelancers", "freelancers", "Solutions", "Encrypted storage and transfer workflows for independent professionals.", "MEGA-icon-cloud.png", + ["Client delivery", "Password vault", "Project backups", "Shared folders"]), + ("Small business", "small-business", "Solutions", "Storage, backups, chat, and external collaboration for small teams.", "Business-img-3.png", + ["Admin dashboard", "Team folders", "Backup policies", "Client uploads"]), + ("Media files", "media-files", "Solutions", "Large media previews, archives, delivery links, and encrypted collaboration.", "Media-and-video-storage.png", + ["Video archive", "Preview links", "Transfer capacity", "Version recovery"]), + ] + for order, (title, slug, section, summary, image, highlights) in enumerate(pages, 1): + body = ( + f"{title} on MEGA focuses on private, encrypted workflows. " + f"The mirror keeps the same navigation and product framing while adding local forms and account data for benchmark tasks." + ) + faq = [ + {"q": f"Who is {title} for?", "a": summary}, + {"q": "Does this work with a MEGA account?", "a": "Yes. Authenticated routes in this mirror persist to the local SQLite database."}, + ] + db.session.add(ProductPage( + title=title, slug=slug, section=section, summary=summary, body=body, + hero_image=image, highlights=dumps(highlights), faq=dumps(faq), nav_order=order + )) + + downloads = [ + ("Desktop", "Windows", "MEGAsyncSetup64.exe", "5.12.1", 83.4, "x64", True, "Windows-1.png", "Sync, backup, and transfer manager for Windows."), + ("Desktop", "Windows", "MEGAcmdSetup64.exe", "1.7.2", 57.2, "x64", False, "Windows-2.png", "Command-line package for Windows automation."), + ("Desktop", "Windows", "MEGAsyncSetup32.exe", "5.12.1", 79.2, "x86", False, "Windows-1.png", "Legacy Windows sync package for 32-bit systems."), + ("Desktop", "Windows", "MEGAsyncARM64.exe", "5.12.1", 81.6, "ARM64", False, "Windows-2.png", "Windows ARM64 sync package."), + ("CMD", "Windows", "MEGAcmdPortable.zip", "1.7.2", 49.8, "Portable x64", False, "20230215_Mega_icons_upd_00017.png", "Portable command-line archive for Windows automation."), + ("Pass", "Windows", "MEGAPassDesktop.exe", "1.12.4", 58.6, "x64", False, "password-icon.png", "Desktop password manager companion for Windows."), + ("Desktop", "macOS", "MEGAsync-macOS.dmg", "5.12.1", 96.1, "Apple silicon + Intel", True, "MacOS-1.png", "Desktop sync and backup for macOS."), + ("Desktop", "Linux", "megasync-x86_64.deb", "5.12.1", 72.8, "Debian/Ubuntu", True, "DA-img-3.png", "Linux desktop client for sync and transfer control."), + ("Mobile", "Android", "MEGA Android app", "14.8", 64.0, "ARM", True, "Android-1.png", "Camera uploads, offline files, and document scan."), + ("Mobile", "iOS", "MEGA iOS app", "14.8", 71.5, "Universal", True, "iOS-1.png", "Camera uploads, slideshow, live text detection, and offline files."), + ("VPN", "Windows", "MEGAvpnSetup64.exe", "2.3.0", 41.2, "x64", True, "Picture-3vpn.png", "MEGA VPN with kill switch and ad blocking."), + ("VPN", "macOS", "MEGA VPN.dmg", "2.3.0", 46.5, "Apple silicon + Intel", True, "MEGA-VPN_Locations.png", "VPN client for macOS."), + ("VPN", "Android", "MEGA VPN Android", "2.3.0", 38.2, "ARM", False, "Android-2.png", "Mobile VPN protection."), + ("VPN", "iOS", "MEGA VPN iOS", "2.3.0", 39.0, "Universal", False, "iOS-2.png", "iOS VPN protection."), + ("Pass", "Chrome", "MEGA Pass Chrome extension", "1.12.4", 9.2, "Browser", True, "icon-Chrome.png", "Autofill and password vault extension."), + ("Pass", "Firefox", "MEGA Pass Firefox extension", "1.12.4", 9.4, "Browser", False, "firefox_logo_platform.png", "Firefox extension for MEGA Pass."), + ("Pass", "Edge", "MEGA Pass Edge extension", "1.12.4", 9.3, "Browser", False, "icon-Microsoft-Edge.png", "Edge extension for MEGA Pass."), + ("CMD", "QNAP", "MEGAcmd_QNAP.qpkg", "1.7.2", 65.3, "NAS", False, "20230215_Mega_icons_upd_00017.png", "QNAP package for command automation."), + ("CMD", "Synology", "MEGAcmd_Synology.spk", "1.7.2", 62.9, "NAS", False, "Synology-logo.png", "Synology package for command automation."), + ] + for product, platform, package, version, size, architecture, recommended, icon, notes in downloads: + db.session.add(Download( + product=product, platform=platform, package_name=package, version=version, + size_mb=size, release_date=SEED_DATE, architecture=architecture, + checksum=f"sha256-{slugify(package)[:18]}-webharbor", notes=notes, + recommended=recommended, icon=icon + )) + + article_specs = [ + ("Account", "Save your recovery key", "Your recovery key is the safety net for a forgotten password. Download it, store it offline, and do not share it with support or teammates.", "recovery key account password backup"), + ("Account", "Enable two-factor authentication", "Two-factor authentication adds a second verification step. Use an authenticator app and keep backup codes outside the vault.", "2fa authenticator security login"), + ("Account", "Change account email address", "Verify the new email address before removing the old one. Team admins should confirm the user still appears in the dashboard.", "email profile account"), + ("Cloud drive", "Create a shared folder", "Use folder sharing when collaborators need ongoing access. Choose read-only, read-and-write, or full access depending on the project.", "shared folder permissions collaboration"), + ("Cloud drive", "Create a file link", "File links can be created from the detail menu. Add a decryption key only when the recipient is trusted.", "file link share key"), + ("Cloud drive", "Find large files", "Sort by size or search by extension to identify large media archives before changing plans.", "large files storage quota search"), + ("Cloud drive", "Recover deleted files", "Recovery depends on file versioning and retention. Check the backup source and modified date before restoring.", "restore deleted file ransomware"), + ("Sync", "Set up selective sync", "Selective sync keeps chosen folders on each device while leaving the rest in the cloud.", "selective sync desktop folders"), + ("Sync", "Resolve sync conflicts", "Conflicted copies show the device and timestamp. Keep the latest verified edit and archive the extra copy.", "sync conflict desktop"), + ("Backup", "Add a desktop backup", "Choose folders from the desktop app, name the backup source, and confirm it appears in Cloud drive.", "backup desktop source"), + ("Backup", "Recover from ransomware", "Disconnect the affected device, review version history, and restore a clean version from before the incident.", "ransomware recovery version history"), + ("Mobile", "Turn on camera uploads", "Camera uploads can include photos and videos. Confirm the destination folder and mobile data preference.", "camera uploads mobile photos"), + ("Mobile", "Make files available offline", "Mark files for offline access from the mobile app; they remain encrypted on the device.", "offline access mobile"), + ("Pass", "Import passwords from a browser", "Export a CSV from the browser, import it into MEGA Pass, then delete the CSV after confirming entries.", "password import browser csv"), + ("Pass", "Use one-time passwords", "MEGA Pass can store one-time-password seeds for accounts that support two-factor authentication.", "otp password manager two factor"), + ("Pass", "Find weak passwords", "Password health highlights reused or weak entries. Update the oldest weak entries first.", "weak password health reused"), + ("VPN", "Use automatic VPN on unsafe networks", "Automatic protection detects untrusted networks and starts the VPN without another click.", "unsafe network automatic vpn"), + ("VPN", "Understand the kill switch", "The kill switch blocks internet traffic if the VPN tunnel drops, preventing accidental exposure.", "kill switch vpn privacy"), + ("VPN", "Choose a VPN city", "Pick the nearest city for speed or a specific region for a location-sensitive workflow.", "vpn city location"), + ("Object storage", "Estimate S4 costs", "S4 planning starts with stored terabytes and egress. Included egress is based on typical usage ratios.", "s4 object storage egress cost"), + ("Object storage", "Connect S3 tools", "Use S3-compatible access keys with tools such as Rclone, Duplicacy, TrueNAS, and S3 Browser.", "s3 compatible rclone truenas duplicacy"), + ("Object storage", "Plan backup and archiving", "Object storage works well for long-term archives, disaster recovery, and media libraries.", "backup archiving s4"), + ("Business", "Invite a team member", "Admins invite users from the team dashboard and assign access to shared folders after acceptance.", "team invite admin dashboard"), + ("Business", "Remove external collaborator access", "Review shared folders, remove the collaborator, and rotate links if they had download access.", "external collaborator remove access"), + ("Business", "Review team storage usage", "The dashboard separates pooled storage, transfer, and backup sources so admins can plan upgrades.", "team storage usage dashboard"), + ("Business", "Export a compliance summary", "Export account, device, and shared-folder summaries for internal audit workflows.", "compliance export audit"), + ("Transfer.it", "Send a large transfer", "Transfer.it is for simple link-based delivery when a persistent shared folder is unnecessary.", "large transfer link"), + ("Transfer.it", "Manage transfer expiry", "Set an expiry date for sensitive transfers and resend a new link if the recipient misses it.", "transfer expiry link"), + ("Downloads", "Pick the right desktop package", "Windows users usually need MEGAsyncSetup64.exe; automation users may need MEGAcmdSetup64.exe.", "desktop windows cmd package"), + ("Downloads", "Install CMD on NAS", "QNAP and Synology packages are available for NAS automation workflows.", "cmd qnap synology nas"), + ("Security", "Understand zero-knowledge encryption", "MEGA cannot reset an account password without the user's recovery key because file keys are controlled by the user.", "zero knowledge encryption privacy"), + ("Security", "Share safely with decryption keys", "Treat decryption keys like passwords and send them through a separate channel when needed.", "decryption key share"), + ("Developers", "Use the MEGA SDK", "The SDK provides the client access engine and keeps encryption behavior consistent across integrations.", "sdk developers client engine"), + ("Developers", "Automate with MEGA CMD", "MEGA CMD supports scripted uploads, downloads, sync checks, and account status commands.", "cmd automate commands"), + ("Billing", "Switch from monthly to yearly", "Yearly billing uses the annual price and can reduce the monthly equivalent for eligible plans.", "billing yearly monthly savings"), + ("Billing", "Update a payment method", "Add the new card, mark it default, then remove the old card after the next successful renewal.", "payment method card billing"), + ] + for category, title, body, terms in article_specs: + db.session.add(HelpArticle( + category=category, title=title, slug=slugify(title), body=body, + applies_to=category, difficulty="standard", updated_at=SEED_DATE, + related_terms=terms + )) + + db.session.commit() + + +def seed_benchmark_users(): + if User.query.filter_by(email="alice.j@test.com").first(): + return + + plan_by_slug = {p.slug: p for p in Plan.query.all()} + users = [ + ("alice_j", "alice.j@test.com", "Alice Johnson", "Riverlight Studio", "Creative Director", "pro-ii", 5240, 1810, True, True), + ("bob_c", "bob.c@test.com", "Bob Chen", "Northstar Analytics", "Data Engineer", "pro-flexi", 2320, 980, True, False), + ("carol_d", "carol.d@test.com", "Carol Davis", "Cedar Legal Group", "Operations Manager", "business-pro", 7710, 2450, False, True), + ("david_k", "david.k@test.com", "David Kim", "David Kim Photo", "Freelancer", "pro-i", 1180, 540, True, True), + ] + user_objs = {} + for username, email, display, company, role, plan_slug, storage, transfer, two_factor, recovery in users: + user = User( + username=username, email=email, display_name=display, company=company, role=role, + phone="317-555-0142", address_line1="415 Market Street", address_line2="Suite 8", + city="Indianapolis", state="IN", postal_code="46204", country="United States", + language="English", timezone="America/Indiana/Indianapolis", + plan_id=plan_by_slug[plan_slug].id, storage_used_gb=storage, transfer_used_gb=transfer, + two_factor_enabled=two_factor, recovery_key_saved=recovery + ) + user.set_password(PASSWORD) + db.session.add(user) + user_objs[email] = user + db.session.flush() + + for user in user_objs.values(): + db.session.add_all([ + PaymentMethod(user_id=user.id, label="Personal Visa", card_type="Visa", last4="4242", exp_month=12, exp_year=2028, billing_country="United States", is_default=True), + PaymentMethod(user_id=user.id, label="Backup Mastercard", card_type="Mastercard", last4="1881", exp_month=9, exp_year=2029, billing_country="United States", is_default=False), + ]) + + alice = user_objs["alice.j@test.com"] + bob = user_objs["bob.c@test.com"] + carol = user_objs["carol.d@test.com"] + david = user_objs["david.k@test.com"] + + def add_files(user, specs): + for name, folder, item_type, size, modified, sync_status, shared, favorite, backup, summary in specs: + db.session.add(CloudItem( + user_id=user.id, name=name, slug=slugify(f"{user.username}-{folder}-{name}"), + item_type=item_type, folder=folder, extension=(name.rsplit(".", 1)[-1].lower() if "." in name else ""), + size_mb=size, modified_at=modified, sync_status=sync_status, shared_with=shared, + share_link=(f"https://mega.nz/file/{slugify(name)[:10].upper()}#seeded" if shared else ""), + favorite=favorite, backup_source=backup, content_summary=summary + )) + + common_distractors = [ + ("Travel itinerary.pdf", "/Documents", "file", 3.4, "2026-04-22", "Synced", "", False, "", "Personal itinerary and bookings."), + ("Old export passwords.csv", "/Security", "file", 0.7, "2025-10-02", "Synced", "", False, "", "Legacy password export retained for audit only."), + ("Camera Uploads 2026.zip", "/Backups/Phone", "file", 884.0, "2026-05-01", "Synced", "david.k@test.com", False, "iPhone 15 Pro", "Photo and video backup archive."), + ("Shared client intake.docx", "/Client Vault", "file", 2.1, "2026-04-18", "Synced", "carol.d@test.com", True, "", "Client intake form and checklist."), + ("Tax receipts archive.zip", "/Finance", "file", 76.4, "2026-03-18", "Synced", "", False, "", "Annual tax receipt package."), + ("Conference slides.pptx", "/Documents", "file", 44.9, "2026-04-02", "Synced", "", False, "", "Presentation slides for conference talk."), + ("Device backup summary.pdf", "/Backups", "file", 2.8, "2026-05-05", "Synced", "", False, "Desktop app", "Backup status exported from desktop app."), + ("Shared media preview.mp4", "/Media Archive", "file", 520.0, "2026-04-25", "Synced", "alice.j@test.com", False, "", "Preview video shared with collaborators."), + ("Project notes.txt", "/Projects", "file", 0.2, "2026-05-03", "Synced", "", False, "", "General project notes."), + ("Recovery checklist.pdf", "/Security", "file", 1.5, "2026-04-10", "Synced", "", True, "", "Account recovery and incident-response checklist."), + ] + add_files(alice, [ + ("Brand refresh brief.pdf", "/Projects/Atlas", "file", 18.6, "2026-05-08", "Synced", "bob.c@test.com", True, "", "Creative brief for Atlas rebrand."), + ("Atlas launch footage.mov", "/Media Archive", "file", 4380.0, "2026-05-06", "Synced", "bob.c@test.com, carol.d@test.com", False, "MacBook Pro", "Long-form 4K launch footage for Atlas."), + ("Atlas social cuts.zip", "/Media Archive", "file", 1260.0, "2026-05-07", "Syncing", "bob.c@test.com", False, "MacBook Pro", "Compressed short clips for social launch."), + ("Q2 invoice batch.xlsx", "/Finance", "file", 9.8, "2026-04-30", "Synced", "carol.d@test.com", True, "", "Quarterly invoices and payment status."), + ("MEGA recovery key.pdf", "/Security", "file", 0.4, "2026-01-15", "Synced", "", True, "", "Account recovery key saved offline."), + ("Client testimonials raw.wav", "/Media Archive", "file", 740.5, "2026-05-03", "Synced", "", False, "Studio NAS", "Audio interviews for testimonial edit."), + ("Atlas b-roll selects.mov", "/Media Archive", "file", 980.0, "2026-05-05", "Synced", "", False, "MacBook Pro", "B-roll clips for Atlas launch."), + ("Atlas color grade preview.mp4", "/Media Archive", "file", 610.0, "2026-05-04", "Synced", "bob.c@test.com", False, "MacBook Pro", "Compressed color grade preview."), + ("Podcast sponsor reel.mov", "/Media Archive", "file", 1320.0, "2026-04-27", "Synced", "", False, "Studio NAS", "Sponsor reel export."), + ("Product stills archive.zip", "/Media Archive", "file", 980.0, "2026-04-19", "Synced", "carol.d@test.com", False, "", "Product photography archive."), + ("Iceland selects 01.zip", "/Photos/Iceland", "file", 1860.0, "2026-02-18", "Synced", "david.k@test.com", True, "iPhone 15 Pro", "Edited Iceland travel photos."), + ("Iceland selects 02.zip", "/Photos/Iceland", "file", 1744.0, "2026-02-20", "Synced", "", False, "iPhone 15 Pro", "Alternate photo set."), + ("Studio insurance policy.pdf", "/Documents", "file", 5.5, "2026-03-11", "Synced", "", False, "", "Business insurance policy."), + ("Ransomware drill notes.md", "/Security", "file", 1.2, "2026-04-12", "Synced", "bob.c@test.com", False, "", "Recovery rehearsal and restore timings."), + ("Atlas", "/", "folder", 0, "2026-05-08", "Synced", "bob.c@test.com", True, "", "Project folder."), + ("Media Archive", "/", "folder", 0, "2026-05-06", "Synced", "bob.c@test.com, carol.d@test.com", False, "", "Video and audio media."), + ] + common_distractors) + add_files(bob, [ + ("warehouse telemetry.parquet", "/S4 Imports", "file", 920.0, "2026-05-10", "Synced", "carol.d@test.com", False, "Pro Flexi S4", "Telemetry training data export."), + ("S3 migration checklist.xlsx", "/S4 Imports", "file", 5.1, "2026-05-09", "Synced", "carol.d@test.com", True, "", "S3-compatible migration checklist."), + ("rclone-config-redacted.txt", "/Automation", "file", 0.3, "2026-04-14", "Synced", "", False, "", "Rclone configuration with credentials removed."), + ("quarterly model weights.bin", "/ML Models", "file", 3280.0, "2026-04-28", "Synced", "", False, "Linux workstation", "Model weights for quarterly forecast."), + ("daily ingestion script.sh", "/Automation", "file", 0.1, "2026-05-11", "Synced", "", True, "", "MEGA CMD script for scheduled uploads."), + ("backup manifest.json", "/Backups/NAS", "file", 2.2, "2026-05-11", "Synced", "", False, "Synology DS923+", "Backup manifest from NAS."), + ] + common_distractors) + add_files(carol, [ + ("2026 vendor contracts.zip", "/Legal", "file", 420.0, "2026-05-04", "Synced", "alice.j@test.com", True, "", "Signed vendor contract archive."), + ("External collaborator review.xlsx", "/Team Admin", "file", 7.7, "2026-05-02", "Synced", "alice.j@test.com, bob.c@test.com", False, "", "External collaborator access review."), + ("Compliance export May.csv", "/Team Admin", "file", 3.9, "2026-05-12", "Synced", "", True, "", "Team audit export."), + ("Board meeting recording.mp4", "/Meetings", "file", 2160.0, "2026-04-26", "Synced", "", False, "Conference Room Mac", "Private meeting recording."), + ("Client data retention policy.pdf", "/Legal", "file", 6.0, "2026-03-21", "Synced", "", False, "", "Retention policy for client files."), + ("Team recovery key escrow.pdf", "/Security", "file", 1.1, "2026-01-08", "Synced", "", True, "", "Escrow record for admin recovery keys."), + ] + common_distractors) + add_files(david, [ + ("wedding edit final.mov", "/Client Delivery/Wedding", "file", 5120.0, "2026-05-09", "Synced", "alice.j@test.com", True, "Mac Studio", "Final wedding video export."), + ("wedding edit trailer.mov", "/Client Delivery/Wedding", "file", 1180.0, "2026-05-08", "Synced", "", False, "Mac Studio", "Short trailer export."), + ("print portfolio.zip", "/Portfolio", "file", 680.0, "2026-03-02", "Synced", "alice.j@test.com", True, "", "Portfolio print files."), + ("client upload request list.xlsx", "/Client Delivery", "file", 1.8, "2026-05-01", "Synced", "", False, "", "Clients and upload request status."), + ("Lightroom backup catalog.lrcat", "/Backups/Mac Studio", "file", 2420.0, "2026-04-29", "Synced", "", False, "Mac Studio", "Lightroom catalog backup."), + ("MEGA Pass emergency kit.pdf", "/Security", "file", 0.6, "2026-02-12", "Synced", "", True, "", "Emergency vault recovery instructions."), + ] + common_distractors) + + def add_vault(user, specs): + for title, username, site_url, category, strength, changed, two_factor, notes in specs: + db.session.add(VaultItem( + user_id=user.id, title=title, slug=slugify(f"{user.username}-{title}"), + username=username, site_url=site_url, category=category, strength=strength, + last_changed=changed, two_factor=two_factor, notes=notes + )) + + add_vault(alice, [ + ("Studio bank portal", "alice.j", "https://bank.example.com", "Finance", "Strong", "2026-04-03", True, "Used for invoice payments."), + ("Old vendor FTP", "studio-old", "ftp://vendor.example.com", "Legacy", "Weak", "2024-11-12", False, "Replace before sending another media package."), + ("Adobe admin", "alice@riverlight.example", "https://account.adobe.com", "Creative", "Strong", "2026-03-30", True, "Team license admin."), + ("Client CMS", "alice_editor", "https://atlas.example.com", "Client", "Reused", "2025-07-09", False, "Shared during Atlas launch."), + ]) + add_vault(bob, [ + ("AWS read-only", "bob.analytics", "https://console.aws.amazon.com", "Cloud", "Strong", "2026-02-18", True, "Read-only cost review."), + ("Rclone test bucket", "bob-s4-test", "https://s4.mega.example", "Cloud", "Strong", "2026-05-03", True, "S4 test credentials."), + ("Legacy Jenkins", "bob", "https://jenkins.example.com", "DevOps", "Weak", "2024-09-30", False, "Needs rotation."), + ]) + add_vault(carol, [ + ("Payroll portal", "carol.ops", "https://payroll.example.com", "Finance", "Strong", "2026-01-18", True, "HR-only."), + ("Vendor contract room", "carol_d", "https://contracts.example.com", "Legal", "Reused", "2025-05-05", False, "Rotate after vendor review."), + ("Admin dashboard", "carol.admin", "https://mega.local/team", "Business", "Strong", "2026-04-20", True, "Team admin workflow."), + ]) + add_vault(david, [ + ("Gallery delivery", "david", "https://gallery.example.com", "Client", "Strong", "2026-05-04", True, "Wedding galleries."), + ("Old print lab", "dkim", "https://prints.example.com", "Vendor", "Weak", "2024-12-21", False, "No recent orders."), + ("Portfolio hosting", "david.photo", "https://portfolio.example.com", "Creative", "Strong", "2026-03-09", True, "Public portfolio."), + ]) + + for user, plan_slug, cycle, seats, number, date in [ + (alice, "pro-ii", "yearly", 1, "MEGA-20260422-AJ", "2026-04-22"), + (bob, "pro-flexi", "monthly", 1, "MEGA-20260415-BC", "2026-04-15"), + (carol, "business-pro", "yearly", 5, "MEGA-20260328-CD", "2026-03-28"), + (david, "pro-i", "monthly", 1, "MEGA-20260501-DK", "2026-05-01"), + ]: + plan = plan_by_slug[plan_slug] + subtotal = plan.yearly_price * seats if cycle == "yearly" else plan.monthly_price * seats + tax = round(subtotal * 0.07, 2) + db.session.add(SubscriptionOrder( + user_id=user.id, plan_id=plan.id, order_number=number, billing_cycle=cycle, seats=seats, + subtotal=round(subtotal, 2), tax=tax, total=round(subtotal + tax, 2), + status="active", created_at=date + )) + + db.session.add_all([ + SupportTicket(user_id=alice.id, ticket_number="MEGA-T0429-AJ", subject="Question about sharing Atlas folder", category="Cloud drive", priority="Normal", status="Waiting on customer", message="Can an external collaborator upload without a MEGA account?", created_at="2026-04-29"), + SupportTicket(user_id=bob.id, ticket_number="MEGA-T0508-BC", subject="S4 lifecycle policy example", category="Object storage", priority="Normal", status="Open", message="Need guidance for archive tiering with Rclone.", created_at="2026-05-08"), + SupportTicket(user_id=carol.id, ticket_number="MEGA-T0502-CD", subject="External collaborator access audit", category="Business", priority="High", status="Open", message="Please confirm best practice for removing a contractor.", created_at="2026-05-02"), + SupportTicket(user_id=david.id, ticket_number="MEGA-T0424-DK", subject="Client upload request expiry", category="Share", priority="Normal", status="Resolved", message="How long should a wedding upload request stay active?", created_at="2026-04-24"), + ]) + + db.session.commit() diff --git a/sites/mega/static/css/.gitkeep b/sites/mega/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/mega/static/css/buttons.css.css b/sites/mega/static/css/buttons.css.css new file mode 100644 index 00000000..a134687a --- /dev/null +++ b/sites/mega/static/css/buttons.css.css @@ -0,0 +1,582 @@ +/** + * Styling for buttons common to all MEGA Wordpress sites. + * + * Button sizes are the same, regardless of grid size. + * + * There are three button bases: + * - 'button' for regular buttons that can include before/after icons and text, + * - 'icon-button' for square buttons with just an icon and + * - 'text-button', similar to 'button' but without the button shape. + * + * Design file: https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=1%3A70 + */ + +/* region Common styling to all buttons */ + +.button, .text-button, .icon-button { + all: unset; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + border-radius: 8px; + cursor: pointer; + scroll-margin-top: var(--top-height); +} + +/* endregion */ + +/* region Common styling for 'button' and 'text-button' */ + +/* Base */ +.button, .text-button { + width: fit-content; + flex-direction: row; + gap: var(--btn-gap); + min-height: var(--btn-height); + font: var(--btn-font); + letter-spacing: var(--btn-letter-spacing); + text-align: center; +} + +/* Size */ +.button.xl, .text-button.xl { + --btn-height: 56px; + --btn-icon-size: 24px; + --btn-gap: 12px; + --btn-padding: 0 24px; /* Static regardless of grid size */ + --btn-font: var(--font-btn-xl); + --btn-letter-spacing: var(--font-btn-xl-spacing); +} +.button.lg, .text-button.lg { + --btn-height: 48px; + --btn-icon-size: 24px; + --btn-gap: 12px; + --btn-padding: 0 24px; + --btn-font: var(--font-btn-lg); + --btn-letter-spacing: var(--font-btn-lg-spacing); +} +.button.md, .text-button.md { + --btn-height: 40px; + --btn-icon-size: 20px; + --btn-gap: 8px; + --btn-padding: 0 24px; + --btn-font: var(--font-btn-md); + --btn-letter-spacing: var(--font-btn-md-spacing); +} +.button.sm, .text-button.sm { + --btn-height: 36px; + --btn-icon-size: 16px; + --btn-gap: 8px; + --btn-padding: 0 16px; + --btn-font: var(--font-btn-sm); + --btn-letter-spacing: var(--font-btn-sm-spacing); +} + +/* Icons */ +.button .before, .button .after, +.text-button .before, .text-button .after { + width: var(--btn-icon-size); + height: var(--btn-icon-size); + flex-shrink: 0; + text-align: center; + mask-position: center; + -webkit-mask-position: center; + background-color: currentColor; + transition: var(--transition-transform); +} +/* Special animation for all 'arrow-right-mro' icons in the 'after' position */ +.button .after.icon.arrow-right-mro, +.text-button .after.icon.arrow-right-mro { + transition: var(--transition-transform); +} +.button:hover .after.icon.arrow-right-mro, .button:active .after.icon.arrow-right-mro, +.text-button:hover .after.icon.arrow-right-mro, .text-button:active .after.icon.arrow-right-mro { + transform: translateX(4px); +} +/* endregion */ + +/* region MEGA Molecular Component: Button */ + +/* Base */ +.button { + padding: var(--btn-padding); +} +.button:disabled { + cursor: default; +} + +/* region Style - Light Theme */ + +/* Primary */ +.button.primary { + color: var(--color-grey-50); + background-color: var(--color-primary-red-500); +} +.button.primary:hover:not(:disabled), +.button.primary:focus:not(:disabled) { + background-color: var(--color-primary-red-600); +} +.button.primary:active:not(:disabled) { + background-color: var(--color-primary-red-700); +} +.button.primary:disabled { + color: var(--color-link-disabled); + background-color: var(--color-grey-100); +} + +/* Secondary */ +.button.secondary { + color: var(--color-primary-red-500); + background-color: var(--color-bg-tinted-pale-pink); +} +.button.secondary:hover:not(:disabled), +.button.secondary:focus:not(:disabled) { + background-color: var(--color-bg-tinted-pink); +} +.button.secondary:active:not(:disabled) { + background-color: var(--color-bg-tinted-peach); +} +.button.secondary:disabled { + color: var(--color-link-disabled); + background-color: var(--color-grey-100); +} + +.theme-dark .button.secondary { + color: var(--color-primary-red-800); + background-color: var(--color-bg-tinted-peach); +} +.theme-dark .button.secondary:active:not(:disabled) { + background-color: var(--color-bg-tinted-pale-pink); +} + + +/* Outline */ +.button.outline { + color: var(--color-primary-red-500); + border: 2px solid var(--color-primary-red-500); +} +.button.outline:hover:not(:disabled), +.button.outline:focus:not(:disabled) { + color: var(--color-primary-red-600); + border-color: var(--color-primary-red-600); +} +.button.outline:active:not(:disabled) { + color: var(--color-primary-red-700); + border-color: var(--color-primary-red-700); +} +.button.outline:disabled { + color: var(--color-link-disabled); + border-color: var(--color-grey-100); +} + +/* Text */ +.button.text { + color: var(--color-primary-red-700); + text-decoration: underline; +} +.button.text:hover:not(:disabled), +.button.text:focus:not(:disabled) { + background-color: var(--color-grey-50); +} +.button.text:active:not(:disabled) { + background-color: var(--color-grey-100); +} +.button.text:disabled { + color: var(--color-link-disabled); +} + +/* Primary, muted */ +.button.primary-muted { + color: var(--color-grey-50); + background-color: var(--color-primary-black-900); +} +.button.primary-muted:hover:not(:disabled), +.button.primary-muted:focus:not(:disabled) { + background-color: var(--color-primary-black-700); +} +.button.primary-muted:active:not(:disabled) { + background-color: var(--color-primary-black-600); +} +.button.primary-muted:disabled { + color: var(--color-link-disabled); + background-color: var(--color-grey-100); +} + +/* Secondary, muted */ +.button.secondary-muted { + color: var(--color-primary-black-900); + background-color: var(--color-grey-100); +} +.button.secondary-muted:hover:not(:disabled), +.button.secondary-muted:focus:not(:disabled) { + background-color: var(--color-grey-150); +} +.button.secondary-muted:active:not(:disabled) { + background-color: var(--color-grey-200); +} +.button.secondary-muted:disabled { + color: var(--color-link-disabled); + background-color: var(--color-grey-100); +} + +/* Outline, muted */ +.button.outline-muted { + color: var(--color-primary-black-900); + border: 2px solid currentColor; +} +.button.outline-muted:hover:not(:disabled), +.button.outline-muted:focus:not(:disabled) { + color: var(--color-primary-black-900); + background-color: var(--color-bg-surface-1); +} +.button.outline-muted:active:not(:disabled) { + color: var(--color-primary-black-800); + background-color: var(--color-bg-surface-2); +} +.button.outline-muted:disabled { + color: var(--color-link-disabled); + background-color: unset; +} + +/* Text, muted */ +.button.text-muted { + color: var(--color-primary-black-900); + text-decoration: underline; +} +.button.text-muted:hover:not(:disabled), +.button.text-muted:focus:not(:disabled) { + background-color: var(--color-grey-50); +} +.button.text-muted:active:not(:disabled) { + background-color: var(--color-grey-100); +} +.button-text-muted:disabled { + color: var(--color-link-disabled); + background-color: unset; +} + +/* endregion */ + +/* region Style - Dark Theme (applied after Light Theme) */ + +/* Primary */ +.theme-dark .button.primary { + color: var(--color-grey-1000); +} + +/* Secondary */ +.theme-dark .button.secondary { + color: var(--color-primary-red-800); + background-color: var(--color-bg-tinted-peach); +} +.theme-dark .button.secondary:hover:not(:disabled), +.theme-dark .button.secondary:focus:not(:disabled) { + color: var(--color-primary-red-800); + background-color: var(--color-bg-tinted-pink); +} +.theme-dark .button.secondary:active:not(:disabled) { + color: var(--color-primary-red-800); + background-color: var(--color-bg-tinted-pale-pink); +} + +/* Outline */ +.theme-dark .button.outline:hover:not(:disabled), +.theme-dark .button.outline:focus:not(:disabled) { + background-color: var(--color-bg-surface-1); +} +.theme-dark .button.outline:active:not(:disabled) { + background-color: var(--color-bg-surface-2); +} + +/* Text */ +.theme-dark .button.text { + color: var(--color-primary-red-700); +} +.theme-dark .button.text:hover:not(:disabled), +.theme-dark .button.text:focus:not(:disabled) { + color: var(--color-primary-red-800); + background-color: var(--color-grey-100); +} +.theme-dark .button.text:active:not(:disabled) { + color: var(--color-primary-red-800); + background-color: var(--color-grey-150); +} + +/* Primary, muted */ +.theme-dark .button.primary-muted:hover:not(:disabled), +.theme-dark .button.primary-muted:focus:not(:disabled) { + background-color: var(--color-primary-black-800); +} +.theme-dark .button.primary-muted:active:not(:disabled) { + background-color: var(--color-primary-black-700); +} + +/* Outline, muted */ +.theme-dark .button.outline-muted:hover:not(:disabled), +.theme-dark .button.outline-muted:focus:not(:disabled) { + border-color: var(--color-primary-black-900); +} +.theme-dark .button.outline-muted:active:not(:disabled) { + border-color: var(--color-primary-black-800); +} +.theme-dark .button.outline-muted:disabled { + color: var(--color-link-disabled); + background-color: unset; + border-color: var(--color-grey-100); +} + +/* Text, muted */ +.theme-dark .button.text-muted:hover:not(:disabled), +.theme-dark .button.text-muted:focus:not(:disabled) { + background-color: var(--color-grey-100); +} +.theme-dark .button.text-muted:active:not(:disabled) { + background-color: var(--color-grey-150); +} +.theme-dark .button-text-muted:disabled { + background-color: unset; +} + +/* endregion */ + +/* endregion */ + +/* region MEGA Molecular Component: Text Button */ + +/* Light Theme */ +.text-button { + color: var(--color-primary-black-900); + text-decoration: underline; +} +.text-button:visited:not(:disabled) { + color: var(--color-primary-black-900); +} +.text-button:hover:not(:disabled), +.text-button:focus:not(:disabled) { + color: var(--color-primary-black-700); + text-decoration: underline; +} +.text-button:active:not(:disabled) { + color: var(--color-primary-black-600); +} +.text-button:disabled { + color: var(--color-link-disabled); +} + +/* Dark Theme */ +.theme-dark .text-button:hover:not(:disabled), +.theme-dark .text-button:focus:not(:disabled) { + color: var(--color-primary-black-800); +} +.theme-dark .text-button:active:not(:disabled) { + color: var(--color-primary-black-700); +} + +/* endregion */ + +/* region MEGA Molecular Component: Icon Button */ + +/* Base */ +.icon-button { + width: var(--icon-btn-size); + height: var(--icon-btn-size); + padding: var(--icon-btn-padding); + flex-shrink: 0; /* Don't shrink if used in a flexbox context */ + color: var(--color-primary-black-900); +} +.icon-button:disabled { + color: var(--color-link-disabled); + cursor: default; +} + +/* Size */ +.icon-button.xl { + --icon-btn-size: 56px; + --icon-btn-padding: 16px; +} +.icon-button.lg { + --icon-btn-size: 48px; + --icon-btn-padding: 12px; +} +.icon-button.md { + --icon-btn-size: 40px; + --icon-btn-padding: 10px; +} +.icon-button.sm { + --icon-btn-size: 36px; + --icon-btn-padding: 10px; +} + +/* Style - Light Theme */ + +/* Primary */ +.icon-button.primary { + background-color: var(--color-grey-100); +} +.icon-button.primary:hover:not(:disabled), +.icon-button.primary:focus:not(:disabled) { + background-color: var(--color-grey-150); +} +.icon-button.primary:active:not(:disabled) { + background-color: var(--color-grey-200); +} +.icon-button.primary:disabled { + background-color: var(--color-grey-100); +} + +/* Transparent */ +.icon-button.transparent { + background-color: unset; +} +.icon-button.transparent:hover:not(:disabled), +.icon-button.transparent:focus:not(:disabled) { + background-color: var(--color-grey-50); +} +.icon-button.transparent:active:not(:disabled) { + background-color: var(--color-grey-100); +} +.icon-button.transparent:disabled { + background-color: unset; +} + +/* Style - Dark Theme */ + +/* Transparent */ +.theme-dark .icon-button.transparent:hover:not(:disabled), +.theme-dark .icon-button.transparent:focus:not(:disabled) { + background-color: var(--color-grey-100); +} +.theme-dark .icon-button.transparent:active:not(:disabled) { + background-color: var(--color-grey-150); +} + +/* endregion */ + + +/* region Radio Button */ + +.radio-option-btn { + all: unset; + height: var(--radio-option-btn-height); + padding: var(--radio-option-btn-padding) 16px; + color: var(--color-link-enabled); + box-sizing: border-box; + border: 1px solid var(--color-grey-200); + border-radius: 5px; + cursor: pointer; +} + +.radio-option-btn.lg { + --radio-option-btn-height: 48px; + --radio-option-btn-padding: 12px; + font: var(--font-btn-lg); + letter-spacing: var(--font-btn-lg-spacing); +} +.radio-option-btn.md { + --radio-option-btn-height: 40px; + --radio-option-btn-padding: 10px; + font: var(--font-btn-md); + letter-spacing: var(--font-btn-md-spacing); +} +.radio-option-btn.sm { + --radio-option-btn-height: 36px; + --radio-option-btn-padding: 8px; + font: var(--font-btn-sm); + letter-spacing: var(--font-btn-sm-spacing); +} + +.radio-option-btn:hover:not(:disabled), +.radio-option-btn:focus:not(:disabled) { + color: var(--color-grey-50); + background-color: var(--color-primary-black-800); + border-color: var(--color-primary-black-800); +} +.radio-option-btn:active:not(:disabled), +.radio-option-btn.active:not(:disabled) { + color: var(--color-grey-50); + background-color: var(--color-primary-black-900); + border-color: var(--color-primary-black-900); +} +.radio-option-btn:disabled { + color: var(--color-link-disabled); + background-color: var(--color-grey-50); + border-color: var(--color-grey-50); + cursor: default; +} +.radio-option-btn.error { + border-color: var(--color-system-error); +} + +/* endregion */ + +/* region Pagination */ + +.pagination-container { + --nav-btn-size: 36px; + display: flex; + justify-content: center; + width: 100%; +} + +/* Button */ +/* span for active/disabled buttons, a for link buttons */ +.pagination-container a, +.pagination-container span { + width: var(--nav-btn-size); + height: var(--nav-btn-size); + margin-left: var(--spacing-3); + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + font: var(--font-btn-sm); + letter-spacing: var(--font-btn-sm-spacing); + text-decoration: none; +} + +.pagination-container a, +.pagination-container span { + color: var(--color-primary-black-800); + background-color: var(--color-grey-100); +} +.pagination-container a:hover, +.pagination-container a:focus, +.pagination-container span:hover, +.pagination-container span:focus { + color: var(--color-grey-50); + background-color: var(--color-primary-black-800); +} +.pagination-container a:active, +.pagination-container span.current { + color: var(--color-grey-50); + background-color: var(--color-primary-black-900); +} +.pagination-container span:not(.current) { + color: var(--color-link-disabled); + background-color: var(--color-grey-100); +} + +.pagination-container .icon { + width: 100%; + height: 100%; + mask-position: center; + -webkit-mask-position: center; + mask-size: auto; + -webkit-mask-size: auto; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + background-color: currentColor; +} +.pagination-container span.dots { + background-color: unset; + background-image: var(--icon-mono-more-horizontal-mro); + background-repeat: no-repeat; + background-position: center; + background-size: auto; +} +.theme-dark .pagination-container span.dots { + filter: invert(100%) sepia(100%) saturate(0%) hue-rotate(288deg) brightness(102%); +} + +/* endregion */ diff --git a/sites/mega/static/css/mega.css b/sites/mega/static/css/mega.css new file mode 100644 index 00000000..e60b9bb9 --- /dev/null +++ b/sites/mega/static/css/mega.css @@ -0,0 +1,121 @@ +:root { + color-scheme: light; + --red: #d90007; + --ink: #101114; + --muted: #686d76; + --line: #e6e8ec; + --soft: #f6f7f9; + --panel: #ffffff; + --dark: #17181c; + --green: #11845b; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: #fff; + line-height: 1.5; +} +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.topbar { + position: sticky; top: 0; z-index: 10; + display: grid; grid-template-columns: auto 1fr minmax(220px, 340px) auto; + align-items: center; gap: 18px; + min-height: 72px; padding: 0 28px; + background: rgba(255,255,255,.94); border-bottom: 1px solid var(--line); + backdrop-filter: blur(14px); +} +.brand, .mainnav, .account-links, .hero-actions, .section-heading, .filters, .radio-row, .check-row { display: flex; align-items: center; gap: 12px; } +.brand img { width: 34px; height: 34px; } +.brand span { font-weight: 800; letter-spacing: .02em; } +.mainnav a, .account-links a { color: #2e3238; font-weight: 650; font-size: 14px; } +.global-search { display: flex; gap: 6px; } +input, select, textarea { + width: 100%; border: 1px solid #d7dbe2; border-radius: 8px; padding: 11px 12px; + font: inherit; background: #fff; color: var(--ink); +} +textarea { min-height: 120px; resize: vertical; } +button, .button { + border: 0; border-radius: 8px; padding: 11px 17px; background: var(--red); + color: white; font-weight: 760; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; +} +button:hover, .button:hover { filter: brightness(.94); } +.button.ghost, .ghost { background: #fff; color: var(--ink); border: 1px solid var(--line); } +.button.small { padding: 8px 12px; } +.cart-pill { border: 1px solid var(--red); color: var(--red) !important; padding: 7px 10px; border-radius: 999px; } +main { min-height: 70vh; } +.flash-wrap { max-width: 1180px; margin: 18px auto 0; padding: 0 24px; } +.flash { padding: 12px 14px; border-radius: 8px; background: #eef7ff; border: 1px solid #cbe4ff; } +.flash.error { background: #fff1f1; border-color: #ffcccf; } +.flash.success { background: #edf9f3; border-color: #bdebd7; } +.hero { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 520px); + gap: 44px; align-items: center; padding: 64px max(28px, calc((100vw - 1180px)/2)) 42px; + background: linear-gradient(180deg, #fff 0%, #f7f8fb 100%); +} +.hero.product { padding-top: 48px; } +.hero h1, .page-head h1 { font-size: clamp(38px, 5vw, 68px); line-height: 1.02; margin: 0 0 18px; letter-spacing: 0; } +.hero p, .page-head p { font-size: 18px; color: var(--muted); max-width: 720px; } +.hero-media { + min-height: 360px; border-radius: 18px; overflow: hidden; background: #f0f1f3; + display: grid; place-items: center; border: 1px solid var(--line); +} +.hero-media img { width: 100%; height: 100%; object-fit: cover; } +.eyebrow { color: var(--red) !important; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; font-size: 13px !important; } +.section, .page-head, .section-tight { max-width: 1180px; margin: 0 auto; padding: 48px 24px; } +.section-tight { padding-top: 28px; padding-bottom: 28px; } +.band { max-width: none; padding-left: max(24px, calc((100vw - 1180px)/2)); padding-right: max(24px, calc((100vw - 1180px)/2)); background: var(--soft); } +.section-heading { justify-content: space-between; margin-bottom: 20px; } +.section-heading h2, .section h2 { margin: 0; font-size: 30px; } +.feature-grid, .plan-grid, .download-grid, .list-grid, .account-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; +} +.feature-card, .plan-card, .download-card, .list-item, .metric-card, .form-card, .purchase-panel, .faq-list article { + border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 18px; +} +.feature-card img, .download-card img { width: 100%; aspect-ratio: 16/10; object-fit: contain; background: #f3f4f6; border-radius: 8px; margin-bottom: 14px; } +.feature-card span, .download-card span, .list-item span, .metric-card span { color: var(--muted); font-size: 13px; font-weight: 700; } +.feature-card h3, .plan-card h3, .download-card h3 { margin: 8px 0; } +.plan-card { position: relative; display: flex; flex-direction: column; gap: 12px; } +.plan-card.popular { border-color: var(--red); box-shadow: 0 18px 40px rgba(217,0,7,.09); } +.badge { position: absolute; top: 14px; right: 14px; background: #fff2f2; color: var(--red); border-radius: 999px; padding: 4px 9px; font-size: 12px; font-weight: 800; } +.price { font-size: 34px; font-weight: 860; } +.price small { font-size: 14px; color: var(--muted); } +dl { margin: 0; } +dl div, .info-table div { display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--line); } +dt, .info-table span { color: var(--muted); } +dd { margin: 0; font-weight: 760; } +.text-link { color: var(--red); font-weight: 800; } +.split, .detail-layout, .columns { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); gap: 24px; align-items: start; } +.columns.two { grid-template-columns: 1fr 360px; max-width: 1180px; margin: 0 auto; padding: 24px; } +.auth-wrap { min-height: 62vh; display: grid; place-items: center; padding: 36px 20px; background: var(--soft); } +.form-card { max-width: 520px; width: 100%; display: grid; gap: 14px; } +.form-card.wide { max-width: 760px; margin: 0 auto 48px; } +.form-grid { max-width: 900px; margin: 0 auto 48px; padding: 0 24px; display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 14px; } +.form-grid button, .form-grid .check-row { grid-column: 1 / -1; } +.filters { max-width: 1180px; margin: 0 auto; padding: 0 24px 18px; align-items: stretch; } +.filters input { flex: 1; min-width: 220px; } +.file-list { display: grid; gap: 8px; } +.file-row { + display: grid; grid-template-columns: 80px minmax(180px, 1fr) minmax(120px, .7fr) 100px 100px; + gap: 12px; align-items: center; border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #fff; +} +.file-type { text-transform: uppercase; color: var(--red); font-weight: 800; font-size: 12px; } +.stack { display: grid; gap: 16px; } +.article { max-width: 820px; margin: 0 auto; padding: 48px 24px; } +.article h1 { font-size: 46px; line-height: 1.06; } +.detail-image { border: 1px solid var(--line); border-radius: 8px; background: var(--soft); padding: 18px; } +.check-list { padding-left: 0; list-style: none; display: grid; gap: 10px; } +.check-list li::before { content: "✓"; color: var(--green); font-weight: 900; margin-right: 8px; } +.footer { display: flex; justify-content: space-between; gap: 24px; padding: 36px max(24px, calc((100vw - 1180px)/2)); background: var(--dark); color: #fff; } +.footer p { color: #b8bdc6; max-width: 560px; } +.footer-links { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; } +@media (max-width: 960px) { + .topbar { position: static; grid-template-columns: 1fr; padding: 16px; } + .mainnav, .account-links, .filters { flex-wrap: wrap; } + .hero, .split, .detail-layout, .columns.two { grid-template-columns: 1fr; } + .file-row { grid-template-columns: 70px 1fr; } + .form-grid { grid-template-columns: 1fr; } +} diff --git a/sites/mega/static/css/theme.css.css b/sites/mega/static/css/theme.css.css new file mode 100644 index 00000000..891d7ffb --- /dev/null +++ b/sites/mega/static/css/theme.css.css @@ -0,0 +1,1399 @@ +/** + * MEGA's common styling for Wordpress sites. + * + * Contains atomic components: + * spacing: https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=412%3A9850&t=uSRFKSkVWXzRUaXK-0 + * typography: https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=121%3A738&t=D5tLygUnvOk9VyVT-0 + * color: https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=112%3A12 + */ + +/* region MEGA Atomic Component: Spacing */ + +/* Breakpoints XL and L */ +:root { + --spacing-1: 2px; + --spacing-2: 4px; + --spacing-3: 8px; + --spacing-4: 12px; + --spacing-5: 16px; + --spacing-6: 20px; + --spacing-7: 24px; + --spacing-8: 32px; + --spacing-9: 40px; + --spacing-10: 48px; + --spacing-11: 64px; + --spacing-12: 80px; + --spacing-13: 96px; + --spacing-14: 120px; + --spacing-15: 160px; +} + + +/* Breakpoints M */ +@media screen and (min-width: 768px) and (max-width: 1080px) { + :root { + --spacing-1: 2px; + --spacing-2: 4px; + --spacing-3: 8px; + --spacing-4: 12px; + --spacing-5: 16px; + --spacing-6: 20px; + --spacing-7: 24px; + --spacing-8: 28px; + --spacing-9: 36px; + --spacing-10: 44px; + --spacing-11: 56px; + --spacing-12: 72px; + --spacing-13: 80px; + --spacing-14: 96px; + --spacing-15: 144px; + } +} + +/* Breakpoints S and XS */ +@media screen and (min-width: 0px) and (max-width: 768px) { + :root { + --spacing-1: 2px; + --spacing-2: 4px; + --spacing-3: 8px; + --spacing-4: 12px; + --spacing-5: 16px; + --spacing-6: 20px; + --spacing-7: 24px; + --spacing-8: 28px; + --spacing-9: 32px; + --spacing-10: 40px; + --spacing-11: 48px; + --spacing-12: 64px; + --spacing-13: 72px; + --spacing-14: 80px; + --spacing-15: 120px; + } +} + +/* endregion */ + +/* region MEGA Atomic Component: Typography */ + +/** + * See https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=121%3A738&t=D5tLygUnvOk9VyVT-0 + * + * Poppins font: https://fonts.google.com/specimen/Poppins?query=poppins + * Inter font: https://fonts.google.com/specimen/Inter?query=inter + */ + +:root { + --font-family-poppins: Poppins, arial, sans-serif; + --font-family-inter: "Inter", arial, sans-serif; + --font-family-almarai: "Almarai", arial, sans-serif; + --font-family-lexend: "Lexend", arial, sans-serif; + --font-family-noto-sans-kr: "Noto Sans KR", arial, sans-serif; + --font-family-noto-sans-tc: "Noto Sans TC", arial, sans-serif; + --font-family-noto-sans-sc: "Noto Sans SC", arial, sans-serif; + --font-family-mulish: "Mulish", arial, sans-serif; + --font-family-prompt: "Prompt", arial, sans-serif; + --font-family-m-plus-1p: "M PLUS 1p", arial, sans-serif; +} + +:root, +:root .page-container:lang(en) { + --font-family-for-headings: var(--font-family-poppins); + --font-family-for-copy: var(--font-family-inter); +} + +:root :lang(ar) { + --font-family-for-headings: var(--font-family-almarai); + --font-family-for-copy: var(--font-family-almarai); +} +:root :lang(vi) { + --font-family-for-headings: var(--font-family-lexend); + --font-family-for-copy: var(--font-family-lexend); +} +:root :lang(kr) { + --font-family-for-headings: var(--font-family-noto-sans-kr); + --font-family-for-copy: var(--font-family-noto-sans-kr); +} +:root :lang(zh-hant) { + --font-family-for-headings: var(--font-family-noto-sans-tc); + --font-family-for-copy: var(--font-family-noto-sans-tc); +} +:root :lang(zh-hans) { + --font-family-for-headings: var(--font-family-noto-sans-sc); + --font-family-for-copy: var(--font-family-noto-sans-sc); +} +:root :lang(ru) { + --font-family-for-headings: var( --font-family-mulish); + --font-family-for-copy: var( --font-family-mulish); +} +:root :lang(th) { + --font-family-for-headings: var( --font-family-prompt); + --font-family-for-copy: var( --font-family-prompt); +} +:root :lang(ja) { + --font-family-for-headings: var( --font-family-m-plus-1p); + --font-family-for-copy: var( --font-family-m-plus-1p); +} +/* Breakpoints XL and L */ +:root, +:root .page-container:lang(en) { + /* Headings */ + --font-h1: normal bold 60px/72px var(--font-family-for-headings); + --font-h1-spacing: -0.3px; + + --font-h2: normal 600 48px/56px var(--font-family-for-headings); + --font-h2-spacing: -0.24px; + + --font-h3: normal 600 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + + --font-h4: normal 600 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0.08px; + + --font-h5: normal 600 24px/28px var(--font-family-for-headings); + --font-h5-spacing: 0; + + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h6-spacing: 0.0025em; + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-lg-spacing: 0.08em; + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-md-spacing: 0.08em; + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + --font-copy-allcaps-sm-spacing: 0.08em; + --font-copy-allcaps-sm-wide-spacing: 0.2em; + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-xl-spacing: 0.01em; + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-lg-spacing: 0; + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-md-spacing: 0; + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + --font-btn-sm-spacing: 0; + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-lg-spacing: 0; + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + --font-caption-md-spacing: 0.02em; + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); + + /* Data */ + --font-data-zero: normal 600 56px/64px var(--font-family-for-headings); + --font-data-one: normal 600 48px/56px var(--font-family-for-headings); + --font-data-two: normal 600 40px/48px var(--font-family-for-headings); +} + +:root :lang(ar) { + --font-h1: normal 800 60px/72px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 48px/56px var(--font-family-for-headings); + --font-h2-spacing: -0.48px; + --font-h3: normal 700 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0.08px; + --font-h5: normal 700 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(vi) { + --font-h1: normal 700 60px/72px var(--font-family-for-headings); + --font-h1-spacing: -0.9px; + --font-h2: normal 600 48px/56px var(--font-family-for-headings); + --font-h2-spacing: -0.72px; + --font-h3: normal 600 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0.08px; + --font-h5: normal 600 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 56px/64px var(--font-family-for-headings); + --font-data-one: normal 600 48px/56px var(--font-family-for-headings); + --font-data-two: normal 600 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(kr) { + --font-h1: normal 900 60px/72px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 48px/56px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(zh-hant) { + --font-h1: normal 900 60px/72px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 48px/56px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(zh-hans) { + --font-h1: normal 900 60px/72px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 48px/56px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(ru) { + --font-h1: normal 900 60px/72px var(--font-family-for-headings); + --font-h1-spacing: -0.6px; + --font-h2: normal 800 48px/56px var(--font-family-for-headings); + --font-h2-spacing: -0.48px; + --font-h3: normal 800 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 800 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0.08px; + --font-h5: normal 800 24px/28px var(--font-family-for-headings); + --font-h6: normal bold 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 800 56px/64px var(--font-family-for-headings); + --font-data-one: normal 800 48px/56px var(--font-family-for-headings); + --font-data-two: normal 800 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(th) { + --font-h1: normal 700 60px/72px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 600 48px/56px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 600 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 24px/28px var(--font-family-for-headings); + --font-h6: normal 600 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 56px/64px var(--font-family-for-headings); + --font-data-one: normal 600 48px/56px var(--font-family-for-headings); + --font-data-two: normal 600 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +:root :lang(ja) { + --font-h1: normal 800 60px/72px var(--font-family-for-headings); + --font-h1-spacing: -0.48px; + --font-h2: normal 700 48px/56px var(--font-family-for-headings); + --font-h2-spacing: -0.24px; + --font-h3: normal 700 40px/48px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 32px/40px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 24px/28px var(--font-family-for-headings); + --font-h6: normal 700 20px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Body Copy */ + --font-copy-lg: normal normal 18px/28px var(--font-family-for-copy); + --font-copy-lg-bold: normal bold 18px/28px var(--font-family-for-copy); + --font-copy-md: normal normal 16px/24px var(--font-family-for-copy); + --font-copy-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-copy-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-copy-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* For font-copy-allcaps: Use uppercase text (via source text only, for localisation support) */ + --font-copy-allcaps-lg: normal bold 20px/24px var(--font-family-for-copy); + --font-copy-allcaps-md: normal bold 18px/20px var(--font-family-for-copy); + --font-copy-allcaps-sm: normal bold 12px/16px var(--font-family-for-copy); + + /* Link fonts (common to all grid sizes and all with text-underline) */ + --font-link-lg: normal normal 18px/24px var(--font-family-for-copy); + --font-link-lg-bold: normal bold 18px/24px var(--font-family-for-copy); + --font-link-md: normal normal 16px/24px var(--font-family-for-copy); + --font-link-md-bold: normal bold 16px/24px var(--font-family-for-copy); + --font-link-sm: normal normal 14px/20px var(--font-family-for-copy); + --font-link-sm-semibold: normal 600 14px/20px var(--font-family-for-copy); + --font-link-sm-bold: normal bold 14px/20px var(--font-family-for-copy); + + /* buttons fonts */ + --font-btn-xl: normal 600 18px/24px var(--font-family-for-copy); + --font-btn-lg: normal 600 16px/24px var(--font-family-for-copy); + --font-btn-md: normal 600 16px/20px var(--font-family-for-copy); + --font-btn-sm: normal 600 14px/20px var(--font-family-for-copy); + + /* caption fonts */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic 400 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 32px/44px var(--font-family-for-copy); + + /* Input Fields */ + --font-input-xl: normal normal 18px/24px var(--font-family-for-copy); + --font-input-lg: normal normal 16px/24px var(--font-family-for-copy); + --font-input-md: normal normal 16px/20px var(--font-family-for-copy); + --font-input-sm: normal medium 14px/20px var(--font-family-for-copy); +} + +/* Breakpoint M */ +@media screen and (min-width: 768px) and (max-width: 1080px) { + :root, + :root .page-container:lang(en) { + /* Headings */ + --font-h1: normal bold 48px/56px var(--font-family-for-headings); + --font-h1-spacing: -0.24px; + --font-h2: normal 600 40px/48px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 600 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0.08px; + --font-h4: normal 600 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h5-spacing: 0.05px; + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h6-spacing: 0; + + /* Captions */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-lg-spacing: 0; + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + --font-caption-md-spacing: 0.02em; + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + + /* Data */ + --font-data-zero: normal 600 48px/56px var(--font-family-for-headings); + --font-data-one: normal 600 40px/48px var(--font-family-for-headings); + --font-data-two: normal 600 32px/40px var(--font-family-for-headings); + } + + :root :lang(ar) { + --font-h1: normal 900 48px/56px var(--font-family-mulish); + --font-h1-spacing: 0; + --font-h2: normal 700 40px/48px var(--font-family-for-headings); + --font-h2-spacing: -0.4px; + --font-h3: normal 700 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal 700 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(vi) { + --font-h1: normal 700 48px/56px var(--font-family-for-headings); + --font-h1-spacing: -0.72px; + --font-h2: normal 600 40px/48px var(--font-family-for-headings); + --font-h2-spacing: -0.6px; + --font-h3: normal 600 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 56px/64px var(--font-family-for-headings); + --font-data-one: normal 600 48px/56px var(--font-family-for-headings); + --font-data-two: normal 600 40px/48px var(--font-family-for-headings); + + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(kr) { + --font-h1: normal 900 48px/56px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 40px/48px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(zh-hant) { + --font-h1: normal 900 48px/56px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 40px/48px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(zh-hans) { + --font-h1: normal 900 48px/56px var(--font-family-noto-sans-sc); + --font-h1-spacing: 0; + --font-h2: normal 700 40px/48px var(--font-family-noto-sans-sc); + --font-h2-spacing: 0; + --font-h3: normal 700 32px/40px var(--font-family-noto-sans-sc); + --font-h3-spacing: 0; + --font-h4: normal 700 24px/28px var(--font-family-noto-sans-sc); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-noto-sans-sc); + --font-h6: normal 600 20px/24px var(--font-family-for-headings); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-noto-sans-sc); + --font-quote-bold: normal 400 24px/34px var(--font-family-noto-sans-sc); + } + + :root :lang(ru) { + --font-h1: normal 900 48px/56px var(--font-family-for-headings); + --font-h1-spacing: -0.48px; + --font-h2: normal 800 40px/48px var(--font-family-for-headings); + --font-h2-spacing: -0.4px; + --font-h3: normal 800 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 800 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 800 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 800 56px/64px var(--font-family-for-headings); + --font-data-one: normal 800 48px/56px var(--font-family-for-headings); + --font-data-two: normal 800 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(th) { + --font-h1: normal 700 48px/56px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 600 40px/48px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 600 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 56px/64px var(--font-family-for-headings); + --font-data-one: normal 600 48px/56px var(--font-family-for-headings); + --font-data-two: normal 600 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(ja) { + --font-h1: normal 800 48px/56px var(--font-family-for-headings); + --font-h1-spacing: -0.384px; + --font-h2: normal 700 40px/48px var(--font-family-for-headings); + --font-h2-spacing: -0.2px; + --font-h3: normal 700 32px/40px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 24px/28px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 56px/64px var(--font-family-for-headings); + --font-data-one: normal 700 48px/56px var(--font-family-for-headings); + --font-data-two: normal 700 40px/48px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } +} + +/* Breakpoint S */ +@media screen and (min-width: 0px) and (max-width: 768px) { + :root, + :root .page-container:lang(en) { + /* Headings */ + --font-h1: normal bold 32px/40px var(--font-family-for-headings); + --font-h1-spacing: 0.08px; + --font-h2: normal 600 28px/36px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 600 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0.12px; + --font-h4: normal 600 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h5-spacing: 0.05px; + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h6-spacing: 0; + + /* Captions */ + --font-caption-lg: normal 400 14px/20px var(--font-family-for-copy); + --font-caption-lg-bold: normal 600 14px/20px var(--font-family-for-copy); + --font-caption-lg-spacing: 0; + --font-caption-md: normal 500 12px/18px var(--font-family-for-copy); + --font-caption-md-bold: normal 600 12px/18px var(--font-family-for-copy); + --font-caption-md-spacing: 0.02em; + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + + /* Data */ + --font-data-zero: normal 600 40px/48px var(--font-family-for-headings); + --font-data-one: normal 600 32px/40px var(--font-family-for-headings); + --font-data-two: normal 600 28px/36px var(--font-family-for-headings); + } + + :root :lang(ar) { + --font-h1: normal 900 32px/40px var(--font-family-mulish); + --font-h1-spacing: 0; + --font-h2: normal 700 28px/36px var(--font-family-for-headings); + --font-h2-spacing: -0.07px; + --font-h3: normal 700 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 40px/48px var(--font-family-for-headings); + --font-data-one: normal 700 32px/40px var(--font-family-for-headings); + --font-data-two: normal 700 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(vi) { + --font-h1: normal 700 32px/40px var(--font-family-for-headings); + --font-h1-spacing: -0.32px; + --font-h2: normal 600 28px/36px var(--font-family-for-headings); + --font-h2-spacing: -0.28px; + --font-h3: normal 600 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 40px/48px var(--font-family-for-headings); + --font-data-one: normal 600 32px/40px var(--font-family-for-headings); + --font-data-two: normal 600 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(kr) { + --font-h1: normal 900 32px/40px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 28px/36px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 40px/48px var(--font-family-for-copy); + --font-data-one: normal 700 32px/40px var(--font-family-for-copy); + --font-data-two: normal 700 28px/36px var(--font-family-for-copy); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-headings); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-headings); + } + + :root :lang(zh-hant) { + --font-h1: normal 900 32px/40px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 28px/36px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 40px/48px var(--font-family-for-headings); + --font-data-one: normal 700 32px/40px var(--font-family-for-headings); + --font-data-two: normal 700 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(zh-hans) { + --font-h1: normal 900 32px/40px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 700 28px/36px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 700 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-headings); + --font-h5-spacing: 0; + --font-data-zero: normal 700 40px/48px var(--font-family-for-headings); + --font-data-one: normal 700 32px/40px var(--font-family-for-headings); + --font-data-two: normal 700 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(ru) { + --font-h1: normal 900 32px/40px var(--font-family-for-headings); + --font-h1-spacing: -0.08px; + --font-h2: normal 800 28px/36px var(--font-family-for-headings); + --font-h2-spacing: -0.07px; + --font-h3: normal 800 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 800 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 800 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 800 40px/48px var(--font-family-for-headings); + --font-data-one: normal 800 32px/40px var(--font-family-for-headings); + --font-data-two: normal 800 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(th) { + --font-h1: normal 700 32px/40px var(--font-family-for-headings); + --font-h1-spacing: 0; + --font-h2: normal 600 28px/36px var(--font-family-for-headings); + --font-h2-spacing: 0; + --font-h3: normal 600 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 600 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 600 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 600 40px/48px var(--font-family-for-headings); + --font-data-one: normal 600 32px/40px var(--font-family-for-headings); + --font-data-two: normal 600 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } + + :root :lang(ja) { + --font-h1: normal 800 32px/40px var(--font-family-for-headings); + --font-h1-spacing: -0.16px; + --font-h2: normal 700 28px/36px var(--font-family-for-headings); + --font-h2-spacing: -0.07px; + --font-h3: normal 700 24px/28px var(--font-family-for-headings); + --font-h3-spacing: 0; + --font-h4: normal 700 22px/26px var(--font-family-for-headings); + --font-h4-spacing: 0; + --font-h5: normal 700 20px/24px var(--font-family-for-headings); + --font-h6: normal bold 18px/24px var(--font-family-for-copy); + --font-h5-spacing: 0; + --font-data-zero: normal 700 40px/48px var(--font-family-for-headings); + --font-data-one: normal 700 32px/40px var(--font-family-for-headings); + --font-data-two: normal 700 28px/36px var(--font-family-for-headings); + + /* Quotation */ + --font-quote: italic normal 18px/28px var(--font-family-for-copy); + --font-quote-bold: normal 400 24px/34px var(--font-family-for-copy); + } +} +/* endregion */ + +/* region MEGA Atomic Component: Colour */ + +/** + * See https://www.figma.com/file/U90j8OCD0pvFFO3fO1YlkN/Website-design-system-v1?node-id=112%3A12 + */ + +:root { + --color-brand-red: #DD1405; + --color-brand-black: #04101E; + --color-brand-white: #FFFFFF; +} + +/* region Light Theme */ + +:root, .theme-light { + --color-bg-page: #FFFFFF; + --color-bg-surface-1: #F9F9FB; + --color-bg-surface-2: #F5F5F6; + --color-bg-surface-3: #EDEDED; + --color-bg-transparent: #0000000D; + + /* Per designer request: only these bg-tinted colours are added; others subject to change */ + --color-bg-tinted-peach: #F8D0CD; + --color-bg-tinted-pink: #FCE8E6; + --color-bg-tinted-pale-pink: #FDF3F3; + --color-bg-tinted-blue: #F8FCFE; + + --color-heading: #04101E; + --color-copy-primary: #333333; + --color-copy-secondary: #6E6E6E; + + --color-link-enabled: #04101E; + --color-link-hovered: #38404A; + --color-link-pressed: #515861; + --color-link-disabled: #D4D4D4; + --color-link-visited: #606060; + + --color-system-success: #00B256; + --color-system-alert: #E09706; + --color-system-error: #E40046; + --color-system-info: #2490B2; + + --color-grey-50: #F8F8F9; + --color-grey-100: #EDEDEE; + --color-grey-150: #E2E3E3; + --color-grey-200: #D7D8D9; + --color-grey-250: #CCCDCE; + --color-grey-300: #C1C2C4; + --color-grey-350: #B6B7B9; + --color-grey-400: #AAACAF; + --color-grey-450: #9FA1A4; + --color-grey-500: #94979A; + --color-grey-550: #898C8F; + --color-grey-600: #7E8185; + --color-grey-650: #73767A; + --color-grey-700: #686B70; + --color-grey-750: #5D6065; + --color-grey-800: #52555B; + --color-grey-850: #464B50; + --color-grey-900: #3B4046; + --color-grey-950: #30353B; + --color-grey-1000: #1A1F26; + + --color-primary-red-50: #E08F85; + --color-primary-red-100: #DC796E; + --color-primary-red-200: #D76457; + --color-primary-red-300: #D35040; + --color-primary-red-400: #CE3F2D; + --color-primary-red-500: #DD1405; + --color-primary-red-600: #B72C1C; + --color-primary-red-700: #A22618; + --color-primary-red-800: #8E2114; + --color-primary-red-900: #7A1C10; + + --color-primary-black-50: #EDEDED; + --color-primary-black-100: #B5B7BC; + --color-primary-black-200: #9C9FA4; + --color-primary-black-300: #8F9195; + --color-primary-black-400: #83888E; + --color-primary-black-500: #697077; + --color-primary-black-600: #515861; + --color-primary-black-700: #38404A; + --color-primary-black-800: #1F2834; + --color-primary-black-900: #04101E; + + /* TODO: add secondary colours */ + --color-secondary-cobalt-50: #F5F9FF; + --color-secondary-cobalt-100: #ADC1EA; + --color-secondary-cobalt-200: #82A2DD; + --color-secondary-cobalt-300: #5784D1; + --color-secondary-cobalt-400: #2B67C3; + --color-secondary-cobalt-500: #004BB5; + --color-secondary-cobalt-600: #003C9C; + --color-secondary-cobalt-700: #002E82; + --color-secondary-cobalt-800: #002267; + --color-secondary-cobalt-900: #00174C; + + --color-secondary-ruby-50: #FDD9D9; + --color-secondary-ruby-100: #F9ADAE; + --color-secondary-ruby-200: #F5828B; + --color-secondary-ruby-300: #F0576F; + --color-secondary-ruby-400: #EB2B58; + --color-secondary-ruby-500: #E40046; + --color-secondary-ruby-600: #B70538; + --color-secondary-ruby-700: #8D072B; + --color-secondary-ruby-800: #64081F; + --color-secondary-ruby-900: #3E0713; + --color-secondary-ruby-900-20: #3E071333; + --color-secondary-ruby-900-40: #3E071366; + + --color-opacities-05: rgba(0, 0, 0, 0.05); + --color-opacities-10: rgba(0, 0, 0, 0.1); + --color-opacities-30: rgba(0, 0, 0, 0.3); + --color-opacities-50: rgba(0, 0, 0, 0.5); + --color-opacities-70: rgba(0, 0, 0, 0.7); + --color-opacities-90: rgba(0, 0, 0, 0.9); +} + +/* endregion */ + +/* region Dark Theme */ + +.theme-dark { + --color-bg-page: #1A1F26; + --color-bg-surface-1: #1F242A; + --color-bg-surface-2: #23282F; + --color-bg-surface-3: #2F333C; + --color-bg-transparent: #00000033; + + --color-bg-tinted-peach: #3E2527; + --color-bg-tinted-pink: #402D30; + --color-bg-tinted-pale-pink: #423539; + + --color-heading: #FFFFFF; + --color-copy-primary: #E5E5E5; + --color-copy-secondary: #999999; + + --color-link-enabled: #EDEDED; + --color-link-hovered: #B5B7BC; + --color-link-pressed: #9C94A4; + --color-link-disabled: #686B70; + --color-link-visited: #A3A3A3; + + --color-system-success: #66E79D; + --color-system-alert: #F0CF6D; + --color-system-error: #F5828B; + --color-system-info: #8BC3D9; + + --color-grey-50: #1A1F26; + --color-grey-100: #30353B; + --color-grey-150: #3B4046; + --color-grey-200: #464B50; + --color-grey-250: #52555B; + --color-grey-300: #5D6065; + --color-grey-350: #686B70; + --color-grey-400: #73767A; + --color-grey-450: #7E8185; + --color-grey-500: #898C8F; + --color-grey-550: #94979A; + --color-grey-600: #9FA1A4; + --color-grey-650: #AAACAF; + --color-grey-700: #B6B7B9; + --color-grey-750: #C1C2C4; + --color-grey-800: #CCCDCE; + --color-grey-850: #D7D8D9; + --color-grey-900: #E2E3E3; + --color-grey-950: #EDEDEE; + --color-grey-1000: #F8F8F9; + + --color-primary-red-50: #7A1C10; + --color-primary-red-100: #8E2114; + --color-primary-red-200: #A22618; + --color-primary-red-300: #B72C1C; + --color-primary-red-400: #DD1405; + --color-primary-red-500: #CE3F2D; + --color-primary-red-600: #D35040; + --color-primary-red-700: #D76457; + --color-primary-red-800: #DC796E; + --color-primary-red-900: #E08F85; + + --color-primary-black-50: #04101E; + --color-primary-black-100: #1F2834; + --color-primary-black-200: #38404A; + --color-primary-black-300: #515861; + --color-primary-black-400: #697077; + --color-primary-black-500: #83888E; + --color-primary-black-600: #8F9195; + --color-primary-black-700: #9C9FA4; + --color-primary-black-800: #B5B7BC; + --color-primary-black-900: #EDEDED; + + /* TODO: add secondary colours */ + --color-secondary-cobalt-50: #00174C; + --color-secondary-cobalt-100: #002267; + --color-secondary-cobalt-200: #002E82; + --color-secondary-cobalt-300: #003C9C; + --color-secondary-cobalt-400: #004BB5; + --color-secondary-cobalt-500: #2B67C3; + --color-secondary-cobalt-600: #5784D1; + --color-secondary-cobalt-700: #82A2DD; + --color-secondary-cobalt-800: #ADC1EA; + --color-secondary-cobalt-900: #D9E1F5; + + --color-secondary-ruby-50: #3E0713; + --color-secondary-ruby-100: #64081F; + --color-secondary-ruby-200: #8D072B; + --color-secondary-ruby-300: #B70538; + --color-secondary-ruby-400: #E40046; + --color-secondary-ruby-500: #EB2B58; + --color-secondary-ruby-600: #F0576F; + --color-secondary-ruby-700: #F5828B; + --color-secondary-ruby-800: #F9ADAE; + --color-secondary-ruby-900: #FDD9D9; + --color-secondary-ruby-900-20: #FDD9D933; + --color-secondary-ruby-900-40: #FDD9D966; + + --color-opacities-05: rgba(255, 255, 255, 0.05); + --color-opacities-10: rgba(255, 255, 255, 0.1); + --color-opacities-30: rgba(255, 255, 255, 0.3); + --color-opacities-50: rgba(255, 255, 255, 0.5); + --color-opacities-70: rgba(255, 255, 255, 0.7); + --color-opacities-90: rgba(255, 255, 255, 0.9); +} + +/* endregion */ + +/* endregion */ + +/* region Transition */ + +:root { + --transition-all: all 200ms ease-in-out; + --transition-transform: transform 300ms ease-out; + --transition-opacity: opacity 200ms ease-in-out; + --transition-maxheight: max-height 200ms ease-in-out; +} + +/* endregion */ + +/* region Element Defaults */ + +body { + background-color: var(--color-bg-page); + font: var(--font-copy-md); + color: var(--color-copy-primary); +} + +p { + margin: 0; /* We never want default margin */ +} + +h1, h2, h3, h4, h5, h6 { + color: var(--color-heading); + margin: 0; /* We never want default margin */ +} + +h1 { + font: var(--font-h1); + letter-spacing: var(--font-h1-spacing); +} +h2 { + font: var(--font-h2); + letter-spacing: var(--font-h2-spacing); +} +h3 { + font: var(--font-h3); + letter-spacing: var(--font-h3-spacing); +} +h4 { + font: var(--font-h4); + letter-spacing: var(--font-h4-spacing); +} +h5 { + font: var(--font-h5); + letter-spacing: var(--font-h5-spacing); +} + +h6 { + font: var(--font-h6); + letter-spacing: var(--font-h6-spacing); +} +/* endregion */ diff --git a/sites/mega/static/icons/.gitkeep b/sites/mega/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/mega/static/icons/check-circle-mto.svg b/sites/mega/static/icons/check-circle-mto.svg new file mode 100644 index 00000000..f790eaa0 --- /dev/null +++ b/sites/mega/static/icons/check-circle-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/cloud-mto.svg b/sites/mega/static/icons/cloud-mto.svg new file mode 100644 index 00000000..a2c234e2 --- /dev/null +++ b/sites/mega/static/icons/cloud-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/cloud-upload-mto.svg b/sites/mega/static/icons/cloud-upload-mto.svg new file mode 100644 index 00000000..b85aeb0b --- /dev/null +++ b/sites/mega/static/icons/cloud-upload-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/code-mro.svg b/sites/mega/static/icons/code-mro.svg new file mode 100644 index 00000000..ae9b60a8 --- /dev/null +++ b/sites/mega/static/icons/code-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/database-mto.svg b/sites/mega/static/icons/database-mto.svg new file mode 100644 index 00000000..f063d1c9 --- /dev/null +++ b/sites/mega/static/icons/database-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/folder-mro.svg b/sites/mega/static/icons/folder-mro.svg new file mode 100644 index 00000000..a08009c1 --- /dev/null +++ b/sites/mega/static/icons/folder-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/key-01-mto.svg b/sites/mega/static/icons/key-01-mto.svg new file mode 100644 index 00000000..4fafb71c --- /dev/null +++ b/sites/mega/static/icons/key-01-mto.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sites/mega/static/icons/lock-mto.svg b/sites/mega/static/icons/lock-mto.svg new file mode 100644 index 00000000..256f57c3 --- /dev/null +++ b/sites/mega/static/icons/lock-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/mega.svg b/sites/mega/static/icons/mega.svg new file mode 100644 index 00000000..4fa5d34b --- /dev/null +++ b/sites/mega/static/icons/mega.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sites/mega/static/icons/monitor-mro.svg b/sites/mega/static/icons/monitor-mro.svg new file mode 100644 index 00000000..d179a7f3 --- /dev/null +++ b/sites/mega/static/icons/monitor-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/password-mto.svg b/sites/mega/static/icons/password-mto.svg new file mode 100644 index 00000000..2a1a69c3 --- /dev/null +++ b/sites/mega/static/icons/password-mto.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/search-sro.svg b/sites/mega/static/icons/search-sro.svg new file mode 100644 index 00000000..ceafbe00 --- /dev/null +++ b/sites/mega/static/icons/search-sro.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sites/mega/static/icons/share-mro.svg b/sites/mega/static/icons/share-mro.svg new file mode 100644 index 00000000..69e9e824 --- /dev/null +++ b/sites/mega/static/icons/share-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/shield-mro.svg b/sites/mega/static/icons/shield-mro.svg new file mode 100644 index 00000000..34df61ea --- /dev/null +++ b/sites/mega/static/icons/shield-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/icons/sync-mto.svg b/sites/mega/static/icons/sync-mto.svg new file mode 100644 index 00000000..4c7b6bad --- /dev/null +++ b/sites/mega/static/icons/sync-mto.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sites/mega/static/icons/transfer-it-mto.svg b/sites/mega/static/icons/transfer-it-mto.svg new file mode 100644 index 00000000..1c25180d --- /dev/null +++ b/sites/mega/static/icons/transfer-it-mto.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/sites/mega/static/icons/users-mro.svg b/sites/mega/static/icons/users-mro.svg new file mode 100644 index 00000000..da350e27 --- /dev/null +++ b/sites/mega/static/icons/users-mro.svg @@ -0,0 +1,3 @@ + + + diff --git a/sites/mega/static/js/.gitkeep b/sites/mega/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/mega/static/js/mega.js b/sites/mega/static/js/mega.js new file mode 100644 index 00000000..72a0dbed --- /dev/null +++ b/sites/mega/static/js/mega.js @@ -0,0 +1,6 @@ +document.addEventListener("click", (event) => { + const card = event.target.closest(".plan-card"); + if (!card) return; + document.querySelectorAll(".plan-card").forEach((el) => el.classList.remove("focused")); + card.classList.add("focused"); +}); diff --git a/sites/mega/tasks.jsonl b/sites/mega/tasks.jsonl new file mode 100644 index 00000000..a5040579 --- /dev/null +++ b/sites/mega/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"MEGA","id":"MEGA--0","ques":"Browse the MEGA storage product page and find two ways the page says users can access or manage their cloud files.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--1","ques":"Search the help center for recovery key guidance and open the article that explains how to save a recovery key.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--2","ques":"Log in as david.k@test.com. Find an individual storage plan with at least 8 TB of storage that includes MEGA VPN, add it to the cart with yearly billing, and go to checkout.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--3","ques":"Log in as alice.j@test.com, choose the Pro I plan with monthly billing, and complete checkout using Alice's default saved payment method.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--4","ques":"Log in as alice.j@test.com. Compare Pro II and Business Pro, decide which has more included users, add that plan with its minimum included seats to the cart, and stop on the checkout review page.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--5","ques":"Log in as alice.j@test.com. In Cloud drive, find the Atlas launch video in the Media Archive, create a share link, and make sure it is shared with Bob and Carol.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--6","ques":"Log in as alice.j@test.com. Search Cloud drive for media files and determine which MOV file is largest, then open it and mark it as a favorite.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--7","ques":"Log in as alice.j@test.com. Create a folder named Q3 Press Kit inside /Projects/Atlas, then add an uploaded file record named press-summary.pdf with size 4.2 MB in that folder.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--8","ques":"Log in as alice.j@test.com. Add a new MEGA Pass vault entry for the Atlas staging site with username alice_editor, category Client, strong password status, and two-factor authentication enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--9","ques":"Log in as alice.j@test.com. Search the MEGA Pass vault for weak or reused entries and identify which old vendor entry does not have two-factor authentication enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--10","ques":"Open Downloads and find the Windows command-line package rather than the regular sync app. Open its detail page and report its checksum.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--11","ques":"Open Downloads and find the recommended Chrome browser extension for MEGA Pass.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--12","ques":"Log in as bob.c@test.com. Use the Pricing filters to show object storage plans, find the plan that mentions an S3-compatible API, and add it to the cart.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--13","ques":"Browse the Business product page and find the capability related to keeping a team organised through administration or user management.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--14","ques":"Log in as bob.c@test.com and submit a High priority Object storage support ticket asking for help estimating S4 egress for quarterly archives.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--15","ques":"Search Help for ransomware recovery, open the matching article, and identify the first action it recommends before restoring files.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--16","ques":"Log in as alice.j@test.com, update the account profile so the company is Riverlight Studio Labs, and make sure both two-factor authentication and recovery key saved are enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name":"MEGA","id":"MEGA--17","ques":"Log in as bob.c@test.com. Find the flexible plan for variable cloud workloads that starts with 3 TB base storage and transfer, compare it with the fixed S4 storage plan, and add the flexible plan to the cart with yearly billing.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} diff --git a/sites/mega/templates/.gitkeep b/sites/mega/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/mega/templates/404.html b/sites/mega/templates/404.html new file mode 100644 index 00000000..9c9d46e0 --- /dev/null +++ b/sites/mega/templates/404.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}Not found{% endblock %} +{% block content %}

Page not found

Use search or the main navigation to continue.

{% endblock %} diff --git a/sites/mega/templates/_plan_card.html b/sites/mega/templates/_plan_card.html new file mode 100644 index 00000000..e434d1b4 --- /dev/null +++ b/sites/mega/templates/_plan_card.html @@ -0,0 +1,19 @@ +
+ {% if plan.popular %}Popular{% endif %} +

{{ plan.name }}

+

{{ plan.tagline }}

+
+ {% if plan.monthly_price == 0 %}Free{% else %}${{ "%.2f"|format(plan.monthly_price) }}/mo{% endif %} +
+
+
Storage
{{ plan.storage_tb }} TB
+
Transfer
{{ plan.transfer_tb }} TB
+
Users
{{ plan.users_included }}
+
+
+ + + +
+ Details +
diff --git a/sites/mega/templates/account.html b/sites/mega/templates/account.html new file mode 100644 index 00000000..cacf3b67 --- /dev/null +++ b/sites/mega/templates/account.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +
+

{{ current_user.company }}

+

{{ current_user.display_name }}

+

{{ current_user.email }} · {{ current_user.plan.name if current_user.plan else 'No active plan' }}

+
+ +
+
+

Recent files

Open drive
+ {% for item in recent_files %}{{ item.folder }}{{ item.name }}{% endfor %} +
+ +
+

Support

New ticket
+ {% for ticket in tickets %}{{ ticket.status }}{{ ticket.subject }}{% endfor %} +
+
+
+ Edit account + Payment methods +
+{% endblock %} diff --git a/sites/mega/templates/account_edit.html b/sites/mega/templates/account_edit.html new file mode 100644 index 00000000..ece4f413 --- /dev/null +++ b/sites/mega/templates/account_edit.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Edit Account{% endblock %} +{% block content %} +

Edit account

+
+ {% for field, label in [('display_name','Name'),('phone','Phone'),('company','Company'),('role','Role'),('address_line1','Address line 1'),('address_line2','Address line 2'),('city','City'),('state','State'),('postal_code','Postal code'),('country','Country'),('language','Language'),('timezone','Timezone')] %} + + {% endfor %} + + + +
+{% endblock %} diff --git a/sites/mega/templates/base.html b/sites/mega/templates/base.html new file mode 100644 index 00000000..195b85d5 --- /dev/null +++ b/sites/mega/templates/base.html @@ -0,0 +1,68 @@ + + + + + + {% block title %}MEGA{% endblock %} + + + + +
+ + MEGA + MEGA + + + + +
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+
+ MEGA +

Encrypted storage, sharing, VPN, password management, and object storage mirrored locally for WebHarbor.

+
+ +
+ + + diff --git a/sites/mega/templates/checkout.html b/sites/mega/templates/checkout.html new file mode 100644 index 00000000..8fc5f8fe --- /dev/null +++ b/sites/mega/templates/checkout.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Checkout{% endblock %} +{% block content %} +

Checkout

Review your MEGA subscription and select a saved payment method.

+
+
+

{{ plan.name }}

+ + +
+ Payment method + {% for method in methods %} + + {% endfor %} +
+ + Add payment method +
+ +
+{% endblock %} diff --git a/sites/mega/templates/cloud_drive.html b/sites/mega/templates/cloud_drive.html new file mode 100644 index 00000000..b7f50655 --- /dev/null +++ b/sites/mega/templates/cloud_drive.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Cloud Drive{% endblock %} +{% block content %} +
+

Cloud drive

+

{{ items|length }} items{% if q %} matching "{{ q }}"{% endif %}

+
+
+ + + +
+
+ +
+
+

New folder

+ + + +
+
+

Upload record

+ + + + + +
+
+
+{% endblock %} diff --git a/sites/mega/templates/contact.html b/sites/mega/templates/contact.html new file mode 100644 index 00000000..bece2530 --- /dev/null +++ b/sites/mega/templates/contact.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Contact MEGA{% endblock %} +{% block content %} +

We’re here to help

Make an enquiry or report an issue.

+
+ + + + + +
+{% endblock %} diff --git a/sites/mega/templates/download_detail.html b/sites/mega/templates/download_detail.html new file mode 100644 index 00000000..612cbe4e --- /dev/null +++ b/sites/mega/templates/download_detail.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ download.package_name }}{% endblock %} +{% block content %} +
+
+

{{ download.product }} / {{ download.platform }}

+

{{ download.package_name }}

+

{{ download.notes }}

+
+
Version{{ download.version }}
+
Size{{ download.size_mb }} MB
+
Architecture{{ download.architecture }}
+
Checksum{{ download.checksum }}
+
+
+ {{ download.package_name }} +
+{% endblock %} diff --git a/sites/mega/templates/downloads.html b/sites/mega/templates/downloads.html new file mode 100644 index 00000000..9dfda9c2 --- /dev/null +++ b/sites/mega/templates/downloads.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}MEGA Downloads{% endblock %} +{% block content %} +

Download MEGA apps

Desktop, mobile, VPN, Pass, CMD, and NAS packages.

+
+ + + +
+
+ {% for d in downloads %} + + {{ d.platform }} + {{ d.product }} · {{ d.platform }} +

{{ d.package_name }}

+

{{ d.version }} · {{ d.size_mb }} MB · {{ d.architecture }}

+ {% if d.recommended %}Recommended{% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/mega/templates/file_detail.html b/sites/mega/templates/file_detail.html new file mode 100644 index 00000000..bd225c0c --- /dev/null +++ b/sites/mega/templates/file_detail.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ item.name }}{% endblock %} +{% block content %} +
+
+

{{ item.folder }}

+

{{ item.name }}

+

{{ item.content_summary }}

+
+
Size{{ item.size_mb|size }}
+
Modified{{ item.modified_at }}
+
Sync{{ item.sync_status }}
+
Backup source{{ item.backup_source or 'None' }}
+
Shared with{{ item.shared_with or 'Not shared' }}
+
Share link{{ item.share_link or 'No link' }}
+
+
+
+

Sharing settings

+ + + + +
+
+{% endblock %} diff --git a/sites/mega/templates/help.html b/sites/mega/templates/help.html new file mode 100644 index 00000000..1e89475a --- /dev/null +++ b/sites/mega/templates/help.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}MEGA Help{% endblock %} +{% block content %} +

Help center

Guides and support for MEGA products.

+
+ + + +
+
+ {% for article in articles %} + + {{ article.category }} · {{ article.updated_at }} + {{ article.title }} +

{{ article.applies_to }} · {{ article.difficulty }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/mega/templates/help_article.html b/sites/mega/templates/help_article.html new file mode 100644 index 00000000..e9a432cc --- /dev/null +++ b/sites/mega/templates/help_article.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }}{% endblock %} +{% block content %} +
+

{{ article.category }} · {{ article.difficulty }}

+

{{ article.title }}

+

{{ article.body }}

+
+
Applies to{{ article.applies_to }}
+
Updated{{ article.updated_at }}
+
+
+
+

Related articles

+
+ {% for item in related %} + {{ item.category }}{{ item.title }} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/mega/templates/index.html b/sites/mega/templates/index.html new file mode 100644 index 00000000..847548e7 --- /dev/null +++ b/sites/mega/templates/index.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}MEGA: Protect your online privacy{% endblock %} +{% block content %} +
+
+

The Privacy Company

+

Online privacy for everyone, with encrypted storage by default

+

Store and share files, collaborate, browse privately, and protect passwords from one MEGA account.

+ +
+
+ Encrypted cloud storage +
+
+ +
+
+

Do more with MEGA

+ Get apps +
+
+ {% for page in products %} + + {{ page.title }} + {{ page.section }} +

{{ page.title }}

+

{{ page.summary }}

+
+ {% endfor %} +
+
+ +
+
+

Popular plans

+ All pricing +
+
+ {% for plan in featured_plans %} + {% include "_plan_card.html" %} + {% endfor %} +
+
+ +
+
+

Security that is always on

+

MEGA surfaces recovery keys, two-factor authentication, sharing controls, and version recovery as everyday account tools.

+ Security and privacy +
+ Security and privacy +
+ +
+
+

Help topics

+ Open help center +
+
+ {% for article in help_articles %} + + {{ article.category }} + {{ article.title }} + + {% endfor %} +
+
+{% endblock %} diff --git a/sites/mega/templates/login.html b/sites/mega/templates/login.html new file mode 100644 index 00000000..22cf766a --- /dev/null +++ b/sites/mega/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Log in{% endblock %} +{% block content %} +
+
+

Log in to MEGA

+ + + +

Benchmark users use password TestPass123!.

+
+
+{% endblock %} diff --git a/sites/mega/templates/order_detail.html b/sites/mega/templates/order_detail.html new file mode 100644 index 00000000..20f123f5 --- /dev/null +++ b/sites/mega/templates/order_detail.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Order {{ order.order_number }}{% endblock %} +{% block content %} +

{{ order.order_number }}

{{ order.status|title }} subscription order from {{ order.created_at }}.

+
+
Plan{{ order.plan.name }}
+
Billing{{ order.billing_cycle }}
+
Seats{{ order.seats }}
+
Total${{ "%.2f"|format(order.total) }}
+
+{% endblock %} diff --git a/sites/mega/templates/payment_methods.html b/sites/mega/templates/payment_methods.html new file mode 100644 index 00000000..8198c797 --- /dev/null +++ b/sites/mega/templates/payment_methods.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Payment Methods{% endblock %} +{% block content %} +

Payment methods

+
+
+ {% for method in methods %} +
{{ method.card_type }} · {{ method.billing_country }}{{ method.label }} ending {{ method.last4 }}{% if method.is_default %} · Default{% endif %}
+ {% endfor %} +
+
+

Add card

+ + + + + + + + +
+
+{% endblock %} diff --git a/sites/mega/templates/plan_detail.html b/sites/mega/templates/plan_detail.html new file mode 100644 index 00000000..da4ea754 --- /dev/null +++ b/sites/mega/templates/plan_detail.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}{{ plan.name }}{% endblock %} +{% block content %} +
+
+

{{ plan.audience|title }} / {{ plan.category|title }}

+

{{ plan.name }}

+

{{ plan.description }}

+
    + {% for feature in plan.get_features() %}
  • {{ feature }}
  • {% endfor %} +
+
+ +
+
+

Similar options

+
+ {% for plan in alternatives %} + {% include "_plan_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/mega/templates/pricing.html b/sites/mega/templates/pricing.html new file mode 100644 index 00000000..86bf2f9d --- /dev/null +++ b/sites/mega/templates/pricing.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Compare MEGA plans{% endblock %} +{% block content %} +
+

Pricing

+

Compare plans for storage, VPN, Pass, business, and S4

+

Filter by audience or product family, then choose a plan to checkout.

+
+
+ + + + +
+
+ {% for plan in plans %} + {% include "_plan_card.html" %} + {% endfor %} +
+{% endblock %} diff --git a/sites/mega/templates/product_page.html b/sites/mega/templates/product_page.html new file mode 100644 index 00000000..147349ac --- /dev/null +++ b/sites/mega/templates/product_page.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }}{% endblock %} +{% block content %} +
+
+

{{ page.section }}

+

{{ page.title }}

+

{{ page.summary }}

+ +
+
{{ page.title }}
+
+
+
+

What you can do

+

{{ page.body }}

+
    {% for item in page.get_highlights() %}
  • {{ item }}
  • {% endfor %}
+
+
+ {% for row in page.get_faq() %} +

{{ row.q }}

{{ row.a }}

+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/mega/templates/register.html b/sites/mega/templates/register.html new file mode 100644 index 00000000..b5a211da --- /dev/null +++ b/sites/mega/templates/register.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Register{% endblock %} +{% block content %} +
+
+

Create account

+ + + + + +
+
+{% endblock %} diff --git a/sites/mega/templates/search.html b/sites/mega/templates/search.html new file mode 100644 index 00000000..22722937 --- /dev/null +++ b/sites/mega/templates/search.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Search{% endblock %} +{% block content %} +

Search MEGA

Results for "{{ q }}".

+{% for heading, rows, endpoint in [ + ('Plans', plans, 'plan_detail'), + ('Products', pages, 'product_page'), + ('Help', articles, 'help_article') +] %} +
+

{{ heading }}

+
+ {% for row in rows %} + {% if heading == 'Plans' %} + {{ row.category }}{{ row.name }}

{{ row.tagline }}

+ {% elif heading == 'Products' %} + {{ row.section }}{{ row.title }}

{{ row.summary }}

+ {% else %} + {{ row.category }}{{ row.title }} + {% endif %} + {% endfor %} +
+
+{% endfor %} +
+

Downloads

+ +
+{% if current_user.is_authenticated %} +
+

Your files

+
{% for f in files %}{{ f.folder }}{{ f.name }}{% endfor %}
+
+
+

Your vault

+
{% for v in vault_items %}
{{ v.category }} · {{ v.strength }}{{ v.title }}
{% endfor %}
+
+{% endif %} +{% endblock %} diff --git a/sites/mega/templates/ticket_detail.html b/sites/mega/templates/ticket_detail.html new file mode 100644 index 00000000..4fa7e02f --- /dev/null +++ b/sites/mega/templates/ticket_detail.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}{{ ticket.ticket_number }}{% endblock %} +{% block content %} +
+

{{ ticket.category }} · {{ ticket.priority }}

+

{{ ticket.subject }}

+

{{ ticket.message }}

+
+
Ticket{{ ticket.ticket_number }}
+
Status{{ ticket.status }}
+
Created{{ ticket.created_at }}
+
+
+{% endblock %} diff --git a/sites/mega/templates/vault.html b/sites/mega/templates/vault.html new file mode 100644 index 00000000..e10854dc --- /dev/null +++ b/sites/mega/templates/vault.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}MEGA Pass Vault{% endblock %} +{% block content %} +

MEGA Pass

Encrypted password vault entries for {{ current_user.display_name }}.

+
+ + +
+
+
+ {% for item in items %} +
+ {{ item.category }} + {{ item.title }} + {{ item.username }} + {{ item.strength }} + {{ '2FA' if item.two_factor else 'No 2FA' }} +
+ {% endfor %} +
+
+

Add vault entry

+ + + + + + + + +
+
+{% endblock %} From 030599d39eb1c44754e0ea6a2f46604be259691e Mon Sep 17 00:00:00 2001 From: Lxr-max Date: Mon, 14 Sep 2026 02:45:36 +0000 Subject: [PATCH 2/4] fix(mega): harden forms, folders, and plan-detail leaks Safe int/float parsing on checkout and uploads, normalize Cloud drive folder paths, show included users on plan detail, drop the listing count label, and strip S3-compatible wording from the S4 Fixed Storage card tagline so that fact lives on the detail page. Co-authored-by: Lxr-max --- sites/mega/app.py | 72 +++++++++++++++++++++------ sites/mega/seed_data.py | 2 +- sites/mega/templates/cloud_drive.html | 6 +-- sites/mega/templates/plan_detail.html | 1 + 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/sites/mega/app.py b/sites/mega/app.py index e94a1e46..8f1a5fcd 100644 --- a/sites/mega/app.py +++ b/sites/mega/app.py @@ -253,6 +253,30 @@ def slugify(value): return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") +def form_int(name, default=1, minimum=1): + raw = request.form.get(name, default) + try: + value = int(raw if raw not in (None, "") else default) + except (TypeError, ValueError): + value = default + return max(minimum, value) + + +def form_float(name, default=0.0): + raw = request.form.get(name, default) + try: + return float(raw if raw not in (None, "") else default) + except (TypeError, ValueError): + return float(default) + + +def normalize_folder(value): + folder = (value or "").strip() or "/" + if not folder.startswith("/"): + folder = "/" + folder + return folder.rstrip("/") or "/" + + def tokenize(query): return [ token for token in re.split(r"\W+", (query or "").lower()) @@ -372,8 +396,8 @@ def plan_detail(slug): def add_to_cart(slug): plan = Plan.query.filter_by(slug=slug, active=True).first_or_404() session["checkout_plan"] = plan.slug - session["billing_cycle"] = request.form.get("billing_cycle", "monthly") - session["seats"] = max(1, int(request.form.get("seats", 1) or 1)) + session["billing_cycle"] = request.form.get("billing_cycle", "monthly") or "monthly" + session["seats"] = form_int("seats", 1) flash(f"{plan.name} is ready for checkout.", "success") return redirect(url_for("checkout")) @@ -392,7 +416,15 @@ def checkout(): flash("Choose a plan before checkout.", "info") return redirect(url_for("pricing")) billing = request.form.get("billing_cycle") or session.get("billing_cycle", "monthly") - seats = max(1, int(request.form.get("seats") or session.get("seats", 1) or 1)) + if request.method == "POST": + seats = form_int("seats", session.get("seats", 1) or 1) + session["billing_cycle"] = billing + session["seats"] = seats + else: + try: + seats = max(1, int(session.get("seats", 1) or 1)) + except (TypeError, ValueError): + seats = 1 payment_id = request.form.get("payment_id", type=int) methods = PaymentMethod.query.filter_by(user_id=current_user.id).all() subtotal, tax, total = order_total(plan, billing, seats) @@ -458,11 +490,15 @@ def register(): display_name=request.form.get("display_name", "MEGA User"), recovery_key_saved=False, ) - user.set_password(request.form.get("password", "")) - db.session.add(user) - db.session.commit() - login_user(user) - return redirect(url_for("account")) + password = request.form.get("password", "") + if len(password) < 6: + flash("Password must be at least 6 characters.", "error") + else: + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + return redirect(url_for("account")) return render_template("register.html") @@ -511,8 +547,8 @@ def payment_methods(): label=request.form.get("label", "New card"), card_type=request.form.get("card_type", "Visa"), last4=last4, - exp_month=int(request.form.get("exp_month", 12)), - exp_year=int(request.form.get("exp_year", 2029)), + exp_month=form_int("exp_month", 12, minimum=1), + exp_year=form_int("exp_year", 2029, minimum=2026), billing_country=request.form.get("billing_country", "United States"), is_default=bool(request.form.get("is_default")), ) @@ -545,11 +581,19 @@ def cloud_drive(): @login_required def new_folder(): name = request.form.get("name", "").strip() - parent = request.form.get("folder", "/") + parent = normalize_folder(request.form.get("folder", "/")) if not name: flash("Folder name is required.", "error") else: - item = CloudItem(user_id=current_user.id, name=name, slug=slugify(f"{parent}-{name}-{current_user.id}"), item_type="folder", folder=parent, modified_at=datetime.utcnow().strftime("%Y-%m-%d"), content_summary="User-created folder") + item = CloudItem( + user_id=current_user.id, + name=name, + slug=slugify(f"{parent}-{name}-{current_user.id}-{datetime.utcnow().timestamp()}"), + item_type="folder", + folder=parent, + modified_at=datetime.utcnow().strftime("%Y-%m-%d"), + content_summary="User-created folder", + ) db.session.add(item) db.session.commit() flash("Folder created.", "success") @@ -560,7 +604,7 @@ def new_folder(): @login_required def upload_file(): name = request.form.get("name", "").strip() - folder = request.form.get("folder", "/") + folder = normalize_folder(request.form.get("folder", "/")) if not name: flash("File name is required.", "error") else: @@ -572,7 +616,7 @@ def upload_file(): item_type="file", folder=folder, extension=ext, - size_mb=float(request.form.get("size_mb", 12) or 12), + size_mb=form_float("size_mb", 12), modified_at=datetime.utcnow().strftime("%Y-%m-%d"), sync_status="Synced", content_summary=request.form.get("content_summary", "Uploaded through the MEGA web client."), diff --git a/sites/mega/seed_data.py b/sites/mega/seed_data.py index d3582ab9..874119a8 100644 --- a/sites/mega/seed_data.py +++ b/sites/mega/seed_data.py @@ -69,7 +69,7 @@ def seed_database(): "Encrypted collaboration for larger teams with advanced support and account governance.", ["10 users included", "25 TB pooled storage", "Advanced account recovery", "Audit-ready exports", "Dedicated onboarding"], []), ("S4 Fixed Storage", "s4-fixed-storage", "business", "objectstorage", 19.99, 199.99, 3.00, 15.00, 1, 0, 0, 3, False, - "Predictable S3-compatible storage", + "Predictable object storage", "Object storage for backups and media libraries with zero surprise egress on typical workloads.", ["3 TB base object storage", "S3-compatible API", "5x included egress guide", "Lifecycle-friendly archive use"], []), ("Pro Flexi", "pro-flexi", "business", "objectstorage", 16.00, 160.00, 3.00, 3.00, 1, 10, 1, 3, False, diff --git a/sites/mega/templates/cloud_drive.html b/sites/mega/templates/cloud_drive.html index b7f50655..1ba1827f 100644 --- a/sites/mega/templates/cloud_drive.html +++ b/sites/mega/templates/cloud_drive.html @@ -3,7 +3,7 @@ {% block content %}

Cloud drive

-

{{ items|length }} items{% if q %} matching "{{ q }}"{% endif %}

+

{% if folder %}Folder {{ folder }}{% elif q %}Matches for "{{ q }}"{% else %}Files, folders, and uploads{% endif %}

@@ -26,13 +26,13 @@

Cloud drive

New folder

- +

Upload record

- + diff --git a/sites/mega/templates/plan_detail.html b/sites/mega/templates/plan_detail.html index da4ea754..dc4158b9 100644 --- a/sites/mega/templates/plan_detail.html +++ b/sites/mega/templates/plan_detail.html @@ -16,6 +16,7 @@

{{ plan.name }}

Storage
{{ plan.storage_tb }} TB
Transfer
{{ plan.transfer_tb }} TB
+
Users included
{{ plan.users_included }}
VPN devices
{{ plan.vpn_devices }}
Pass accounts
{{ plan.pass_accounts }}
From a947cc162a0795be230d8129e5de68d572f3a85c Mon Sep 17 00:00:00 2001 From: Lxr-max Date: Mon, 14 Sep 2026 02:45:36 +0000 Subject: [PATCH 3/4] review(mega): add deterministic task verifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-anchor ill-posed / leaky tasks onto page-specific facts, put demo credentials in login tasks, point web URLs at localhost:40028, and add verify_lib.py plus verify_0.py–verify_17.py with judge_rubric checkpoints. Co-authored-by: Lxr-max --- sites/mega/tasks.jsonl | 36 +- sites/mega/verify/README.md | 19 + sites/mega/verify/test_verifiers.py | 303 ++++++++++++ sites/mega/verify/verify_0.py | 31 ++ sites/mega/verify/verify_1.py | 27 + sites/mega/verify/verify_10.py | 34 ++ sites/mega/verify/verify_11.py | 35 ++ sites/mega/verify/verify_12.py | 31 ++ sites/mega/verify/verify_13.py | 25 + sites/mega/verify/verify_14.py | 40 ++ sites/mega/verify/verify_15.py | 29 ++ sites/mega/verify/verify_16.py | 29 ++ sites/mega/verify/verify_17.py | 31 ++ sites/mega/verify/verify_2.py | 33 ++ sites/mega/verify/verify_3.py | 41 ++ sites/mega/verify/verify_4.py | 40 ++ sites/mega/verify/verify_5.py | 37 ++ sites/mega/verify/verify_6.py | 40 ++ sites/mega/verify/verify_7.py | 47 ++ sites/mega/verify/verify_8.py | 41 ++ sites/mega/verify/verify_9.py | 31 ++ sites/mega/verify/verify_lib.py | 742 ++++++++++++++++++++++++++++ 22 files changed, 1704 insertions(+), 18 deletions(-) create mode 100644 sites/mega/verify/README.md create mode 100644 sites/mega/verify/test_verifiers.py create mode 100644 sites/mega/verify/verify_0.py create mode 100644 sites/mega/verify/verify_1.py create mode 100644 sites/mega/verify/verify_10.py create mode 100644 sites/mega/verify/verify_11.py create mode 100644 sites/mega/verify/verify_12.py create mode 100644 sites/mega/verify/verify_13.py create mode 100644 sites/mega/verify/verify_14.py create mode 100644 sites/mega/verify/verify_15.py create mode 100644 sites/mega/verify/verify_16.py create mode 100644 sites/mega/verify/verify_17.py create mode 100644 sites/mega/verify/verify_2.py create mode 100644 sites/mega/verify/verify_3.py create mode 100644 sites/mega/verify/verify_4.py create mode 100644 sites/mega/verify/verify_5.py create mode 100644 sites/mega/verify/verify_6.py create mode 100644 sites/mega/verify/verify_7.py create mode 100644 sites/mega/verify/verify_8.py create mode 100644 sites/mega/verify/verify_9.py create mode 100644 sites/mega/verify/verify_lib.py diff --git a/sites/mega/tasks.jsonl b/sites/mega/tasks.jsonl index a5040579..644ab6f7 100644 --- a/sites/mega/tasks.jsonl +++ b/sites/mega/tasks.jsonl @@ -1,18 +1,18 @@ -{"web_name":"MEGA","id":"MEGA--0","ques":"Browse the MEGA storage product page and find two ways the page says users can access or manage their cloud files.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--1","ques":"Search the help center for recovery key guidance and open the article that explains how to save a recovery key.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--2","ques":"Log in as david.k@test.com. Find an individual storage plan with at least 8 TB of storage that includes MEGA VPN, add it to the cart with yearly billing, and go to checkout.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--3","ques":"Log in as alice.j@test.com, choose the Pro I plan with monthly billing, and complete checkout using Alice's default saved payment method.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--4","ques":"Log in as alice.j@test.com. Compare Pro II and Business Pro, decide which has more included users, add that plan with its minimum included seats to the cart, and stop on the checkout review page.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--5","ques":"Log in as alice.j@test.com. In Cloud drive, find the Atlas launch video in the Media Archive, create a share link, and make sure it is shared with Bob and Carol.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--6","ques":"Log in as alice.j@test.com. Search Cloud drive for media files and determine which MOV file is largest, then open it and mark it as a favorite.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--7","ques":"Log in as alice.j@test.com. Create a folder named Q3 Press Kit inside /Projects/Atlas, then add an uploaded file record named press-summary.pdf with size 4.2 MB in that folder.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--8","ques":"Log in as alice.j@test.com. Add a new MEGA Pass vault entry for the Atlas staging site with username alice_editor, category Client, strong password status, and two-factor authentication enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--9","ques":"Log in as alice.j@test.com. Search the MEGA Pass vault for weak or reused entries and identify which old vendor entry does not have two-factor authentication enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--10","ques":"Open Downloads and find the Windows command-line package rather than the regular sync app. Open its detail page and report its checksum.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--11","ques":"Open Downloads and find the recommended Chrome browser extension for MEGA Pass.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--12","ques":"Log in as bob.c@test.com. Use the Pricing filters to show object storage plans, find the plan that mentions an S3-compatible API, and add it to the cart.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--13","ques":"Browse the Business product page and find the capability related to keeping a team organised through administration or user management.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--14","ques":"Log in as bob.c@test.com and submit a High priority Object storage support ticket asking for help estimating S4 egress for quarterly archives.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--15","ques":"Search Help for ransomware recovery, open the matching article, and identify the first action it recommends before restoring files.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--16","ques":"Log in as alice.j@test.com, update the account profile so the company is Riverlight Studio Labs, and make sure both two-factor authentication and recovery key saved are enabled.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} -{"web_name":"MEGA","id":"MEGA--17","ques":"Log in as bob.c@test.com. Find the flexible plan for variable cloud workloads that starts with 3 TB base storage and transfer, compare it with the fixed S4 storage plan, and add the flexible plan to the cart with yearly billing.","web":"http://localhost:40015/","upstream_url":"https://mega.io/"} +{"web_name": "MEGA", "id": "MEGA--0", "ques": "Open the Cloud storage product page. Quote two of the 'What you can do' highlights that describe how users access or manage their cloud files.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) The trajectory MUST open the Cloud storage product page (/storage), not only the homepage. (2) The answer MUST quote two on-page highlights about accessing or managing files. (3) Empty answer = FAIL. FAIL if /storage was never opened or the highlights are not taken from that page."} +{"web_name": "MEGA", "id": "MEGA--1", "ques": "Search the help center for recovery key guidance, open the article about saving a recovery key, and report who that article says you should not share the recovery key with.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the help article about saving a recovery key (not only the help index). (2) The answer MUST report who the article says not to share the key with. (3) Empty answer = FAIL. FAIL if the article page was never opened or the restriction is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--2", "ques": "Log in as david.k@test.com (password: TestPass123!). Open the Pro II individual plan (8 TB storage with MEGA VPN listed in its features), add it to the cart with yearly billing, and stop on the checkout review page without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as david.k@test.com. (2) MUST open the Pro II plan page and reach checkout with yearly billing selected. (3) MUST NOT complete payment / create a new order. FAIL if login, Pro II, yearly checkout, or the no-purchase constraint is missing."} +{"web_name": "MEGA", "id": "MEGA--3", "ques": "Log in as alice.j@test.com (password: TestPass123!), open the Pro I plan, choose monthly billing, and complete checkout using Alice's default saved payment method.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Pro I plan and complete checkout with monthly billing and a saved payment method. (3) After-state MUST contain a new Pro I monthly order for Alice. FAIL if checkout was only reviewed, the plan/cycle is wrong, or no order was written."} +{"web_name": "MEGA", "id": "MEGA--4", "ques": "Log in as alice.j@test.com (password: TestPass123!). Open the Pro II and Business Pro plan pages, report which plan includes more users, add that plan using its minimum included seat count, and stop on the checkout review page without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open BOTH Pro II and Business Pro plan pages. (3) The answer MUST identify which of those two plans includes more users. (4) MUST add the larger-user plan at its minimum included seats and stop on checkout without creating a new order. FAIL if either plan page is skipped, the comparison is wrong, or payment is completed."} +{"web_name": "MEGA", "id": "MEGA--5", "ques": "Log in as alice.j@test.com (password: TestPass123!). In Cloud drive, open Atlas b-roll selects.mov in the Media Archive, create a share link, and share it with bob.c@test.com and carol.d@test.com.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Atlas b-roll selects.mov file page in Cloud drive. (3) After-state MUST show a share link plus both requested collaborators. FAIL if a different file was used, the link was not created, or either collaborator is missing."} +{"web_name": "MEGA", "id": "MEGA--6", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search Cloud drive for media files, report which MOV file is largest, open it, and mark it as a favorite.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open Cloud drive / the largest MOV's file page. (2) The answer MUST name that largest MOV. (3) After-state MUST mark that file as a favorite. FAIL if the wrong file is named, the file page was never opened, or favorite was not saved."} +{"web_name": "MEGA", "id": "MEGA--7", "ques": "Log in as alice.j@test.com (password: TestPass123!). Create a folder named Q3 Press Kit inside /Projects/Atlas, then add an uploaded file record named press-summary.pdf with size 4.2 MB in that folder.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com and use Cloud drive folder/upload forms. (2) After-state MUST contain a Q3 Press Kit folder under /Projects/Atlas and press-summary.pdf at 4.2 MB in that folder path. FAIL if either record is missing, the parent path is wrong, or the size is not 4.2 MB."} +{"web_name": "MEGA", "id": "MEGA--8", "ques": "Log in as alice.j@test.com (password: TestPass123!). Add a new MEGA Pass vault entry for the Atlas staging site with username alice_editor, category Client, strong password status, and two-factor authentication enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the MEGA Pass vault. (2) After-state MUST contain a new vault entry whose title refers to the Atlas staging site, username alice_editor, category Client, strength Strong, and 2FA enabled. FAIL if an existing near-miss entry is reused or any required field is wrong."} +{"web_name": "MEGA", "id": "MEGA--9", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search the MEGA Pass vault for weak or reused entries and identify which old vendor entry does not have two-factor authentication enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open / search the MEGA Pass vault. (2) The answer MUST name the old vendor vault entry that has no 2FA. (3) Empty answer = FAIL. FAIL if the vault was never opened or a different weak/reused entry is named."} +{"web_name": "MEGA", "id": "MEGA--10", "ques": "Open Downloads and find the Windows MEGAcmd setup installer (not MEGAsync and not the portable zip). Open its detail page and report the checksum shown there.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Windows MEGAcmd setup installer detail page (not MEGAsync, not the portable zip). (2) The answer MUST report the checksum shown on that page. FAIL if only the downloads index was opened, the wrong package was used, or the checksum is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--11", "ques": "Open Downloads, find the recommended Chrome browser extension for MEGA Pass, open its detail page, and report the package name and version shown there.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the recommended MEGA Pass Chrome extension detail page. (2) The answer MUST report the package name and version shown on that page. FAIL if a Firefox/Edge extension is used instead, the detail page was skipped, or name/version is missing."} +{"web_name": "MEGA", "id": "MEGA--12", "ques": "Log in as bob.c@test.com (password: TestPass123!). Use the Pricing filters to show object storage plans, open the plan whose features mention an S3-compatible API, and add it to the cart (stop on checkout; do not complete payment).", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST use Pricing with the object-storage filter and open the plan whose features mention an S3-compatible API. (3) MUST add that plan and reach checkout without creating a new order. FAIL if a different object-storage plan is chosen or payment is completed."} +{"web_name": "MEGA", "id": "MEGA--13", "ques": "Open the Business product page and report the highlight that names the team administration surface.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Business product page (/business), not only the homepage card. (2) The answer MUST report the on-page highlight that names the team administration surface. FAIL if /business was never opened or the highlight is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--14", "ques": "Log in as bob.c@test.com (password: TestPass123!) and submit a High priority Object storage support ticket asking for help estimating S4 egress for quarterly archives.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com and submit the contact/support form. (2) After-state MUST contain a new High-priority Object storage ticket whose subject/message asks about S4 egress for quarterly archives. FAIL if priority/category is wrong or no new ticket exists."} +{"web_name": "MEGA", "id": "MEGA--15", "ques": "Search Help for ransomware recovery, open the matching article, and report the first action it recommends before restoring files.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the ransomware recovery help article, not only the help index. (2) The answer MUST report the first action that article recommends before restoring files. FAIL if a different recovery article is used or the first action is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--16", "ques": "Log in as alice.j@test.com (password: TestPass123!), update the account profile so the company is Riverlight Studio Labs, and make sure both two-factor authentication and recovery key saved are enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the account edit form. (2) After-state MUST show company Riverlight Studio Labs with 2FA enabled and recovery key marked saved. FAIL if the company string is unchanged/wrong or either security flag is off."} +{"web_name": "MEGA", "id": "MEGA--17", "ques": "Log in as bob.c@test.com (password: TestPass123!). Open the flexible plan for variable cloud workloads that starts with 3 TB base storage and transfer, compare it with the fixed S4 storage plan, add the flexible plan to the cart with yearly billing, and stop on checkout without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST open both the flexible variable-workload plan and the fixed S4 storage plan. (3) MUST add the flexible plan with yearly billing and stop on checkout without a new order. FAIL if the fixed S4 plan is added instead, yearly billing is missing, or payment is completed."} diff --git a/sites/mega/verify/README.md b/sites/mega/verify/README.md new file mode 100644 index 00000000..5e8883a3 --- /dev/null +++ b/sites/mega/verify/README.md @@ -0,0 +1,19 @@ +# MEGA deterministic grading contract + +Each row in `sites/mega/tasks.jsonl` points at `verify_0.py` … `verify_17.py`. +Wrappers share `verify_lib.py` (navigation, negation-aware answer match, SQLite +after-state, optional LLM anchors). Ground truth lives only in the verifiers. + +```bash +python sites/mega/verify/verify_0.py --run_dir RUN --initial_db SEED.db --after_db LIVE.db --no_llm true +# or, with a running mirror container: +uv run python agent_demo/eval_judge.py --run_dir RUN --verifier True +``` + +Read-only tasks (0, 1, 9–11, 13, 15) require an unchanged database. +Stateful tasks check the exact row the task asked for (orders, cloud items, +vault entries, tickets, profile fields). Cart-only tasks (2, 4, 12, 17) require +checkout navigation and **no new subscription order**. + +`python3 sites/mega/verify/test_verifiers.py` runs a no-op / pass / shortcut / +wrong-answer matrix without Docker or an API key. diff --git a/sites/mega/verify/test_verifiers.py b/sites/mega/verify/test_verifiers.py new file mode 100644 index 00000000..b264a92c --- /dev/null +++ b/sites/mega/verify/test_verifiers.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""No-op / pass / shortcut / wrong-answer matrix for MEGA verifiers. + +Uses the locally generated seed (sites/mega/instance_seed/mega.db). No Docker +and no API key: every verifier is invoked with --no_llm true. +""" +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import struct +import subprocess +import sys +import tempfile +import zlib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SITE = HERE.parent +SEED = SITE / "instance_seed" / "mega.db" +BASE = "http://localhost:40028" +TASKS = list(range(18)) +READ_ONLY = {0, 1, 9, 10, 11, 13, 15} + + +def png_bytes(width, height, rgb): + rows = [] + for y in range(height): + row = bytearray() + for x in range(width): + row.extend(( + (rgb[0] + x + y) % 256, + (rgb[1] + x * 3) % 256, + (rgb[2] + y * 5) % 256, + )) + rows.append(b"\x00" + bytes(row)) + raw = b"".join(rows) + + def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack( + ">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + + return (b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"")) + + +def write_shots(run_dir, count=4): + shots = run_dir / "screenshots" + shots.mkdir(parents=True, exist_ok=True) + colors = [(220, 30, 40), (20, 90, 180), (16, 132, 90), (240, 180, 40), (30, 30, 30)] + for i in range(count): + (shots / f"step_{i:03d}.png").write_bytes(png_bytes(640, 400, colors[i % len(colors)])) + + +def make_run(run_dir, task_id, paths, answer, params=None, extra_steps=None): + run_dir = Path(run_dir) + if run_dir.exists(): + shutil.rmtree(run_dir) + run_dir.mkdir(parents=True) + write_shots(run_dir) + steps = [] + urls = [BASE + "/"] + [BASE + p if p.startswith("/") else p for p in paths] + if urls[-1] != urls[0] and len(urls) == 2: + pass + params = params or {} + for i, url in enumerate(urls): + last = i == len(urls) - 1 + step = { + "step": i, + "url": url, + "title": "MEGA", + "thought": "test", + "action": "done" if last else "click", + "params": {"text": answer, "success": True, **params} if last else params, + "screenshot_before": f"step_{min(i, 3):03d}.png", + "screenshot_after": f"step_{min(i + 1, 3):03d}.png", + } + steps.append(step) + if extra_steps: + steps.extend(extra_steps) + steps[-1]["action"] = "done" + steps[-1]["params"] = {"text": answer, "success": True, **params} + traj = { + "task_id": f"MEGA--{task_id}" if isinstance(task_id, int) else task_id, + "task": "", + "start_url": BASE + "/", + "terminated": True, + "termination_reason": "agent_done", + "final_answer": answer, + "success_self_report": True, + "steps": steps, + "verifier_path": f"sites/mega/verify/verify_{task_id}.py", + } + (run_dir / "trajectory.json").write_text(json.dumps(traj, indent=2)) + return run_dir + + +def copy_db(src, dest): + dest = Path(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + return dest + + +def mutate(path, statements): + con = sqlite3.connect(path) + try: + for sql, params in statements: + con.execute(sql, params) + con.commit() + finally: + con.close() + + +def scalar(db, sql, params=()): + con = sqlite3.connect(db) + try: + row = con.execute(sql, params).fetchone() + return row[0] if row else None + finally: + con.close() + + +def run_verifier(n, run_dir, initial, after): + script = HERE / f"verify_{n}.py" + proc = subprocess.run( + [sys.executable, str(script), "--run_dir", str(run_dir), + "--initial_db", str(initial), "--after_db", str(after), "--no_llm", "true"], + capture_output=True, text=True) + try: + verdict = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + verdict = {"pass": False, "reason": "unparseable", "evidence": [proc.stdout, proc.stderr]} + verdict["_returncode"] = proc.returncode + verdict["_stderr"] = proc.stderr + return verdict + + +def expect(label, verdict, should_pass): + ok = bool(verdict.get("pass")) + if ok != should_pass: + raise SystemExit( + f"{label}: expected pass={should_pass} got pass={ok} reason={verdict.get('reason')!r}\n" + f"evidence={verdict.get('evidence')}\nstderr={verdict.get('_stderr')}") + print(f" OK {label}: pass={ok} reason={verdict.get('reason')!r}") + + +def pass_paths(n, alice_broll, alice_launch, cmd_id, chrome_id): + login = ["/login"] + if n == 0: + return ["/storage"], "Mobile and desktop access; File and folder links." + if n == 1: + return ["/help", "/help/save-your-recovery-key"], "Do not share it with support or teammates." + if n == 2: + return login + ["/plans/pro-ii", "/checkout"], "Pro II yearly in checkout." + if n == 3: + return login + ["/plans/pro-i", "/checkout", "/orders/MEGA-TEST-ALICE"], "Completed Pro I monthly checkout." + if n == 4: + return login + ["/plans/pro-ii", "/plans/business-pro", "/checkout"], "Business Pro includes more users (5)." + if n == 5: + return login + ["/cloud", f"/cloud/item/{alice_broll}"], "Shared Atlas b-roll selects.mov." + if n == 6: + return login + ["/cloud", f"/cloud/item/{alice_launch}"], "Atlas launch footage.mov is the largest MOV." + if n == 7: + return login + ["/cloud", "/cloud?folder=/Projects/Atlas"], "Created Q3 Press Kit and uploaded press-summary.pdf." + if n == 8: + return login + ["/vault"], "Saved Atlas staging site vault entry." + if n == 9: + return login + ["/vault", "/vault?q=weak"], "Old vendor FTP has no 2FA." + if n == 10: + return ["/downloads", f"/downloads/{cmd_id}"], "sha256-megacmdsetup64-exe-webharbor" + if n == 11: + return ["/downloads", f"/downloads/{chrome_id}"], "MEGA Pass Chrome extension version 1.12.4" + if n == 12: + return login + ["/pricing?category=objectstorage", "/plans/s4-fixed-storage", "/checkout"], "S4 Fixed Storage in checkout." + if n == 13: + return ["/business"], "Team dashboard" + if n == 14: + return login + ["/contact", "/support/tickets/MEGA-T-TEST"], "Submitted High priority Object storage ticket." + if n == 15: + return ["/help", "/help/recover-from-ransomware"], "Disconnect the affected device." + if n == 16: + return login + ["/account/edit", "/account"], "Company is Riverlight Studio Labs; 2FA and recovery key enabled." + if n == 17: + return login + ["/plans/pro-flexi", "/plans/s4-fixed-storage", "/checkout"], "Pro Flexi yearly in checkout." + raise AssertionError(n) + + +def apply_pass_mutation(n, after): + alice = scalar(after, "SELECT id FROM users WHERE email=?", ("alice.j@test.com",)) + bob = scalar(after, "SELECT id FROM users WHERE email=?", ("bob.c@test.com",)) + pro_i = scalar(after, "SELECT id FROM plans WHERE slug=?", ("pro-i",)) + if n == 3: + mutate(after, [( + "INSERT INTO subscription_orders (user_id, plan_id, order_number, billing_cycle, seats, " + "subtotal, tax, total, status, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)", + (alice, pro_i, "MEGA-TEST-ALICE", "monthly", 1, 10.99, 0.77, 11.76, "active", "2026-09-14"), + ), ("UPDATE users SET plan_id=? WHERE id=?", (pro_i, alice))]) + elif n == 5: + mutate(after, [( + "UPDATE cloud_items SET shared_with=?, share_link=? WHERE user_id=? AND name=?", + ("bob.c@test.com, carol.d@test.com", "https://mega.nz/file/BROLL#test", + alice, "Atlas b-roll selects.mov"), + )]) + elif n == 6: + mutate(after, [("UPDATE cloud_items SET favorite=1 WHERE user_id=? AND name=?", + (alice, "Atlas launch footage.mov"))]) + elif n == 7: + mutate(after, [ + ("INSERT INTO cloud_items (user_id, name, slug, item_type, folder, extension, size_mb, " + "modified_at, sync_status, shared_with, share_link, favorite, backup_source, content_summary) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (alice, "Q3 Press Kit", "q3-press-kit-test", "folder", "/Projects/Atlas", "", 0, + "2026-09-14", "Synced", "", "", 0, "", "User-created folder")), + ("INSERT INTO cloud_items (user_id, name, slug, item_type, folder, extension, size_mb, " + "modified_at, sync_status, shared_with, share_link, favorite, backup_source, content_summary) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (alice, "press-summary.pdf", "press-summary-test", "file", + "/Projects/Atlas/Q3 Press Kit", "pdf", 4.2, "2026-09-14", "Synced", "", "", 0, "", "Upload")), + ]) + elif n == 8: + mutate(after, [( + "INSERT INTO vault_items (user_id, title, slug, username, site_url, category, strength, " + "last_changed, two_factor, notes) VALUES (?,?,?,?,?,?,?,?,?,?)", + (alice, "Atlas staging site", "atlas-staging-site-test", "alice_editor", + "https://staging.atlas.example", "Client", "Strong", "2026-09-14", 1, "new"), + )]) + elif n == 14: + mutate(after, [( + "INSERT INTO support_tickets (user_id, ticket_number, subject, category, priority, status, " + "message, created_at) VALUES (?,?,?,?,?,?,?,?)", + (bob, "MEGA-T-TEST", "Need help estimating S4 egress for quarterly archives", + "Object storage", "High", "Open", + "Please help estimate S4 egress for quarterly archives.", "2026-09-14"), + )]) + elif n == 16: + mutate(after, [("UPDATE users SET company=? WHERE id=?", ("Riverlight Studio Labs", alice))]) + + +def billing_params(n): + if n in {2, 17}: + return {"billing_cycle": "yearly"} + if n == 3: + return {"billing_cycle": "monthly"} + return {} + + +def main(): + if not SEED.exists(): + raise SystemExit(f"missing seed DB {SEED}; generate it by importing sites/mega/app.py") + alice_broll = scalar(SEED, "SELECT slug FROM cloud_items WHERE name=? AND user_id=(" + "SELECT id FROM users WHERE email=?)", + ("Atlas b-roll selects.mov", "alice.j@test.com")) + alice_launch = scalar(SEED, "SELECT slug FROM cloud_items WHERE name=? AND user_id=(" + "SELECT id FROM users WHERE email=?)", + ("Atlas launch footage.mov", "alice.j@test.com")) + cmd_id = scalar(SEED, "SELECT id FROM downloads WHERE package_name=?", ("MEGAcmdSetup64.exe",)) + chrome_id = scalar(SEED, "SELECT id FROM downloads WHERE package_name=?", + ("MEGA Pass Chrome extension",)) + + failures = 0 + with tempfile.TemporaryDirectory(prefix="mega-verify-") as tmp: + tmp = Path(tmp) + for n in TASKS: + print(f"task {n}") + initial = copy_db(SEED, tmp / f"t{n}" / "initial.db") + # --- no-op --- + after = copy_db(SEED, tmp / f"t{n}" / "noop-after.db") + run = make_run(tmp / f"t{n}" / "noop", n, [], "") + expect(f"{n} no-op", run_verifier(n, run, initial, after), False) + # --- shortcut: right answer, homepage only --- + paths, answer = pass_paths(n, alice_broll, alice_launch, cmd_id, chrome_id) + run = make_run(tmp / f"t{n}" / "shortcut", n, [], answer, params=billing_params(n)) + expect(f"{n} shortcut", run_verifier(n, run, initial, copy_db(SEED, tmp / f"t{n}" / "short-after.db")), False) + # --- wrong answer on the real pages (read-only / comparison) --- + run = make_run(tmp / f"t{n}" / "wrong", n, paths, "this is not the on-page answer", + params=billing_params(n)) + after_wrong = copy_db(SEED, tmp / f"t{n}" / "wrong-after.db") + apply_pass_mutation(n, after_wrong) + if n in READ_ONLY or n in {4, 6}: + expect(f"{n} wrong-answer", run_verifier(n, run, initial, after_wrong), False) + # --- genuine pass --- + after_pass = copy_db(SEED, tmp / f"t{n}" / "pass-after.db") + apply_pass_mutation(n, after_pass) + run = make_run(tmp / f"t{n}" / "pass", n, paths, answer, params=billing_params(n)) + expect(f"{n} pass", run_verifier(n, run, initial, after_pass), True) + # stateful: self-report without DB write + for n in (3, 5, 6, 7, 8, 14, 16): + paths, answer = pass_paths(n, alice_broll, alice_launch, cmd_id, chrome_id) + initial = copy_db(SEED, tmp / f"state{n}" / "initial.db") + after = copy_db(SEED, tmp / f"state{n}" / "after.db") + run = make_run(tmp / f"state{n}" / "run", n, paths, answer, params=billing_params(n)) + expect(f"{n} state-mismatch", run_verifier(n, run, initial, after), False) + print("all verifier matrix checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sites/mega/verify/verify_0.py b/sites/mega/verify/verify_0.py new file mode 100644 index 00000000..f33e4989 --- /dev/null +++ b/sites/mega/verify/verify_0.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""MEGA--0 (read-only): Cloud storage highlights for accessing/managing files. + +Ground truth: 'Mobile and desktop access' and 'File and folder links'. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, final_answer, llm_text_match, load_run, + navigated_path, parse_args, resolve_db, tables_unchanged) + +def main(): + a = parse_args() + j = Judge("MEGA--0", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + changed = tables_unchanged(init, after) + j.check("db_read_only", changed == [], f"changed tables={changed!r}") + j.bind_run(t, shot_url="/storage") + j.check("nav_storage", navigated_path(t, "/storage"), "opened the Cloud storage product page") + j.check("answer_mobile_desktop", affirms_any(fa, ["mobile and desktop access", "mobile and desktop"]), + f"final={fa!r}") + j.check("answer_file_folder_links", affirms_any(fa, ["file and folder links", "file and folder link"]), + f"final={fa!r}") + ok, ev = llm_text_match(fa, "Mobile and desktop access; File and folder links", + "Which two Cloud storage highlights describe accessing or managing files?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_1.py b/sites/mega/verify/verify_1.py new file mode 100644 index 00000000..4b9056bf --- /dev/null +++ b/sites/mega/verify/verify_1.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""MEGA--1 (read-only): recovery-key article — do not share with support or teammates.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, contains_all, final_answer, llm_text_match, load_run, + navigated_to, parse_args, resolve_db, tables_unchanged) + +def main(): + a = parse_args() + j = Judge("MEGA--1", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + j.bind_run(t, shot_url="/help/save-your-recovery-key") + j.check("nav_article", navigated_to(t, "/help/save-your-recovery-key"), + "opened the Save your recovery key article") + j.check("answer_support_teammates", contains_all(fa, ["support", "teammate"]), + f"final={fa!r}") + ok, ev = llm_text_match(fa, "Do not share the recovery key with support or teammates.", + "Who does the recovery-key article say not to share the key with?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_10.py b/sites/mega/verify/verify_10.py new file mode 100644 index 00000000..a7a3b5a0 --- /dev/null +++ b/sites/mega/verify/verify_10.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""MEGA--10 (read-only): Windows MEGAcmd setup installer checksum.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms, download_named, final_answer, llm_text_match, + load_run, navigated_to, parse_args, resolve_db, tables_unchanged) + +PACKAGE = "MEGAcmdSetup64.exe" + +def main(): + a = parse_args() + j = Judge("MEGA--10", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + row = download_named(init or after, PACKAGE) + j.check("db_package", row is not None, f"row={row!r}") + download_id = row[0] if row else None + checksum = row[3] if row else "" + j.bind_run(t, shot_url=f"/downloads/{download_id}" if download_id else "/downloads") + j.check("nav_downloads", navigated_to(t, "/downloads"), "opened Downloads") + j.check("nav_detail", download_id is not None and navigated_to(t, f"/downloads/{download_id}"), + f"opened installer detail id={download_id}") + j.check("not_portable_only", not (navigated_to(t, "/downloads/5") and not navigated_to(t, f"/downloads/{download_id}")), + "did not grade the portable zip instead of the setup installer") + j.check("answer_checksum", bool(checksum) and affirms(fa, checksum), f"expected={checksum!r} final={fa!r}") + ok, ev = llm_text_match(fa, checksum, "What checksum is shown for MEGAcmdSetup64.exe?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_11.py b/sites/mega/verify/verify_11.py new file mode 100644 index 00000000..ab63fc55 --- /dev/null +++ b/sites/mega/verify/verify_11.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""MEGA--11 (read-only): recommended MEGA Pass Chrome extension name + version.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms, affirms_any, download_named, final_answer, + llm_text_match, load_run, navigated_to, parse_args, resolve_db, + tables_unchanged) + +PACKAGE = "MEGA Pass Chrome extension" + +def main(): + a = parse_args() + j = Judge("MEGA--11", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + row = download_named(init or after, PACKAGE) + j.check("db_package", row is not None and bool(row[4]), f"row={row!r}") + download_id = row[0] if row else None + version = row[2] if row else "" + j.bind_run(t, shot_url=f"/downloads/{download_id}" if download_id else "/downloads") + j.check("nav_downloads", navigated_to(t, "/downloads"), "opened Downloads") + j.check("nav_detail", download_id is not None and navigated_to(t, f"/downloads/{download_id}"), + f"opened Chrome extension detail id={download_id}") + j.check("answer_name", affirms_any(fa, ["mega pass chrome", PACKAGE.lower()]), f"final={fa!r}") + j.check("answer_version", bool(version) and affirms(fa, version), f"version={version!r} final={fa!r}") + ok, ev = llm_text_match(fa, f"{PACKAGE} version {version}", + "What is the recommended MEGA Pass Chrome extension package name and version?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_12.py b/sites/mega/verify/verify_12.py new file mode 100644 index 00000000..a4f45413 --- /dev/null +++ b/sites/mega/verify/verify_12.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""MEGA--12: Bob adds S4 Fixed Storage (S3-compatible API) and stops at checkout.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, orders_for, parse_args, + resolve_db) + +EMAIL = "bob.c@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--12", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/checkout") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_pricing_objectstorage", navigated_to(t, "category=objectstorage"), + "applied the object storage pricing filter") + j.check("nav_s4_fixed", + navigated_to(t, "/plans/s4-fixed-storage") or navigated_to(t, "/cart/add/s4-fixed-storage"), + "opened/added the S3-compatible S4 Fixed Storage plan") + j.check("nav_checkout", navigated_to(t, "/checkout"), "reached checkout") + before = orders_for(init, EMAIL) if init else None + after_orders = orders_for(after, EMAIL) if after else None + j.check("db_no_new_order", before is not None and after_orders is not None and len(after_orders) == len(before), + f"orders seed={None if before is None else len(before)} after={None if after_orders is None else len(after_orders)}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_13.py b/sites/mega/verify/verify_13.py new file mode 100644 index 00000000..d225192f --- /dev/null +++ b/sites/mega/verify/verify_13.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""MEGA--13 (read-only): Business page highlight 'Team dashboard'.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, final_answer, llm_text_match, load_run, + navigated_path, parse_args, resolve_db, tables_unchanged) + +def main(): + a = parse_args() + j = Judge("MEGA--13", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + j.bind_run(t, shot_url="/business") + j.check("nav_business", navigated_path(t, "/business"), "opened the Business product page") + j.check("answer_team_dashboard", affirms_any(fa, ["team dashboard"]), f"final={fa!r}") + ok, ev = llm_text_match(fa, "Team dashboard", + "Which Business page highlight names the team administration surface?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_14.py b/sites/mega/verify/verify_14.py new file mode 100644 index 00000000..8cf7310e --- /dev/null +++ b/sites/mega/verify/verify_14.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""MEGA--14: Bob files a High-priority Object storage ticket about S4 egress.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, parse_args, resolve_db, + tickets_for) + +EMAIL = "bob.c@test.com" + +def _match(row): + _id, _num, subject, category, priority, message = row + blob = f"{subject} {message}".casefold() + return ((category or "").casefold() == "object storage" + and (priority or "").casefold() == "high" + and "s4" in blob and "egress" in blob + and "quarter" in blob) + +def main(): + a = parse_args() + j = Judge("MEGA--14", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/support/tickets/") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_contact", navigated_to(t, "/contact") or navigated_to(t, "/support/tickets/"), + "opened contact / ticket confirmation") + before = tickets_for(init, EMAIL) if init else None + after_tix = tickets_for(after, EMAIL) if after else None + j.check("seed_no_match", before is not None and not any(_match(row) for row in before), + f"seed={before!r}") + new = [] + if before is not None and after_tix is not None: + before_ids = {row[0] for row in before} + new = [row for row in after_tix if row[0] not in before_ids] + j.check("db_new_ticket", len(new) == 1 and _match(new[0]), f"new={new!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_15.py b/sites/mega/verify/verify_15.py new file mode 100644 index 00000000..9f23389c --- /dev/null +++ b/sites/mega/verify/verify_15.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""MEGA--15 (read-only): ransomware article first action is disconnect the affected device.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, final_answer, llm_text_match, load_run, + navigated_to, parse_args, resolve_db, tables_unchanged) + +def main(): + a = parse_args() + j = Judge("MEGA--15", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + j.bind_run(t, shot_url="/help/recover-from-ransomware") + j.check("nav_article", navigated_to(t, "/help/recover-from-ransomware"), + "opened Recover from ransomware") + j.check("answer_disconnect", + affirms_any(fa, ["disconnect the affected device", "disconnect"]), + f"final={fa!r}") + j.check("answer_device", affirms_any(fa, ["device", "affected device"]), f"final={fa!r}") + ok, ev = llm_text_match(fa, "Disconnect the affected device", + "What is the first action the ransomware article recommends before restoring files?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_16.py b/sites/mega/verify/verify_16.py new file mode 100644 index 00000000..cd5f2db7 --- /dev/null +++ b/sites/mega/verify/verify_16.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""MEGA--16: Alice company -> Riverlight Studio Labs; 2FA and recovery key stay on.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, parse_args, resolve_db, user_row) + +EMAIL = "alice.j@test.com" +COMPANY = "Riverlight Studio Labs" + +def main(): + a = parse_args() + j = Judge("MEGA--16", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/account") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_edit", navigated_to(t, "/account/edit"), "opened account edit") + seed = user_row(init, EMAIL) if init else None + row = user_row(after, EMAIL) if after else None + j.check("seed_company", seed is not None and seed[2] == "Riverlight Studio", + f"seed company={None if not seed else seed[2]!r}") + j.check("after_company", row is not None and row[2] == COMPANY, f"after company={None if not row else row[2]!r}") + j.check("after_2fa", row is not None and bool(row[3]), f"2fa={None if not row else row[3]}") + j.check("after_recovery", row is not None and bool(row[4]), f"recovery={None if not row else row[4]}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_17.py b/sites/mega/verify/verify_17.py new file mode 100644 index 00000000..e9cb8e29 --- /dev/null +++ b/sites/mega/verify/verify_17.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""MEGA--17: Bob compares Pro Flexi vs S4 Fixed, carts Pro Flexi yearly, no purchase.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, orders_for, parse_args, + posted_billing, resolve_db) + +EMAIL = "bob.c@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--17", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/checkout") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_flexi", navigated_to(t, "/plans/pro-flexi") or navigated_to(t, "/cart/add/pro-flexi"), + "opened/added Pro Flexi") + j.check("nav_s4_fixed", navigated_to(t, "/plans/s4-fixed-storage"), + "opened the fixed S4 storage plan for comparison") + j.check("nav_checkout", navigated_to(t, "/checkout"), "reached checkout") + j.check("billing_yearly", posted_billing(t, "yearly"), "yearly billing selected") + before = orders_for(init, EMAIL) if init else None + after_orders = orders_for(after, EMAIL) if after else None + j.check("db_no_new_order", before is not None and after_orders is not None and len(after_orders) == len(before), + f"orders seed={None if before is None else len(before)} after={None if after_orders is None else len(after_orders)}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_2.py b/sites/mega/verify/verify_2.py new file mode 100644 index 00000000..dadcffa8 --- /dev/null +++ b/sites/mega/verify/verify_2.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""MEGA--2 (stateful cart, no purchase): David adds Pro II yearly and stops at checkout.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, final_answer, load_run, navigated_to, orders_for, + parse_args, posted_billing, resolve_db) + +EMAIL = "david.k@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--2", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/checkout") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_pro_ii", navigated_to(t, "/plans/pro-ii") or navigated_to(t, "/cart/add/pro-ii"), + "opened Pro II or added it to the cart") + j.check("nav_checkout", navigated_to(t, "/checkout"), "reached checkout review") + j.check("billing_yearly", posted_billing(t, "yearly"), f"traj billing evidence") + before = orders_for(init, EMAIL) if init else None + after_orders = orders_for(after, EMAIL) if after else None + j.check("db_no_new_order", before is not None and after_orders is not None and len(after_orders) == len(before), + f"orders seed={None if before is None else len(before)} after={None if after_orders is None else len(after_orders)}") + fa = final_answer(t) + if fa: + from verify_lib import affirms_any + j.check("answer_mentions_plan", True, f"final={fa!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_3.py b/sites/mega/verify/verify_3.py new file mode 100644 index 00000000..0907933c --- /dev/null +++ b/sites/mega/verify/verify_3.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""MEGA--3 (stateful): Alice completes Pro I monthly checkout with default card.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, orders_for, parse_args, + plan_row, posted_billing, resolve_db) + +EMAIL = "alice.j@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--3", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/orders/") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_pro_i", navigated_to(t, "/plans/pro-i") or navigated_to(t, "/cart/add/pro-i"), + "opened or added Pro I") + j.check("nav_checkout", navigated_to(t, "/checkout") or navigated_to(t, "/orders/"), + "checkout or order confirmation") + plan = plan_row(after or init, "pro-i") + j.check("db_plan", plan is not None, f"pro-i={plan!r}") + before = orders_for(init, EMAIL) if init else None + after_orders = orders_for(after, EMAIL) if after else None + new = [] + if before is not None and after_orders is not None: + before_ids = {row[0] for row in before} + new = [row for row in after_orders if row[0] not in before_ids] + j.check("db_new_order", len(new) == 1, f"new_orders={new!r}") + if new: + _oid, _num, cycle, _seats, plan_id, slug, status = new[0] + j.check("order_plan", slug == "pro-i" or (plan and plan_id == plan[0]), f"slug={slug} plan_id={plan_id}") + j.check("order_monthly", cycle == "monthly", f"billing={cycle}") + j.check("order_active", status == "active", f"status={status}") + elif posted_billing(t, "monthly"): + j.check("order_monthly", False, "no new order to confirm monthly billing") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_4.py b/sites/mega/verify/verify_4.py new file mode 100644 index 00000000..02934021 --- /dev/null +++ b/sites/mega/verify/verify_4.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""MEGA--4: compare Pro II vs Business Pro users; cart Business Pro; no purchase.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, final_answer, llm_text_match, load_run, + navigated_to, orders_for, parse_args, plan_row, resolve_db) + +EMAIL = "alice.j@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--4", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + ref = init or after + j.bind_run(t, shot_url="/checkout") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_pro_ii", navigated_to(t, "/plans/pro-ii"), "opened Pro II") + j.check("nav_business_pro", navigated_to(t, "/plans/business-pro"), "opened Business Pro") + j.check("nav_checkout", navigated_to(t, "/checkout") or navigated_to(t, "/cart/add/business-pro"), + "added Business Pro / reached checkout") + pro_ii = plan_row(ref, "pro-ii") + biz = plan_row(ref, "business-pro") + j.check("db_users_business_more", + pro_ii is not None and biz is not None and biz[4] > pro_ii[4], + f"pro_ii_users={None if not pro_ii else pro_ii[4]} business={None if not biz else biz[4]}") + j.check("answer_business_pro", affirms_any(fa, ["business pro"]), f"final={fa!r}") + j.check("answer_five_users", affirms_any(fa, ["5", "five"]), f"final={fa!r}") + before = orders_for(init, EMAIL) if init else None + after_orders = orders_for(after, EMAIL) if after else None + j.check("db_no_new_order", before is not None and after_orders is not None and len(after_orders) == len(before), + f"orders seed={None if before is None else len(before)} after={None if after_orders is None else len(after_orders)}") + ok, ev = llm_text_match(fa, "Business Pro includes more users (5) than Pro II (1).", + "Which plan includes more users, Pro II or Business Pro?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_5.py b/sites/mega/verify/verify_5.py new file mode 100644 index 00000000..d98eb8c0 --- /dev/null +++ b/sites/mega/verify/verify_5.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""MEGA--5: share Atlas b-roll selects.mov with Bob and Carol and create a link.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, cloud_item_named, emails_listed, load_run, navigated_to, + parse_args, resolve_db) + +EMAIL = "alice.j@test.com" +NAME = "Atlas b-roll selects.mov" +COLLABS = ("bob.c@test.com", "carol.d@test.com") + +def main(): + a = parse_args() + j = Judge("MEGA--5", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + seed_item = cloud_item_named(init, EMAIL, NAME) if init else None + item = cloud_item_named(after, EMAIL, NAME) if after else None + slug = (item or seed_item or [None, None, ""])[2] + j.bind_run(t, require_answer=False, shot_url=f"/cloud/item/{slug}" if slug else "/cloud") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_file", bool(slug) and navigated_to(t, f"/cloud/item/{slug}"), + f"opened {NAME} slug={slug!r}") + j.check("seed_unshared", seed_item is not None and not (seed_item[7] or "").strip() + and not (seed_item[8] or "").strip(), + f"seed shared_with={None if not seed_item else seed_item[7]!r}") + j.check("db_item", item is not None, f"after_item={item!r}") + if item: + _id, _n, _slug, folder, _ext, _sz, _fav, shared, link, _typ = item + j.check("folder_media_archive", folder == "/Media Archive", f"folder={folder!r}") + j.check("share_link", bool((link or "").strip()), f"share_link={link!r}") + j.check("shared_bob_carol", emails_listed(shared, COLLABS), f"shared_with={shared!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_6.py b/sites/mega/verify/verify_6.py new file mode 100644 index 00000000..275013ba --- /dev/null +++ b/sites/mega/verify/verify_6.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""MEGA--6: largest Alice MOV is Atlas launch footage.mov; mark it favorite.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, cloud_item_named, cloud_items_for, + final_answer, llm_text_match, load_run, navigated_to, + parse_args, resolve_db) + +EMAIL = "alice.j@test.com" +NAME = "Atlas launch footage.mov" + +def main(): + a = parse_args() + j = Judge("MEGA--6", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + ref = init or after + movs = cloud_items_for(ref, EMAIL, extension="mov") if ref else None + largest = None + if movs: + largest = max(movs, key=lambda row: float(row[5] or 0)) + j.check("db_largest_mov", largest is not None and largest[1] == NAME, + f"largest={None if not largest else largest[1:]}") + slug = (cloud_item_named(after or init, EMAIL, NAME) or [None, None, ""])[2] + j.bind_run(t, shot_url=f"/cloud/item/{slug}" if slug else "/cloud") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_drive", navigated_to(t, "/cloud") or navigated_to(t, "/drive"), "opened Cloud drive") + j.check("nav_file", bool(slug) and navigated_to(t, f"/cloud/item/{slug}"), f"opened {NAME}") + j.check("answer_filename", affirms_any(fa, ["atlas launch footage", NAME.lower()]), f"final={fa!r}") + seed = cloud_item_named(init, EMAIL, NAME) if init else None + item = cloud_item_named(after, EMAIL, NAME) if after else None + j.check("seed_not_favorite", seed is not None and not seed[6], f"seed favorite={None if not seed else seed[6]}") + j.check("after_favorite", item is not None and bool(item[6]), f"after favorite={None if not item else item[6]}") + ok, ev = llm_text_match(fa, NAME, "Which MOV file in the account is largest?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_7.py b/sites/mega/verify/verify_7.py new file mode 100644 index 00000000..b67f53f6 --- /dev/null +++ b/sites/mega/verify/verify_7.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""MEGA--7: create Q3 Press Kit under /Projects/Atlas and upload press-summary.pdf 4.2 MB.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, cloud_items_for, load_run, navigated_to, parse_args, + resolve_db) + +EMAIL = "alice.j@test.com" +FOLDER_NAME = "Q3 Press Kit" +PARENT = "/Projects/Atlas" +FILE_NAME = "press-summary.pdf" + +def main(): + a = parse_args() + j = Judge("MEGA--7", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/cloud") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_drive", navigated_to(t, "/cloud") or navigated_to(t, "/drive"), "opened Cloud drive") + seed_folders = cloud_items_for(init, EMAIL, item_type="folder", name=FOLDER_NAME) if init else None + after_folders = cloud_items_for(after, EMAIL, item_type="folder", name=FOLDER_NAME) if after else None + j.check("seed_no_folder", seed_folders == [], f"seed_folders={seed_folders!r}") + folder_ok = False + if after_folders: + folder_ok = any(row[3] == PARENT and row[1].casefold() == FOLDER_NAME.casefold() + for row in after_folders) + j.check("db_folder", folder_ok, f"folders={after_folders!r}") + seed_files = cloud_items_for(init, EMAIL, name=FILE_NAME) if init else None + after_files = cloud_items_for(after, EMAIL, name=FILE_NAME) if after else None + j.check("seed_no_file", seed_files == [], f"seed_files={seed_files!r}") + file_ok = False + nested = f"{PARENT}/{FOLDER_NAME}" + if after_files: + for row in after_files: + size = float(row[5] or 0) + if abs(size - 4.2) > 0.05: + continue + folder = (row[3] or "").casefold() + if "q3 press kit" in folder: + file_ok = True + j.check("db_file", file_ok, f"files={after_files!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_8.py b/sites/mega/verify/verify_8.py new file mode 100644 index 00000000..13fcdb15 --- /dev/null +++ b/sites/mega/verify/verify_8.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""MEGA--8: new vault entry for Atlas staging site.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, load_run, navigated_to, parse_args, resolve_db, + vault_items_for, vault_named) + +EMAIL = "alice.j@test.com" + +def _match(row): + _id, title, username, category, strength, two_factor = row[:6] + title_ok = "atlas staging" in (title or "").casefold() + return (title_ok and (username or "").casefold() == "alice_editor" + and (category or "").casefold() == "client" + and (strength or "").casefold() == "strong" + and bool(two_factor)) + +def main(): + a = parse_args() + j = Judge("MEGA--8", a.no_llm) + t = load_run(a.run_dir) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.bind_run(t, require_answer=False, shot_url="/vault") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_vault", navigated_to(t, "/vault") or navigated_to(t, "/pass"), "opened MEGA Pass vault") + before = vault_items_for(init, EMAIL) if init else None + after_items = vault_items_for(after, EMAIL) if after else None + j.check("seed_no_match", before is not None and not any(_match(row) for row in before), + f"seed={before!r}") + j.check("db_new_entry", after_items is not None and any(_match(row) for row in after_items), + f"after={after_items!r}") + # Near-miss: Client CMS already uses alice_editor without 2FA / Reused. + cms = vault_named(after, EMAIL, "Client CMS") if after else None + if cms is not None: + j.check("did_not_mutate_cms", (cms[4] or "").casefold() == "reused" and not cms[5], + f"client_cms={cms!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_9.py b/sites/mega/verify/verify_9.py new file mode 100644 index 00000000..d2fef76d --- /dev/null +++ b/sites/mega/verify/verify_9.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""MEGA--9 (read-only): old vendor vault entry without 2FA is Old vendor FTP.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (Judge, affirms_any, final_answer, llm_text_match, load_run, + navigated_to, parse_args, resolve_db, tables_unchanged, + vault_named) + +EMAIL = "alice.j@test.com" + +def main(): + a = parse_args() + j = Judge("MEGA--9", a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + after = resolve_db(a.after_db, a.container, "instance") + j.check("db_read_only", tables_unchanged(init, after) == [], + f"changed={tables_unchanged(init, after)!r}") + j.bind_run(t, shot_url="/vault") + j.check("nav_login", navigated_to(t, "/login"), "logged in") + j.check("nav_vault", navigated_to(t, "/vault"), "opened MEGA Pass vault") + row = vault_named(init or after, EMAIL, "Old vendor FTP") + j.check("db_old_vendor", row is not None and not row[5], f"row={row!r}") + j.check("answer_old_vendor_ftp", affirms_any(fa, ["old vendor ftp", "vendor ftp"]), f"final={fa!r}") + ok, ev = llm_text_match(fa, "Old vendor FTP", + "Which old vendor vault entry does not have two-factor authentication?") + j.check("answer_llm", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/mega/verify/verify_lib.py b/sites/mega/verify/verify_lib.py new file mode 100644 index 00000000..ed552db1 --- /dev/null +++ b/sites/mega/verify/verify_lib.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic + LLM utilities for MEGA task verification. + +Philosophy: DETERMINISTIC FIRST. + 1. Trajectory navigation check (anti knowledge-shortcut): the agent MUST have + opened the relevant on-site page; a correct answer with no matching navigation + is a memory-recall shortcut = FAIL. + 2. Answer check: exact / regex / token-containment / number match against frozen + ground truth. + 3. DB after-state check (stateful tasks): query the SQLite instance DB directly — + the strongest deterministic signal (orders, cloud items, vault entries, + tickets, account profile fields). + 4. LLM utilities (text match, screenshot-contains) are used ONLY where exact + matching is brittle, and are ALWAYS anchored on ground truth: the model + verifies *presence* of given content, never supplies knowledge. One call each. + They SKIP (never fail-close) when the LLM is unavailable, so the deterministic + layer stays authoritative. + +Input signature (per task): + --run_dir DIR agent trajectory dir: trajectory.json + screenshots/step_NNN.png + --initial_db PATH initial-state SQLite DB. Optional: when omitted the verifier fetches + ://instance_seed/.db + --after_db PATH after-state SQLite DB. Optional: when omitted the verifier fetches + ://instance/.db + (the container is --container, else $WH_CONTAINER, else "wh-review") + --container NAME docker container to fetch DBs from (default: $WH_CONTAINER or wh-review) + --site NAME site directory inside the container (default: $WH_SITE or mega) + --no_llm skip LLM-based checks (run deterministic-only) + +The documented harness call (`eval_judge.py --run_dir DIR --verifier True`) passes only +--run_dir, so the DB arguments must stay optional and the container fetch is the default +path. An explicit --initial_db/--after_db always wins, which is what the offline negative +sample matrix uses. Every check stays fail-closed: if a DB cannot be obtained, the checks +that need it FAIL, and the process exits 1 with a structured JSON verdict. +Output: JSON {task_id, pass, reason, evidence[]} to stdout; exit 0 on PASS, 1 on FAIL. +""" +import argparse, base64, hashlib, json, os, re, shutil, sqlite3, subprocess, sys, tempfile, urllib.request +from pathlib import Path + +SITE = "mega" +WEBSYN_DIR = "/opt/WebSyn" +_DB_CACHE: dict = {} +_RUN_ERRORS: list = [] + +# ---------------------------------------------------------------- trajectory +def _empty_run(run_dir, error): + _RUN_ERRORS.append(str(error)) + return {"steps": [], "final_answer": "", "_shots": {}, "_run_dir": Path(run_dir), + "_load_error": str(error)} + + +def load_run(run_dir): + """Load trajectory.json + screenshots/, or return an empty run carrying the error. + + Never raises: a missing or malformed run directory must produce a structured + FAIL verdict (Judge.emit forces it), not a traceback. + """ + d = Path(run_dir) + trajectory_file = d / "trajectory.json" + try: + raw = trajectory_file.read_text() + except OSError as error: + return _empty_run(run_dir, f"cannot read {trajectory_file}: {error}") + try: + traj = json.loads(raw) + except ValueError as error: + return _empty_run(run_dir, f"trajectory.json is not valid JSON: {error}") + if not isinstance(traj, dict): + return _empty_run(run_dir, f"trajectory.json must be an object, got {type(traj).__name__}") + if not isinstance(traj.get("steps", []), list): + return _empty_run(run_dir, "trajectory.json: 'steps' must be a list") + traj["_run_dir"] = d + shots_dir = d / "screenshots" + try: + traj["_shots"] = {p.name: p for p in sorted(shots_dir.glob("step_*.png"))} + except OSError as error: + traj["_shots"] = {} + _RUN_ERRORS.append(f"cannot list {shots_dir}: {error}") + return traj + + +def run_complete(traj, require_answer=True): + """(ok, note): the trajectory is a finished run, not a truncated tail. + + agent_demo writes `terminated`, `termination_reason` and a final `done` step + (with the self-reported answer). A trajectory whose last step is not that + `done` step is a truncated recording, and one that ended on max_steps never + finished the task. + """ + steps = traj.get("steps") or [] + if not steps: + return False, "trajectory has no steps" + last = steps[-1] + terminated = traj.get("terminated") + reason = traj.get("termination_reason") + if terminated is not True: + return False, f"trajectory is not terminated (terminated={terminated!r}, reason={reason!r})" + if reason != "agent_done": + return False, f"run did not finish with agent_done (termination_reason={reason!r})" + if last.get("action") != "done": + return False, f"the last step is {last.get('action')!r}, not the final 'done' step (truncated recording)" + if require_answer and not (traj.get("final_answer") or "").strip(): + return False, "the final 'done' step carries no answer" + return True, f"terminated with agent_done after {len(steps)} steps" + + +def run_load_errors(): + return list(_RUN_ERRORS) + +def step_urls(traj): + return [s.get("url", "") for s in traj.get("steps", [])] + +def navigated_to(traj, substr, times=1): + """Deterministic: at least `times` trajectory steps have a URL containing substr.""" + return sum(1 for u in step_urls(traj) if substr in u) >= times + +def navigated_any(traj, substrs): + return any(navigated_to(traj, s) for s in substrs) + + +def url_path(url): + match = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/?#]+([^?#]*)", (url or "").strip()) + if match: + return match.group(1) or "/" + return (url or "").split("?", 1)[0] + + +def navigated_path(traj, path): + """True when some step URL path equals `path` (no substring false positives).""" + want = (path or "/").rstrip("/") or "/" + return any((url_path(u).rstrip("/") or "/") == want for u in step_urls(traj)) + + +# ---------------------------------------------------------------- origin binding +# Evidence URLs must point at the mirror that is being graded, not at the live +# upstream site or any other host with the same path layout. Deployments that +# serve the mirrors on a LAN address can extend the set with WH_ALLOWED_HOSTS +# (comma separated host[:port] entries). +LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"} + + +def url_host(url): + """Host[:port] of an absolute URL, else None.""" + match = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", (url or "").strip()) + return match.group(1).lower() if match else None + + +def _host_only(host): + if not host: + return None + if host.startswith("["): + return host.split("]")[0] + "]" + return host.split(":")[0] + + +def allowed_hosts(): + extra = {h.strip().lower() for h in os.environ.get("WH_ALLOWED_HOSTS", "").split(",") if h.strip()} + return LOCAL_HOSTS | extra + + +def origin_ok(traj, extra_hosts=()): + """(ok, note): every URL in the trajectory must belong to a local mirror origin.""" + allowed = allowed_hosts() | {h.lower() for h in extra_hosts} + allowed_bare = {bare for bare in (_host_only(h) for h in allowed) if bare} + seen = [] + for url in [traj.get("start_url", "")] + [s.get("url", "") for s in traj.get("steps", [])]: + host = url_host(url) + if host: + seen.append(host) + bad = sorted({h for h in seen if h not in allowed and _host_only(h) not in allowed_bare}) + if bad: + return False, (f"trajectory URLs point outside the local mirror: {bad} " + f"(allowed hosts: {sorted(allowed_bare)})") + return True, f"hosts={sorted(set(seen))}" + +def final_answer(traj): + return (traj.get("final_answer") or "").strip() + +def _shot(traj, name): + if not name: + return None + p = traj["_shots"].get(Path(name).name) + return p if (p and p.exists()) else None + +def shot_after_url(traj, substr): + for s in traj.get("steps", []): + if substr in s.get("url", ""): + p = _shot(traj, s.get("screenshot_after")) + if p: + return p + return None + +def last_shot(traj): + for s in reversed(traj.get("steps", [])): + p = _shot(traj, s.get("screenshot_after")) or _shot(traj, s.get("screenshot_before")) + if p: + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + + +# ---------------------------------------------------------------- screenshot evidence +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def png_size(path): + """(width, height) of a decodable PNG, else None. + + Reads the IHDR chunk directly so the check stays dependency-free. + """ + if not path: + return None + try: + data = Path(path).read_bytes() + except OSError: + return None + if len(data) < 33 or data[:8] != PNG_SIGNATURE or data[12:16] != b"IHDR": + return None + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if width <= 0 or height <= 0: + return None + return width, height + + +def screenshot_ok(path, min_w=200, min_h=120, min_bytes=2000): + """(ok, note) for one screenshot file: exists, decodable PNG, plausible size.""" + if not path: + return False, "no screenshot recorded" + try: + size = Path(path).stat().st_size + except OSError as error: + return False, f"unreadable: {error}" + dims = png_size(path) + if dims is None: + return False, f"not a decodable PNG ({path.name}, {size} bytes)" + if dims[0] < min_w or dims[1] < min_h: + return False, f"too small: {dims[0]}x{dims[1]} ({path.name})" + if size < min_bytes: + return False, f"too few bytes: {size} ({path.name})" + return True, f"{path.name} {dims[0]}x{dims[1]} {size}B" + + +def shot_at(traj, url_substr, min_w=200, min_h=120, min_bytes=2000): + """Screenshot bound to a step whose url contains `url_substr`. + + The recorder captures `screenshot_before` for the page the step was decided + on, i.e. the page whose URL that step carries, so that frame is the evidence + that the page was actually rendered. `screenshot_after` is accepted as a + fallback. Returns (ok, note) and fails when the page has no valid frame. + """ + tried = [] + for step in traj.get("steps", []): + if url_substr not in (step.get("url") or ""): + continue + for field in ("screenshot_before", "screenshot_after"): + path = _shot(traj, step.get(field)) + if not path: + continue + ok, note = screenshot_ok(path, min_w, min_h, min_bytes) + if ok: + return True, f"{url_substr} -> {note}" + tried.append(f"{path.name}: {note}") + if tried: + return False, f"no valid screenshot for {url_substr} ({' ; '.join(tried[:4])})" + return False, f"no trajectory step ever recorded a screenshot for {url_substr}" + + +def shot_final(traj, min_w=200, min_h=120, min_bytes=2000): + """Screenshot of the final page (the state the agent finished on).""" + ok, note = screenshot_ok(last_shot(traj), min_w, min_h, min_bytes) + return ok, ("final screenshot " + note) + + +def shot_distinct(traj, minimum=3): + """Distinct frames among the screenshots the steps reference. + + Every recorded step must bring its own frame; a run that pastes a single + image into every step slot (or reuses one frame for the whole run) cannot + claim to have rendered the visited pages. `minimum` is floored by half of the + referenced frames so short runs are not penalised. + """ + frames = [] + for step in traj.get("steps", []): + for field in ("screenshot_before", "screenshot_after"): + path = _shot(traj, step.get(field)) + if not path: + continue + try: + frames.append(hashlib.sha256(Path(path).read_bytes()).hexdigest()) + except OSError: + continue + required = min(minimum, max(1, len(frames) // 2)) + distinct = len(set(frames)) + return distinct >= required, f"distinct frames={distinct} of {len(frames)} referenced (required {required})" + +# ---------------------------------------------------------------- deterministic answer match +def norm(s): + return re.sub(r"\s+", " ", (s or "").strip()).casefold() + +def answer_equals(final, expected): + return norm(final) == norm(expected) + +def contains_all(final, tokens): + f = norm(final) + return all(norm(t) in f for t in tokens) + +def contains_any(final, tokens): + f = norm(final) + return any(norm(t) in f for t in tokens) + + +# ---------------------------------------------------------------- negation-aware match +# An answer that names the expected value inside a negation ("the metric is not +# Classification Accuracy") must not count as an answer. A cue only negates when it +# sits in the same clause, so the search window stops at the previous clause +# separator ("not RMSE but Classification Accuracy" still affirms the metric). +NEGATION_CUES = ("not", "n't", "never", "without", "rather than", "instead of", + "other than", "except", "excluding", "neither", "nor", "false", + "incorrect", "wrong", "invalid", "untrue", "cannot", "can't") +CLAUSE_SEPARATORS = (";", ".", "!", "?", ",", "\n", " but ", " however", " whereas", + " although", " and ", " yet ", " though", " while ") +NEGATION_WINDOW = 48 + + +def _clause_before(text, index, window=NEGATION_WINDOW): + chunk = text[max(0, index - window):index].casefold() + cut = 0 + for marker in CLAUSE_SEPARATORS: + position = chunk.rfind(marker) + if position >= 0: + cut = max(cut, position + len(marker)) + return chunk[cut:] + + +def _negated(text, index): + clause = _clause_before(text, index) + for cue in NEGATION_CUES: + position = clause.rfind(cue) + if position < 0: + continue + before = clause[position - 1] if position > 0 else " " + after_index = position + len(cue) + after = clause[after_index] if after_index < len(clause) else " " + if not before.isalnum() and not after.isalnum(): + return True + return False + + +def affirms(text, token): + """True when `token` occurs in `text` and at least once not inside a negation.""" + if not text or not token: + return False + low, needle = text.casefold(), token.casefold() + start = 0 + while True: + index = low.find(needle, start) + if index < 0: + return False + if not _negated(text, index): + return True + start = index + max(1, len(needle)) + + +def affirms_any(text, tokens): + return any(affirms(text, token) for token in tokens) + + +def affirms_regex(text, pattern): + """True when `pattern` matches `text` and the match is not inside a negation.""" + if not text: + return False + for match in re.finditer(pattern, text): + if not _negated(text, match.start()): + return True + return False + + +def affirms_number(final, n): + """contains_number() with negation awareness.""" + return affirms_regex(final or "", rf"(?" + s = out.strip() + if not s: + return None, "" + return s.upper().startswith("PASS"), s + +def llm_text_match(agent_answer, ground_truth, question): + if _NO_LLM: + return None, "[skipped: --no_llm]" + out = _chat([{"role": "user", "content": + f"You are a STRICT binary grader.\nQuestion: {question}\n" + f"Ground-truth answer (ANCHOR — judge against THIS, never use your own knowledge): {ground_truth}\n" + f"Agent's answer: {agent_answer}\n" + f"Decide PASS or FAIL ignoring case/punctuation/word order/surrounding prose. " + f"PASS only if the agent's answer is consistent with the ground truth AND actually answers the question. " + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}]) + return _verdict(out) + +def llm_screenshot_shows(shot_path, must_show, question=""): + if _NO_LLM: + return None, "[skipped: --no_llm]" + b64 = base64.b64encode(Path(shot_path).read_bytes()).decode() + out = _chat([{"role": "user", "content": [ + {"type": "text", "text": + f"You are a STRICT binary grader. Only what is VISIBLY rendered in this screenshot counts.\n" + f"Question the page should answer: {question}\n" + f"Expected content to verify PRESENCE of: {must_show}\n" + f"PASS only if the expected content (or a semantically equivalent on-screen answer) is visibly shown. " + f"Do NOT use prior knowledge — judge only the rendered pixels.\n" + f"Line 1: PASS or FAIL. Line 2: quote the visible evidence."}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]) + return _verdict(out) + +# ---------------------------------------------------------------- judge harness + CLI +class Judge: + def __init__(self, task_id, no_llm=False): + global _NO_LLM + _NO_LLM = bool(no_llm) + self.task_id = task_id + self.no_llm = no_llm + self.ok = True + self.reason = "" + self.evidence = [] + + def check(self, name, cond, evidence="", llm=False): + if llm and (self.no_llm or cond is None): + why = "--no_llm" if self.no_llm else "LLM unavailable" + self.evidence.append(f"[SKIP] {name} ({why}): {evidence}") + return True + if cond: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(cond) + + def emit(self): + # A missing or malformed run directory is a structured FAIL, never a traceback. + errors = run_load_errors() + if errors: + self.ok = False + self.reason = "run_dir_unreadable" + self.evidence.insert(0, f"[FAIL] run_dir_readable: {'; '.join(errors)}") + print(json.dumps({"task_id": self.task_id, "pass": self.ok, + "reason": self.reason, "evidence": self.evidence}, indent=2)) + sys.exit(0 if self.ok else 1) + + def bind_run(self, traj, require_answer=True, shot_url=None): + """Harness gates every task shares: completed run, local origin, real screenshots.""" + complete_ok, complete_note = run_complete(traj, require_answer=require_answer) + self.check("run_complete", complete_ok, complete_note) + origin_note_ok, origin_note = origin_ok(traj) + self.check("nav_origin_local", origin_note_ok, origin_note) + if shot_url: + shot_ok, shot_note = shot_at(traj, shot_url) + self.check("shot_target_page", shot_ok, shot_note) + final_ok, final_note = shot_final(traj) + self.check("shot_final_page", final_ok, final_note) + distinct_ok, distinct_note = shot_distinct(traj) + self.check("shot_frames_distinct", distinct_ok, distinct_note) + + +def emails_listed(text, emails): + blob = (text or "").casefold() + return all(email.casefold() in blob for email in emails) + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + # Optional: eval_judge.py --verifier True invokes the verifier with --run_dir only, + # so the DBs default to a fetch from the mirror container (see resolve_db). + parser.add_argument("--initial_db", default=None) + parser.add_argument("--after_db", default=None) + parser.add_argument("--container", default=None) + parser.add_argument("--site", default=None) + parser.add_argument("--no_llm", nargs="?", const="true", default="false") + args = parser.parse_args() + args.no_llm = str(args.no_llm).casefold() in {"1", "true", "yes", "on"} + if args.site: + os.environ["WH_SITE"] = args.site + if args.container: + os.environ["WH_CONTAINER"] = args.container + return args From 2a89be16097ff7036aef7f0e36b5043f393b35c9 Mon Sep 17 00:00:00 2001 From: Lxr-max Date: Sat, 19 Sep 2026 10:22:47 +0000 Subject: [PATCH 4/4] review(mega): re-slot onto current main (index 45 / port 40045) Append mega after y_combinator so existing ports stay put (Google Flights 40007, ESPN 40014, Cookpad 40040). Leave the HF pin SHA unchanged; mega.tar.gz is still waiting on open dataset PR #87. Co-authored-by: Xuanrui Li --- .assets-revision | 7 +++-- .claude/skills/clone-website/SKILL.md | 4 +-- .claude/skills/evolve-env/SKILL.md | 6 ++-- .claude/skills/harden-env/SKILL.md | 2 +- .claude/skills/review-env/SKILL.md | 8 ++--- .claude/skills/seed-database/SKILL.md | 4 +-- AGENTS.md | 12 ++++---- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- Dockerfile | 4 +-- README.md | 17 ++++++----- agent_demo/README.md | 2 +- control_server.py | 2 +- sites/drugs_com/tests/test_integration.py | 13 ++++---- sites/mega/tasks.jsonl | 36 +++++++++++------------ sites/mega/verify/test_verifiers.py | 2 +- websyn_start.sh | 2 +- 17 files changed, 66 insertions(+), 59 deletions(-) diff --git a/.assets-revision b/.assets-revision index a090dfd4..72e960a8 100644 --- a/.assets-revision +++ b/.assets-revision @@ -1,12 +1,15 @@ # Hugging Face dataset holding WebHarbor's heavy static assets. # Pin an immutable merged commit SHA, never a moving branch or refs/pr/. # -# All 45 registered sites use this immutable dataset-main revision. +# This pin covers the previous 45 registered sites. MEGA is registered in +# this checkout at index 45 / port 40045, but mega.tar.gz is not on this +# revision (HF dataset PR #87 is still open). Do not bump the pin until that +# PR is merged and the combined archive set is re-validated. # The previous global revision f3ecd60ea35795eef566ee19d67140c16b200f26 contains 45 archives: 44 registered-site archives plus the unregistered Bandcamp archive. All 45 are byte-for-byte unchanged here. # Merged HF PR #83 adds y_combinator.tar.gz: 121,085,653 bytes, SHA-256 43c666bda84680f327ce64720b2baa2f9e427acb751fe81900967f267e9176f4, with 3,973 managed archive members and 3,971 inventoried runtime assets. # This revision descends from f3ecd60ea35795eef566ee19d67140c16b200f26, which includes the Versus archive added after 555a9aa0b02946a8bdf873ba1d59902b71564a07. # Drugs.com retains the corrected archive from HF #102, including 13 attributed DailyMed labels, 13 packaging images and its catalog. -# assets-manifest.json binds every registered-site archive and the extracted managed tree. +# assets-manifest.json binds every archive on this pin and the extracted managed tree. # The unregistered Bandcamp archive is ignored by fetch_assets.sh. # Tracked seed migrations/build-generation remain part of the build contract. repo: ChilleD/WebHarbor diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index 054fee08..f4621266 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 45 sites still return 200 on the alt-port container +- All 46 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index 6b82f4f4..104c241b 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40044) +# 1. Stop test container (don't touch user's working container on :40000-40045) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 45 sites still return 200 +- All 46 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index 816c4562..82dd9cf6 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index da88e76c..3a1de21e 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,17 +33,17 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 45 sites (40000-40044). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 46 sites (40000-40045). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 45 sites return 200 +# 1. all 46 sites return 200 for p in $(seq 41000 41044); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 45 sites return 200 +- [x] All 46 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index e50cefc4..58165759 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,9 +169,9 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev -# all 45 sites return 200 +# all 46 sites return 200 for p in $(seq 41000 41044); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/AGENTS.md b/AGENTS.md index c0e935d0..b849ea09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -45 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +46 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -59,17 +59,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -e WEBSYN_CONTROL_TOKEN -d -p 8101:8101 -p 40000-40044:40000-40044 webharbor:dev +docker run -e WEBSYN_CONTROL_TOKEN -d -p 8101:8101 -p 40000-40045:40000-40045 webharbor:dev ``` Or use the published image directly: ```bash -docker run -e WEBSYN_CONTROL_TOKEN -d -p 8101:8101 -p 40000-40044:40000-40044 \ +docker run -e WEBSYN_CONTROL_TOKEN -d -p 8101:8101 -p 40000-40045:40000-40045 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40044` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40045` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -147,13 +147,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -e WEBSYN_CONTROL_TOKEN -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41044:40000-40044 webharbor:dev + -p 8201:8101 -p 41000-41045:40000-40045 webharbor:dev # 4. control plane healthy, all sites alive curl -s -H "Authorization: Bearer $WEBSYN_CONTROL_TOKEN" http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41044); do +for p in $(seq 41000 41045); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 46733760..d753253b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40044`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41044`). +If a container is already running on `:8101` / `:40000-40045`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41045`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab468602..2c4bd807 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -e WEBSYN_CONTROL_TOKEN -d --rm \ - -p 8101:8101 -p 40000-40044:40000-40044 webharbor:dev + -p 8101:8101 -p 40000-40045:40000-40045 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index c6c7ff92..d02dc63c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 45 Flask mirror sites + control plane on :8101. +# 46 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm@sha256:782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254 @@ -156,6 +156,6 @@ RUN cd /opt/WebSyn/y_combinator && rm -rf instance instance_seed && \ # Fail closed after all registered-site seed migrations/generators. RUN python3 /opt/check_seed_databases.py /opt/WebSyn -EXPOSE 8101 40000-40044 +EXPOSE 8101 40000-40045 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index c76ea62b..383ed5fc 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 45 sites today, scaling to 100+ together +- **Community-driven** — 46 sites today, scaling to 100+ together ## 🚀 Quickstart @@ -55,10 +55,10 @@ Build this checkout to run its registered web environments (published image tags ```bash ./scripts/build.sh webharbor:dev -docker run -e WEBSYN_CONTROL_TOKEN -p 8101:8101 -p 40000-40044:40000-40044 webharbor:dev +docker run -e WEBSYN_CONTROL_TOKEN -p 8101:8101 -p 40000-40045:40000-40045 webharbor:dev ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40044` to explore 45 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, UC Berkeley, B&H Photo, AccuWeather, GOV.UK, IMDb, NBA, Recreation.gov, BoardGameGeek, CarMax, BabyCenter, Amtrak, Cookpad, Craigslist, Drugs.com, Versus, and Y Combinator`. +Then point your agent at `http://localhost:40000` through `http://localhost:40045` to explore 46 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, UC Berkeley, B&H Photo, AccuWeather, GOV.UK, IMDb, NBA, Recreation.gov, BoardGameGeek, CarMax, BabyCenter, Amtrak, Cookpad, Craigslist, Drugs.com, Versus, Y Combinator, and MEGA`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -77,7 +77,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ### Site registry -This checkout registers **45 sites**. NVIDIA remains at index 28, UC Berkeley remains at index 29, B&H Photo remains at index 30, AccuWeather remains at index 31, GOV.UK remains at index 32, IMDb remains at index 33 and NBA remains at index 34. Recreation.gov remains at index 35, BoardGameGeek remains at index 36, CarMax remains at index 37, BabyCenter remains at index 38, Amtrak remains at index 39, Cookpad remains at index 40, Craigslist remains at index 41, Drugs.com remains at index 42, Versus remains at index 43, and Y Combinator is appended at index 44. Build the image from this checkout to use this registry; publishing source does not update the published Docker image automatically. +This checkout registers **46 sites**. NVIDIA remains at index 28, UC Berkeley remains at index 29, B&H Photo remains at index 30, AccuWeather remains at index 31, GOV.UK remains at index 32, IMDb remains at index 33 and NBA remains at index 34. Recreation.gov remains at index 35, BoardGameGeek remains at index 36, CarMax remains at index 37, BabyCenter remains at index 38, Amtrak remains at index 39, Cookpad remains at index 40, Craigslist remains at index 41, Drugs.com remains at index 42, Versus remains at index 43, Y Combinator remains at index 44, and MEGA is appended at index 45. Build the image from this checkout to use this registry; publishing source does not update the published Docker image automatically. | Site | Registry position | Container port | Example local review host port | | --- | --- | --- | --- | @@ -98,9 +98,10 @@ This checkout registers **45 sites**. NVIDIA remains at index 28, UC Berkeley re | Drugs.com | 42 | 40042 | 48042 | | Versus | 43 | 40043 | 48043 | | Y Combinator | 44 | 40044 | 48044 | +| MEGA | 45 | 40045 | 48045 | `websyn_start.sh`, `control_server.py`, the `Dockerfile` `EXPOSE` line and every -site's `tasks.jsonl` `web` URL agree on 45 sites and `40000-40044`; +site's `tasks.jsonl` `web` URL agree on 46 sites and `40000-40045`; `scripts/check_site_registry.py` (run by `scripts/check_assets.sh`) fails when they drift. @@ -108,7 +109,7 @@ After preparing the candidate assets and building `webharbor:dev`, the local review deployment uses: ```bash -docker run -e WEBSYN_CONTROL_TOKEN -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48044:40000-40044 webharbor:dev +docker run -e WEBSYN_CONTROL_TOKEN -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48045:40000-40045 webharbor:dev ``` NVIDIA inherits the site contribution from @KaKituken @@ -129,7 +130,9 @@ The bundle contains 78 articles and 62 structured guidance sections. That archiv is part of the consolidated pinned dataset revision below. -The current `.assets-revision` consolidates all **45** registered sites at merged HF commit `eedc1a7f85118ab835eac38e04678fa85d141973`. All 45 archives from the previous global revision `f3ecd60ea35795eef566ee19d67140c16b200f26`—44 registered-site archives plus the unregistered Bandcamp archive—are byte-identical; merged HF PR #83 adds `y_combinator.tar.gz` (121,085,653 bytes, SHA-256 `43c666bda84680f327ce64720b2baa2f9e427acb751fe81900967f267e9176f4`). The previous revision already includes the reviewed Versus archive from HF PR #90. Drugs.com retains its 13 attributed DailyMed labels and packaging images from HF #101 with the archive-root correction from [HF #102](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/102). `assets-manifest.json` binds all selected archive hashes and the extracted tree. The unregistered Bandcamp archive is not fetched. Seed migrations and build-generated seeds remain part of the build contract. +The current `.assets-revision` still pins merged HF commit `eedc1a7f85118ab835eac38e04678fa85d141973`, which contains archives for the previous **45** registered sites. All 45 archives from the previous global revision `f3ecd60ea35795eef566ee19d67140c16b200f26`—44 registered-site archives plus the unregistered Bandcamp archive—are byte-identical; merged HF PR #83 adds `y_combinator.tar.gz` (121,085,653 bytes, SHA-256 `43c666bda84680f327ce64720b2baa2f9e427acb751fe81900967f267e9176f4`). The previous revision already includes the reviewed Versus archive from HF PR #90. Drugs.com retains its 13 attributed DailyMed labels and packaging images from HF #101 with the archive-root correction from [HF #102](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/102). `assets-manifest.json` binds all selected archive hashes and the extracted tree. The unregistered Bandcamp archive is not fetched. Seed migrations and build-generated seeds remain part of the build contract. + +MEGA is registered at index 45 / port 40045, but `.assets-revision` is **not** bumped: `mega.tar.gz` is still waiting on open [HF dataset PR #87](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/87). `scripts/fetch_assets.sh` / `scripts/check_assets.sh` will fail for `sites/mega` until that PR is merged onto `ChilleD/WebHarbor` `main` and the pin is updated to the immutable merge SHA. Do not pin an unmerged discussion commit. Historical asset integration notes below describe superseded pins, not the current pin. diff --git a/agent_demo/README.md b/agent_demo/README.md index 07463428..d0d71034 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -30,7 +30,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -e WEBSYN_CONTROL_TOKEN -p 8101:8101 -p 40000-40044:40000-40044 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -e WEBSYN_CONTROL_TOKEN -p 8101:8101 -p 40000-40045:40000-40045 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index a378d92f..5ead69bf 100644 --- a/control_server.py +++ b/control_server.py @@ -37,7 +37,7 @@ 'nvidia', 'berkeley', 'bh_photo', 'accuweather', 'gov_uk', 'imdb', 'nba', 'recreation_gov', 'boardgamegeek', 'carmax', 'babycenter', 'amtrak', - 'cookpad', 'craigslist', 'drugs_com', 'versus', 'y_combinator', + 'cookpad', 'craigslist', 'drugs_com', 'versus', 'y_combinator', 'mega', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/drugs_com/tests/test_integration.py b/sites/drugs_com/tests/test_integration.py index 3239eb69..b2cd271b 100644 --- a/sites/drugs_com/tests/test_integration.py +++ b/sites/drugs_com/tests/test_integration.py @@ -24,7 +24,7 @@ "target", "ted", "osu", "rotten_tomatoes", "compass", "walmart_careers", "fedex", "webmd_doctor", "healthline", "kaggle", "nvidia", "berkeley", "bh_photo", "accuweather", "gov_uk", "imdb", "nba", "recreation_gov", - "boardgamegeek", "carmax", "babycenter", "amtrak", "cookpad", "craigslist", "drugs_com", "versus", "y_combinator", + "boardgamegeek", "carmax", "babycenter", "amtrak", "cookpad", "craigslist", "drugs_com", "versus", "y_combinator", "mega", ] @@ -51,7 +51,7 @@ def load_control_server(): return module -def test_exact_45_site_registry_and_ports(): +def test_exact_46_site_registry_and_ports(): assert shell_sites() == control_sites() == EXPECTED assert EXPECTED.index("rotten_tomatoes") + 40000 == 40021 assert EXPECTED.index("compass") + 40000 == 40022 @@ -59,6 +59,7 @@ def test_exact_45_site_registry_and_ports(): assert EXPECTED.index("drugs_com") + 40000 == 40042 assert EXPECTED.index("versus") + 40000 == 40043 assert EXPECTED.index("y_combinator") + 40000 == 40044 + assert EXPECTED.index("mega") + 40000 == 40045 def test_task_manifest_uses_port_40042_and_complete_verifiers(): @@ -71,10 +72,10 @@ def test_task_manifest_uses_port_40042_and_complete_verifiers(): assert all("do not require JSON or verbatim wording" in row["judge_rubric"] for row in rows) -def test_docker_and_docs_use_45_site_range(): +def test_docker_and_docs_use_46_site_range(): dockerfile = (ROOT / "Dockerfile").read_text() - assert "45 Flask mirror sites" in dockerfile - assert "EXPOSE 8101 40000-40044" in dockerfile + assert "46 Flask mirror sites" in dockerfile + assert "EXPOSE 8101 40000-40045" in dockerfile assert "check_asset_inventory.py /opt/WebSyn/drugs_com" in dockerfile assert "cd /opt/WebSyn/drugs_com" in dockerfile assert "check_seed_databases.py /opt/WebSyn" in dockerfile @@ -83,7 +84,7 @@ def test_docker_and_docs_use_45_site_range(): dockerignore = set((ROOT / ".dockerignore").read_text().splitlines()) assert {"**/.env", "**/.env.*", "**/secrets.json", "**/*.pem", "**/*.key"} <= dockerignore for relative in ["README.md", "AGENTS.md", "CONTRIBUTING.md", "CLAUDE.md", "agent_demo/README.md"]: - assert "40000-40044" in (ROOT / relative).read_text(), relative + assert "40000-40045" in (ROOT / relative).read_text(), relative def test_asset_path_contracts_are_synchronized(): diff --git a/sites/mega/tasks.jsonl b/sites/mega/tasks.jsonl index 644ab6f7..9d6fb551 100644 --- a/sites/mega/tasks.jsonl +++ b/sites/mega/tasks.jsonl @@ -1,18 +1,18 @@ -{"web_name": "MEGA", "id": "MEGA--0", "ques": "Open the Cloud storage product page. Quote two of the 'What you can do' highlights that describe how users access or manage their cloud files.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) The trajectory MUST open the Cloud storage product page (/storage), not only the homepage. (2) The answer MUST quote two on-page highlights about accessing or managing files. (3) Empty answer = FAIL. FAIL if /storage was never opened or the highlights are not taken from that page."} -{"web_name": "MEGA", "id": "MEGA--1", "ques": "Search the help center for recovery key guidance, open the article about saving a recovery key, and report who that article says you should not share the recovery key with.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the help article about saving a recovery key (not only the help index). (2) The answer MUST report who the article says not to share the key with. (3) Empty answer = FAIL. FAIL if the article page was never opened or the restriction is missing/wrong."} -{"web_name": "MEGA", "id": "MEGA--2", "ques": "Log in as david.k@test.com (password: TestPass123!). Open the Pro II individual plan (8 TB storage with MEGA VPN listed in its features), add it to the cart with yearly billing, and stop on the checkout review page without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as david.k@test.com. (2) MUST open the Pro II plan page and reach checkout with yearly billing selected. (3) MUST NOT complete payment / create a new order. FAIL if login, Pro II, yearly checkout, or the no-purchase constraint is missing."} -{"web_name": "MEGA", "id": "MEGA--3", "ques": "Log in as alice.j@test.com (password: TestPass123!), open the Pro I plan, choose monthly billing, and complete checkout using Alice's default saved payment method.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Pro I plan and complete checkout with monthly billing and a saved payment method. (3) After-state MUST contain a new Pro I monthly order for Alice. FAIL if checkout was only reviewed, the plan/cycle is wrong, or no order was written."} -{"web_name": "MEGA", "id": "MEGA--4", "ques": "Log in as alice.j@test.com (password: TestPass123!). Open the Pro II and Business Pro plan pages, report which plan includes more users, add that plan using its minimum included seat count, and stop on the checkout review page without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open BOTH Pro II and Business Pro plan pages. (3) The answer MUST identify which of those two plans includes more users. (4) MUST add the larger-user plan at its minimum included seats and stop on checkout without creating a new order. FAIL if either plan page is skipped, the comparison is wrong, or payment is completed."} -{"web_name": "MEGA", "id": "MEGA--5", "ques": "Log in as alice.j@test.com (password: TestPass123!). In Cloud drive, open Atlas b-roll selects.mov in the Media Archive, create a share link, and share it with bob.c@test.com and carol.d@test.com.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Atlas b-roll selects.mov file page in Cloud drive. (3) After-state MUST show a share link plus both requested collaborators. FAIL if a different file was used, the link was not created, or either collaborator is missing."} -{"web_name": "MEGA", "id": "MEGA--6", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search Cloud drive for media files, report which MOV file is largest, open it, and mark it as a favorite.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open Cloud drive / the largest MOV's file page. (2) The answer MUST name that largest MOV. (3) After-state MUST mark that file as a favorite. FAIL if the wrong file is named, the file page was never opened, or favorite was not saved."} -{"web_name": "MEGA", "id": "MEGA--7", "ques": "Log in as alice.j@test.com (password: TestPass123!). Create a folder named Q3 Press Kit inside /Projects/Atlas, then add an uploaded file record named press-summary.pdf with size 4.2 MB in that folder.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com and use Cloud drive folder/upload forms. (2) After-state MUST contain a Q3 Press Kit folder under /Projects/Atlas and press-summary.pdf at 4.2 MB in that folder path. FAIL if either record is missing, the parent path is wrong, or the size is not 4.2 MB."} -{"web_name": "MEGA", "id": "MEGA--8", "ques": "Log in as alice.j@test.com (password: TestPass123!). Add a new MEGA Pass vault entry for the Atlas staging site with username alice_editor, category Client, strong password status, and two-factor authentication enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the MEGA Pass vault. (2) After-state MUST contain a new vault entry whose title refers to the Atlas staging site, username alice_editor, category Client, strength Strong, and 2FA enabled. FAIL if an existing near-miss entry is reused or any required field is wrong."} -{"web_name": "MEGA", "id": "MEGA--9", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search the MEGA Pass vault for weak or reused entries and identify which old vendor entry does not have two-factor authentication enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open / search the MEGA Pass vault. (2) The answer MUST name the old vendor vault entry that has no 2FA. (3) Empty answer = FAIL. FAIL if the vault was never opened or a different weak/reused entry is named."} -{"web_name": "MEGA", "id": "MEGA--10", "ques": "Open Downloads and find the Windows MEGAcmd setup installer (not MEGAsync and not the portable zip). Open its detail page and report the checksum shown there.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Windows MEGAcmd setup installer detail page (not MEGAsync, not the portable zip). (2) The answer MUST report the checksum shown on that page. FAIL if only the downloads index was opened, the wrong package was used, or the checksum is missing/wrong."} -{"web_name": "MEGA", "id": "MEGA--11", "ques": "Open Downloads, find the recommended Chrome browser extension for MEGA Pass, open its detail page, and report the package name and version shown there.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the recommended MEGA Pass Chrome extension detail page. (2) The answer MUST report the package name and version shown on that page. FAIL if a Firefox/Edge extension is used instead, the detail page was skipped, or name/version is missing."} -{"web_name": "MEGA", "id": "MEGA--12", "ques": "Log in as bob.c@test.com (password: TestPass123!). Use the Pricing filters to show object storage plans, open the plan whose features mention an S3-compatible API, and add it to the cart (stop on checkout; do not complete payment).", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST use Pricing with the object-storage filter and open the plan whose features mention an S3-compatible API. (3) MUST add that plan and reach checkout without creating a new order. FAIL if a different object-storage plan is chosen or payment is completed."} -{"web_name": "MEGA", "id": "MEGA--13", "ques": "Open the Business product page and report the highlight that names the team administration surface.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Business product page (/business), not only the homepage card. (2) The answer MUST report the on-page highlight that names the team administration surface. FAIL if /business was never opened or the highlight is missing/wrong."} -{"web_name": "MEGA", "id": "MEGA--14", "ques": "Log in as bob.c@test.com (password: TestPass123!) and submit a High priority Object storage support ticket asking for help estimating S4 egress for quarterly archives.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com and submit the contact/support form. (2) After-state MUST contain a new High-priority Object storage ticket whose subject/message asks about S4 egress for quarterly archives. FAIL if priority/category is wrong or no new ticket exists."} -{"web_name": "MEGA", "id": "MEGA--15", "ques": "Search Help for ransomware recovery, open the matching article, and report the first action it recommends before restoring files.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the ransomware recovery help article, not only the help index. (2) The answer MUST report the first action that article recommends before restoring files. FAIL if a different recovery article is used or the first action is missing/wrong."} -{"web_name": "MEGA", "id": "MEGA--16", "ques": "Log in as alice.j@test.com (password: TestPass123!), update the account profile so the company is Riverlight Studio Labs, and make sure both two-factor authentication and recovery key saved are enabled.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the account edit form. (2) After-state MUST show company Riverlight Studio Labs with 2FA enabled and recovery key marked saved. FAIL if the company string is unchanged/wrong or either security flag is off."} -{"web_name": "MEGA", "id": "MEGA--17", "ques": "Log in as bob.c@test.com (password: TestPass123!). Open the flexible plan for variable cloud workloads that starts with 3 TB base storage and transfer, compare it with the fixed S4 storage plan, add the flexible plan to the cart with yearly billing, and stop on checkout without completing payment.", "web": "http://localhost:40028/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST open both the flexible variable-workload plan and the fixed S4 storage plan. (3) MUST add the flexible plan with yearly billing and stop on checkout without a new order. FAIL if the fixed S4 plan is added instead, yearly billing is missing, or payment is completed."} +{"web_name": "MEGA", "id": "MEGA--0", "ques": "Open the Cloud storage product page. Quote two of the 'What you can do' highlights that describe how users access or manage their cloud files.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) The trajectory MUST open the Cloud storage product page (/storage), not only the homepage. (2) The answer MUST quote two on-page highlights about accessing or managing files. (3) Empty answer = FAIL. FAIL if /storage was never opened or the highlights are not taken from that page."} +{"web_name": "MEGA", "id": "MEGA--1", "ques": "Search the help center for recovery key guidance, open the article about saving a recovery key, and report who that article says you should not share the recovery key with.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) The agent MUST open the help article about saving a recovery key (not only the help index). (2) The answer MUST report who the article says not to share the key with. (3) Empty answer = FAIL. FAIL if the article page was never opened or the restriction is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--2", "ques": "Log in as david.k@test.com (password: TestPass123!). Open the Pro II individual plan (8 TB storage with MEGA VPN listed in its features), add it to the cart with yearly billing, and stop on the checkout review page without completing payment.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as david.k@test.com. (2) MUST open the Pro II plan page and reach checkout with yearly billing selected. (3) MUST NOT complete payment / create a new order. FAIL if login, Pro II, yearly checkout, or the no-purchase constraint is missing."} +{"web_name": "MEGA", "id": "MEGA--3", "ques": "Log in as alice.j@test.com (password: TestPass123!), open the Pro I plan, choose monthly billing, and complete checkout using Alice's default saved payment method.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Pro I plan and complete checkout with monthly billing and a saved payment method. (3) After-state MUST contain a new Pro I monthly order for Alice. FAIL if checkout was only reviewed, the plan/cycle is wrong, or no order was written."} +{"web_name": "MEGA", "id": "MEGA--4", "ques": "Log in as alice.j@test.com (password: TestPass123!). Open the Pro II and Business Pro plan pages, report which plan includes more users, add that plan using its minimum included seat count, and stop on the checkout review page without completing payment.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open BOTH Pro II and Business Pro plan pages. (3) The answer MUST identify which of those two plans includes more users. (4) MUST add the larger-user plan at its minimum included seats and stop on checkout without creating a new order. FAIL if either plan page is skipped, the comparison is wrong, or payment is completed."} +{"web_name": "MEGA", "id": "MEGA--5", "ques": "Log in as alice.j@test.com (password: TestPass123!). In Cloud drive, open Atlas b-roll selects.mov in the Media Archive, create a share link, and share it with bob.c@test.com and carol.d@test.com.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com. (2) MUST open the Atlas b-roll selects.mov file page in Cloud drive. (3) After-state MUST show a share link plus both requested collaborators. FAIL if a different file was used, the link was not created, or either collaborator is missing."} +{"web_name": "MEGA", "id": "MEGA--6", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search Cloud drive for media files, report which MOV file is largest, open it, and mark it as a favorite.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open Cloud drive / the largest MOV's file page. (2) The answer MUST name that largest MOV. (3) After-state MUST mark that file as a favorite. FAIL if the wrong file is named, the file page was never opened, or favorite was not saved."} +{"web_name": "MEGA", "id": "MEGA--7", "ques": "Log in as alice.j@test.com (password: TestPass123!). Create a folder named Q3 Press Kit inside /Projects/Atlas, then add an uploaded file record named press-summary.pdf with size 4.2 MB in that folder.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as alice.j@test.com and use Cloud drive folder/upload forms. (2) After-state MUST contain a Q3 Press Kit folder under /Projects/Atlas and press-summary.pdf at 4.2 MB in that folder path. FAIL if either record is missing, the parent path is wrong, or the size is not 4.2 MB."} +{"web_name": "MEGA", "id": "MEGA--8", "ques": "Log in as alice.j@test.com (password: TestPass123!). Add a new MEGA Pass vault entry for the Atlas staging site with username alice_editor, category Client, strong password status, and two-factor authentication enabled.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the MEGA Pass vault. (2) After-state MUST contain a new vault entry whose title refers to the Atlas staging site, username alice_editor, category Client, strength Strong, and 2FA enabled. FAIL if an existing near-miss entry is reused or any required field is wrong."} +{"web_name": "MEGA", "id": "MEGA--9", "ques": "Log in as alice.j@test.com (password: TestPass123!). Search the MEGA Pass vault for weak or reused entries and identify which old vendor entry does not have two-factor authentication enabled.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open / search the MEGA Pass vault. (2) The answer MUST name the old vendor vault entry that has no 2FA. (3) Empty answer = FAIL. FAIL if the vault was never opened or a different weak/reused entry is named."} +{"web_name": "MEGA", "id": "MEGA--10", "ques": "Open Downloads and find the Windows MEGAcmd setup installer (not MEGAsync and not the portable zip). Open its detail page and report the checksum shown there.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Windows MEGAcmd setup installer detail page (not MEGAsync, not the portable zip). (2) The answer MUST report the checksum shown on that page. FAIL if only the downloads index was opened, the wrong package was used, or the checksum is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--11", "ques": "Open Downloads, find the recommended Chrome browser extension for MEGA Pass, open its detail page, and report the package name and version shown there.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the recommended MEGA Pass Chrome extension detail page. (2) The answer MUST report the package name and version shown on that page. FAIL if a Firefox/Edge extension is used instead, the detail page was skipped, or name/version is missing."} +{"web_name": "MEGA", "id": "MEGA--12", "ques": "Log in as bob.c@test.com (password: TestPass123!). Use the Pricing filters to show object storage plans, open the plan whose features mention an S3-compatible API, and add it to the cart (stop on checkout; do not complete payment).", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST use Pricing with the object-storage filter and open the plan whose features mention an S3-compatible API. (3) MUST add that plan and reach checkout without creating a new order. FAIL if a different object-storage plan is chosen or payment is completed."} +{"web_name": "MEGA", "id": "MEGA--13", "ques": "Open the Business product page and report the highlight that names the team administration surface.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the Business product page (/business), not only the homepage card. (2) The answer MUST report the on-page highlight that names the team administration surface. FAIL if /business was never opened or the highlight is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--14", "ques": "Log in as bob.c@test.com (password: TestPass123!) and submit a High priority Object storage support ticket asking for help estimating S4 egress for quarterly archives.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com and submit the contact/support form. (2) After-state MUST contain a new High-priority Object storage ticket whose subject/message asks about S4 egress for quarterly archives. FAIL if priority/category is wrong or no new ticket exists."} +{"web_name": "MEGA", "id": "MEGA--15", "ques": "Search Help for ransomware recovery, open the matching article, and report the first action it recommends before restoring files.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the ransomware recovery help article, not only the help index. (2) The answer MUST report the first action that article recommends before restoring files. FAIL if a different recovery article is used or the first action is missing/wrong."} +{"web_name": "MEGA", "id": "MEGA--16", "ques": "Log in as alice.j@test.com (password: TestPass123!), update the account profile so the company is Riverlight Studio Labs, and make sure both two-factor authentication and recovery key saved are enabled.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in and open the account edit form. (2) After-state MUST show company Riverlight Studio Labs with 2FA enabled and recovery key marked saved. FAIL if the company string is unchanged/wrong or either security flag is off."} +{"web_name": "MEGA", "id": "MEGA--17", "ques": "Log in as bob.c@test.com (password: TestPass123!). Open the flexible plan for variable cloud workloads that starts with 3 TB base storage and transfer, compare it with the fixed S4 storage plan, add the flexible plan to the cart with yearly billing, and stop on checkout without completing payment.", "web": "http://localhost:40045/", "upstream_url": "https://mega.io/", "verifier_path": "sites/mega/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST log in as bob.c@test.com. (2) MUST open both the flexible variable-workload plan and the fixed S4 storage plan. (3) MUST add the flexible plan with yearly billing and stop on checkout without a new order. FAIL if the fixed S4 plan is added instead, yearly billing is missing, or payment is completed."} diff --git a/sites/mega/verify/test_verifiers.py b/sites/mega/verify/test_verifiers.py index b264a92c..0cb49ada 100644 --- a/sites/mega/verify/test_verifiers.py +++ b/sites/mega/verify/test_verifiers.py @@ -20,7 +20,7 @@ HERE = Path(__file__).resolve().parent SITE = HERE.parent SEED = SITE / "instance_seed" / "mega.db" -BASE = "http://localhost:40028" +BASE = "http://localhost:40045" TASKS = list(range(18)) READ_ONLY = {0, 1, 9, 10, 11, 13, 15} diff --git a/websyn_start.sh b/websyn_start.sh index c2eb9e6a..220bd620 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -14,7 +14,7 @@ SITES=(allrecipes amazon apple arxiv bbc_news booking target ted osu rotten_tomatoes compass walmart_careers fedex webmd_doctor healthline kaggle nvidia berkeley bh_photo accuweather gov_uk imdb nba recreation_gov boardgamegeek carmax babycenter amtrak cookpad craigslist drugs_com - versus y_combinator) + versus y_combinator mega) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR"